1 Коммитов
Автор SHA1 Сообщение Дата
viktor 5f2bdd921d docs: подробный README (разбор проекта при переносе в Gitea)
Старый README сохранён как README.upstream.md.
2026-08-20 04:18:36 +09:00
187 изменённых файлов: 9937 добавлений и 16638 удалений
-40
Просмотреть файл
@@ -1,40 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
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: ${{ 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
shell:
name: Shell scripts
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- run: make lint-shell
-64
Просмотреть файл
@@ -1,64 +0,0 @@
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,9 +91,3 @@ composer.lock
# System files # System files
*.pid *.pid
*.sock *.sock
# Offline Python wheels bundled into system images
wheels/
# рабочие копии агентов и локальные настройки Claude Code
.claude/
-62
Просмотреть файл
@@ -1,62 +0,0 @@
# Virtuality — заметки для Claude Code
Virtuality — простая замена Proxmox для дома и малого офиса: KVM/QEMU/libvirt + собственная
веб-панель на FastAPI, установочный ISO на базе чистого Ubuntu Server 26.04 LTS и мастер
первичной настройки в браузере. Пользователь — обычный человек, не админ.
**Общайся с владельцем проекта по-русски.** Весь текст интерфейса — на простом русском, без жаргона
(термины вроде qcow2, libvirt, nvram — только в раскрывающихся блоках «Для специалистов»).
## Устройство репозитория
- `web/app.py` — основное приложение FastAPI (машины, ISO, образы дисков, сеть, журналы, обновления, консоль).
- `web/core.py` — общие примитивы: `run_cmd`, хранилище операций (`new_operation`, `run_operation`,
`finish_operation`, `active_operations_for`, `interrupt_orphaned_operations`), `render`, `require_auth`,
`valid_vm_name`, шаблоны Jinja. Импорт `core` выставляет `LC_ALL=C.UTF-8` (virsh иначе переводится).
- `web/features/*.py` — модули с `router = APIRouter()`, подключаются в конце `app.py`:
`setup` (мастер `/setup`), `settings` (`/settings`), `snapshots`, `backups`, `downloads` (скачивание
по ссылке и каталог), `vmops` (клон, увеличение диска, пауза/спящий режим, заметки, нагрузка).
POST-маршруты модулей — минимум 4 сегмента (`/vm/{name}/<модуль>/<действие>`), иначе их перехватит
`@app.post("/vm/{name}/{action}")`.
- `web/nodectl.py` — факты об узле и действия через `scripts/virtuality-ctl` (единая точка для
systemd-юнитов, TLS, firewall, моста br0 с автооткатом, диска под хранилище, пароля, часового пояса).
- `web/network_core.py` — NAT-сеть `virtuality-nat` (192.168.100.0/24), резервации DHCP (фиксированный
MAC по имени машины), проброс портов (nftables + ufw + iptables-fallback с меткой `virtuality-forward`).
- `web/catalog.py` — каталог облачных образов и типы ОС; `web/cloudinit.py` — seed cloud-init.
- `web/update_core.py` + `scripts/auto_update_check.sh`, `scripts/apply_github_update.sh`,
`scripts/update_target.sh` — обновления; канал `stable` = последний тег `vX.Y.Z` (пока тегов нет — main),
канал `main` = ветка main.
- `scripts/virtuality_firstboot.sh` — первая загрузка установленной системы: panel → network →
virtualization → tools → finish, прогресс в `/var/lib/virtuality/config/setup.json`.
- `scripts/install_web_panel.sh` — установка/обновление панели (web.prev для отката, офлайн-wheels).
- `install.sh` (установка одной командой), `install_virtuality_node.sh` (пакеты KVM/libvirt).
- `image/build-iso.sh`, `image/autoinstall.yaml`, `image/grub/` — сборка установочного ISO.
- `updates/versions.json` — журнал изменений для центра обновлений; `VERSION` — текущая версия.
## Команды
```bash
make check # pyflakes + shellcheck + pytest
python3 -m pytest -q # тесты (fake virsh в tests/conftest.py)
python3 tests/dev_server.py 8765 # демо-панель, вход: tester / любой пароль
VIRTUALITY_DEV_SETUP=installing python3 tests/dev_server.py # мастер на шаге установки
VIRTUALITY_DEV_SETUP=done|finished ... # мастер после установки / настроенный сервер
make iso # ISO в dist/ (нужны xorriso, git, curl, python3-pip; ~3 ГБ загрузки)
./image/build-iso.sh --help # все параметры сборки (в т.ч. --unattended)
```
Скриншоты: Playwright и Chromium доступны в облачной среде, локально — `npx playwright`.
## Правила
- Код как вокруг: плотность комментариев, имена, идиомы модуля. Не переформатировать чужой код.
- Каждое изменение поведения — с тестом. Перед коммитом: `make check`.
- Коммиты по-русски в стиле «<что> было сделано».
- Работаем прямо в `main` (так решил владелец). Перед каждым пушем — `make check`; не пушить
недоделанное: ноды на канале «Ранний доступ» (`main`) подтягивают main каждую ночь.
- Узлы на канале `stable` (по умолчанию) обновляются только до тегов `vX.Y.Z`. Релиз: поднять `VERSION`,
добавить запись в `updates/versions.json`, запушить, поставить тег `vX.Y.Z` на main — тег же запускает
сборку ISO в GitHub Actions (artifacts `virtuality-iso-amd64/arm64`).
- Секреты и пароли не логировать; пароль cloud-init хранится только как SHA-512 crypt.
- Не возвращать удалённое: Cockpit, консольный dashboard, эмуляцию ARM64 на x86, PXE, смену
архитектуры машины, скрипты bootstrap/clean_install/setup_bridge_br0/create_test_vm.
-34
Просмотреть файл
@@ -1,34 +0,0 @@
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 {} +
+91 -321
Просмотреть файл
@@ -1,362 +1,132 @@
# Virtuality # Virtuality
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка. Платформа управления виртуализацией для одиночной Linux-ноды: KVM/QEMU/libvirt плюс собственная web-панель на FastAPI. Предназначена для администратора домашнего сервера, VPS или ARM64-платы (Raspberry Pi, Orange Pi 5), которому нужен упрощённый аналог Proxmox: создание VM из ISO, управление питанием, NAT/bridge-сеть с пробросом портов, web-консоль noVNC, журнал фоновых операций и самообновление ноды из git.
Текущая версия: **0.11.0** (файл `VERSION`, журнал изменений — `updates/versions.json`). Установка выполняется одной командой; установщик сам определяет профиль хоста (x86_64 / raspberry-arm64 / orangepi5-arm64 / generic-arm64) и ставит соответствующий набор пакетов. Авторизация в панели — по существующему Linux-пользователю (PAM/shadow), отдельной базы пользователей нет.
Что умеет: Исходный README автора сохранён в `README.upstream.md` — там подробные разделы про порты, директории, диагностику, NAT Router и полезные команды.
- ставится с готового ISO-образа, как Proxmox: три экрана установщика — и через несколько минут панель открыта в браузере; ## Стек
- создаёт машины из установочных ISO (в том числе Windows 11) и за минуту — из готовых облачных образов Ubuntu, Debian и Alpine;
- снимки, резервные копии, клонирование, увеличение диска, спящий режим;
- сеть машин «из коробки»: отдельная сеть NAT с постоянными адресами и пробросом портов или ваша домашняя сеть через мост;
- экран машины прямо в браузере, HTTPS для панели, автообновления со стабильным каналом.
![Обзор](docs/screenshots/dashboard.png) - Python 3, FastAPI, Uvicorn, Jinja2, itsdangerous (подписанные cookie-сессии)
- Bash — установщики, диагностика, сетевые скрипты, TUI-дашборд
- KVM, QEMU, libvirt (`virsh`, `virt-install`), Cockpit + Cockpit Machines
- nftables — NAT-роутер и port forwarding, netplan — настройка bridge `br0`
- noVNC + websockify — web-консоль VM
- systemd — юниты `virtuality-web`, `virtuality-auto-update.service/.timer`
- Хранение состояния — файлы JSON в `/var/lib/virtuality`, СУБД не используется
- Внешние API отсутствуют; обновления тянутся с GitHub (git pull или zip-архив ветки)
| Мастер создания машины | Страница машины | ## Структура
|---|---|
| ![Создание машины](docs/screenshots/create.png) | ![Машина](docs/screenshots/vm.png) |
| Сеть и доступ | Тёмная тема | | Путь | Назначение |
|---|---| | --- | --- |
| ![Сеть](docs/screenshots/network.png) | ![Тёмная тема](docs/screenshots/dashboard-dark.png) | | `install.sh` | Основной установщик: проверка требований, apt-пакеты, клонирование репозитория, запуск установки ноды и панели |
| `bootstrap.sh` | Публичная точка входа one-command установки на чистый сервер |
| `install_virtuality_node.sh` | Установка компонентов ноды: KVM/QEMU/libvirt, Cockpit, storage pools, firewall |
| `setup_github_sync.sh` | Настройка git-синхронизации репозитория на ноде |
| `VERSION`, `updates/versions.json` | Текущая версия и манифест истории версий, используется центром обновлений панели |
| `web/app.py` | Базовое FastAPI-приложение панели: логин, дашборд, ISO, создание VM, карточка VM, сеть, операции, health |
| `web/network_core.py` | NAT-роутер, nftables-правила, port forwarding, диагностика сети |
| `web/host_profile.py` | Определение и чтение профиля хоста |
| `web/update_core.py` | Центр обновлений: сравнение версий и коммитов, запуск обновления, лог |
| `web/templates/` | Jinja2-шаблоны страниц панели (`dashboard`, `vm_create`, `vm_detail`, `iso`, `disk_images`, `network`, `logs`, `update`, `console`, `operations`, `login`, `_sidebar`) |
| `web/static/` | `app.css`, `themes.css` (переключаемые темы, включая MacOS Flat), `panel.js`, статическая база знаний |
| `scripts/install_web_panel.sh` | Установка панели в `/opt/virtuality/web`, venv, systemd-юнит, применение всех патчей |
| `scripts/patch_*.py` | ~28 python-патчей, дописывающих функциональность в **установленный** `app.py` (диски, boot order, автозапуск VM, noVNC, сетевые диапазоны, центр логов, центр обновлений и т. д.) |
| `scripts/setup_bridge_br0.sh` | Безопасная настройка bridge `br0` через netplan с бэкапом и откатом |
| `scripts/check_node.sh`, `virtuality_healthcheck.sh`, `virtuality_console_dashboard.sh` | Диагностика ноды, healthcheck-команда `vhealth`, консольный btop-подобный дашборд |
| `scripts/auto_update_check.sh`, `apply_github_update.sh` | Автообновление по systemd-таймеру и применение обновления |
| `scripts/create_test_vm.sh`, `clean_install.sh`, `fix_cockpit_auth.sh` | Вспомогательные операции |
| `docs/` | `ARCHITECTURE.md`, `REQUIREMENTS.md`, `ROADMAP.md`, `FIRST_VM.md` |
| Мастер настройки: установка | Мастер настройки: сеть машин | Важная особенность архитектуры: репозиторный `web/app.py` — это **не** полный код панели. `install_web_panel.sh` копирует его в `/opt/virtuality/web/app.py` и затем последовательно применяет патч-скрипты из `scripts/`, которые вставляют в файл недостающие маршруты и функции (менеджер дисковых образов, web-консоль, центр логов, центр обновлений, сетевые доработки). Полная функциональность существует только после установки.
|---|---|
| ![Мастер, установка](docs/screenshots/wizard-install.png) | ![Мастер, сеть](docs/screenshots/wizard-network.png) |
| Каталог готовых систем | Настройки | ## Как запустить
|---|---|
| ![Каталог](docs/screenshots/catalog.png) | ![Настройки](docs/screenshots/settings.png) |
| Снимки | Резервные копии | Установка на чистый Ubuntu/Debian-сервер (требует root или sudo):
|---|---|
| ![Снимки](docs/screenshots/snapshots.png) | ![Резервные копии](docs/screenshots/backups.png) |
| Вход | На телефоне |
|---|---|
| ![Вход](docs/screenshots/login.png) | ![Мобильная версия](docs/screenshots/dashboard-mobile.png) |
---
## Содержание
- [Установка](#установка)
- [Способ 1: установочный ISO](#способ-1-установочный-iso)
- [Способ 2: одна команда на готовый сервер](#способ-2-одна-команда-на-готовый-сервер)
- [Способ 3: вручную](#способ-3-вручную)
- [Первая загрузка и мастер настройки](#первая-загрузка-и-мастер-настройки)
- [Возможности](#возможности)
- [Настройки](#настройки)
- [Команда virtuality-ctl](#команда-virtuality-ctl)
- [Обновления и откат](#обновления-и-откат)
- [Удаление](#удаление)
- [Если что-то не работает](#если-что-то-не-работает)
- [Где что лежит](#где-что-лежит)
- [Разработка](#разработка)
Подробные документы: [требования](docs/REQUIREMENTS.md) · [установочный образ](docs/IMAGE.md) · [первая машина](docs/FIRST_VM.md) · [архитектура](docs/ARCHITECTURE.md) · [планы](docs/ROADMAP.md).
---
## Установка
Перед установкой проверьте [требования](docs/REQUIREMENTS.md): процессор x86_64 с включённой виртуализацией (Intel VT-x / AMD-V), от 4 ГБ памяти, проводная сеть.
### Способ 1: установочный ISO
Самый простой путь для «голого» сервера или старого компьютера. Образ собирается из чистого Ubuntu Server 26.04 LTS; установщик спрашивает только сеть, диск и пользователя.
Где взять образ:
- **GitHub Actions.** Откройте вкладку Actions репозитория → workflow **Build ISO image** → последний успешный запуск → раздел Artifacts → `virtuality-iso-amd64` (или `virtuality-iso-arm64`). Внутри архива — `.iso` и файл `.sha256`. Артефакты хранятся 30 дней; новый образ собирается при каждом выпуске (тег `vX.Y.Z`), запустить сборку вручную можно кнопкой Run workflow.
- **Собрать самому** на любой Linux-машине (root не нужен):
```bash
sudo apt install -y xorriso git curl python3-pip
git clone https://github.com/viktor138irk/virtuality.git
cd virtuality
make iso # dist/virtuality-0.11.0-ubuntu-26.04-amd64.iso
```
Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md).
### Способ 2: одна команда на готовый сервер
Подходит для уже установленного Ubuntu Server 24.04 / 26.04 или Debian 13. Работает и под `root`, и под обычным пользователем с `sudo`:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | bash
``` ```
Скрипт проверит требования, поставит пакеты KVM/QEMU/libvirt, команду `vhealth` и панель, а в конце покажет адрес панели. Логин — тот пользователь, от которого запущена установка (через `sudo` — ваш пользователь, под `root` — `root`); пароль — его пароль Linux. Если пароль ещё не задан: `sudo passwd <пользователь>`. Выбор Linux-пользователя для входа в панель:
Переменные окружения, которые понимает `install.sh`:
```text
VIRTUALITY_USER=admin # какой Linux-пользователь входит в панель (будет создан, если его нет)
VIRTUALITY_WEB_PORT=8089 # порт панели (по умолчанию 8088)
VIRTUALITY_AUTO_UPDATE=0 # не включать ночное автообновление
VIRTUALITY_BRANCH=main # ветка репозитория
VIRTUALITY_INSTALL_VERBOSE=1 # показывать вывод команд вместо спиннера
```
Пример:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=admin bash curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=<linux_user> bash
``` ```
При первом входе в панель откроется мастер настройки — тот же, что после установки с ISO. Установка из локального клона:
### Способ 3: вручную ```bash
sudo bash install_virtuality_node.sh
Если хочется видеть каждый шаг. Всё делается от `root` или через `sudo`: sudo bash scripts/install_web_panel.sh
```
Обновление на ноде:
```bash ```bash
sudo mkdir -p /opt/virtuality
sudo git clone https://github.com/viktor138irk/virtuality.git /opt/virtuality/source
cd /opt/virtuality/source cd /opt/virtuality/source
sudo bash install_virtuality_node.sh # пакеты KVM/QEMU/libvirt, пулы, firewall sudo git pull
sudo bash scripts/install_healthcheck_command.sh # команда sudo vhealth sudo bash scripts/install_web_panel.sh
sudo VIRTUALITY_AUTH_USER=admin bash scripts/install_web_panel.sh # панель и virtuality-ctl sudo systemctl restart virtuality-web
``` ```
`install_web_panel.sh` копирует панель в `/opt/virtuality/web`, создаёт virtualenv, ставит `virtuality-ctl` и через него — systemd-юниты и правила firewall. Переменные: `VIRTUALITY_AUTH_USER` (пользователь для входа), `VIRTUALITY_WEB_PORT` (порт), `VIRTUALITY_AUTO_UPDATE` (0/1). Повторный запуск скрипта обновляет панель, сохраняя настройки. Доступ после установки:
---
## Первая загрузка и мастер настройки
### Что происходит после установки с 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 ```text
VIRTUALITY_WEB_PORT=8088 # порт HTTP Virtuality UI: http://SERVER_IP:8088
VIRTUALITY_TLS=1 # 1 — HTTPS включён Cockpit: https://SERVER_IP:9090
VIRTUALITY_TLS_PORT=8443 # порт HTTPS VNC: 5900-5999/tcp
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 КЛЮЧ=ЗНАЧЕНИЕ` сохраняет значение и сразу применяет его. Локальный запуск панели без установщика возможен (`uvicorn app:app` из `web/` после `pip install -r web/requirements.txt`), но функциональность будет урезана: не применены патч-скрипты, отсутствуют каталоги `/var/lib/virtuality`, `virsh` и права на libvirt. Полноценный dev-режим в репозитории не предусмотрен.
--- Минимальные требования (из `docs/REQUIREMENTS.md`): 2 ядра с VT-x/AMD-V, 4 GB RAM, 8 GB свободно на `/`, 20 GB под `/var/lib/virtuality`, Ubuntu Server 24.04 LTS или Debian-подобная система с apt.
## Команда virtuality-ctl ## Конфигурация
`virtuality-ctl` ставится в `/usr/local/bin` вместе с панелью; панель сама вызывает его для всех действий с сервером, так что из терминала можно сделать то же самое. Запускается от `root`: Переменные окружения установщиков (`install.sh`, `bootstrap.sh`):
```text | Переменная | Назначение | Пример / по умолчанию |
sudo virtuality-ctl status # что установлено, порты, адрес панели | --- | --- | --- |
sudo virtuality-ctl url # адрес панели | `VIRTUALITY_USER` | Linux-пользователь для входа в панель | `viktor`; по умолчанию `SUDO_USER` или `root` |
sudo virtuality-ctl reconfigure # пересоздать systemd-юниты из web.env и перезапустить панель | `VIRTUALITY_WEB_PORT` | Порт web-панели | `8088` |
sudo virtuality-ctl set KEY=VALUE ... # изменить настройку в web.env и применить (например set VIRTUALITY_TLS=1) | `VIRTUALITY_REPO_URL` | Источник исходников | `https://github.com/viktor138irk/virtuality.git` |
sudo virtuality-ctl passwd [USER] # сменить пароль пользователя панели | `VIRTUALITY_INSTALL_URL` | URL самого установщика для self-install | raw.githubusercontent.com/... |
sudo virtuality-ctl timezone Europe/Moscow # часовой пояс сервера | `VIRTUALITY_PROJECT_BASE_DIR` / `VIRTUALITY_PROJECT_DIR` | Куда ставить проект | `/opt/virtuality`, `/opt/virtuality/source` |
sudo virtuality-ctl power reboot|poweroff # перезагрузить или выключить сервер (машины выключаются корректно) | `VIRTUALITY_SETUP_BRIDGE` / `VIRTUALITY_BRIDGE_IFACE` | Настроить bridge `br0` и на каком интерфейсе | `0`, пусто |
sudo virtuality-ctl backup-config [FILE] # архив настроек ноды и описаний машин | `VIRTUALITY_CREATE_TEST_VM` | Создать тестовую VM после установки | `0` |
sudo virtuality-ctl restore-config FILE # восстановить настройки из архива | `VIRTUALITY_SKIP_REQUIREMENTS` | Пропустить проверку требований | `0` |
sudo virtuality-ctl rollback # вернуть предыдущую версию панели | `VIRTUALITY_MIN_ROOT_FREE_MB` / `MIN_VAR_FREE_MB` / `MIN_RAM_MB` / `MIN_CPU_CORES` | Пороги проверки требований | `8192` / `20480` / `4096` / `2` |
sudo virtuality-ctl maintenance # ежедневное обслуживание (запускается таймером) | `VIRTUALITY_CLEAN_BEFORE_INSTALL` | Очистка перед установкой | `0` |
sudo virtuality-ctl bridge <iface> [dhcp|static] # мост br0 с автооткатом через 2 минуты | `VIRTUALITY_INSTALL_VERBOSE` | Подробный вывод установки | `0` |
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
```
--- Переменные времени выполнения панели (читаются из `/opt/virtuality/virtuality.env` и окружения):
## Обновления и откат | Переменная | Назначение | Значение по умолчанию |
| --- | --- | --- |
| `VIRTUALITY_AUTH_USER` | Linux-логин, который принимает форма входа | `viktor` |
| `VIRTUALITY_SESSION_SECRET` | Ключ подписи cookie-сессии | `dev-secret-change-me` — при установке генерируется и кладётся в `/var/lib/virtuality/config/session_secret` |
| `VIRTUALITY_SOURCE_DIR` | Каталог исходников для центра обновлений | `/opt/virtuality/source` |
| `VIRTUALITY_UPDATE_REMOTE` / `VIRTUALITY_UPDATE_BRANCH` | Git-remote и ветка обновлений | `origin` / `main` |
| `VIRTUALITY_UPDATE_ZIP_URL` | Резервный zip-источник обновления | архив ветки `main` на GitHub |
- **Из панели**: раздел **Обновления** → «Проверить сейчас» → «Обновить». Машины во время обновления продолжают работать. Что изменилось — видно на той же странице. Пароль пользователя нигде не хранится — проверяется напрямую по системному shadow. Профиль хоста пишется в `/var/lib/virtuality/config/host_profile.json`.
- **Автоматически**: таймер `virtuality-auto-update.timer` проверяет обновления каждую ночь около 4:00 по времени сервера. Включается и выключается в «Настройках» или командой `sudo virtuality-ctl set VIRTUALITY_AUTO_UPDATE=0`.
- **Каналы**: `stable` — только выпущенные версии (последний тег `vX.Y.Z`; пока тегов нет, стабильный канал следует ветке `main`), `main` — все изменения ветки `main`. Нода никогда не откатывается на более старую версию сама.
- **Вручную из терминала**: `sudo bash /opt/virtuality/source/scripts/apply_github_update.sh` — тот же сценарий, что и в панели (git fetch, при недоступности git — ZIP с GitHub, затем `scripts/install_web_panel.sh` и перезапуск панели). Журнал: `/var/log/virtuality/update.log`.
- **Откат**: перед каждым обновлением предыдущая версия панели сохраняется в `/opt/virtuality/web.prev`. Если новая версия не запустилась: `sudo virtuality-ctl rollback`.
--- ## Состояние
## Удаление Рабочий проект в активной разработке, доведённый до практического применения, но с сырой архитектурой (см. ниже). Версия по `VERSION` — `0.9.6`. 451 коммит, последний — 2026-05-07. С мая 2026 года работа в репозитории остановлена.
```bash Разработка шла очень интенсивно двое суток (2026-05-06 — 2026-05-07): от «Initial commit» до 0.9.6. Значительная часть коммитов — механические bump-версии и правки вёрстки панели.
sudo virtuality-ctl uninstall # убирает панель, службы и настройки; машины, образы и копии остаются
sudo virtuality-ctl uninstall --purge # удаляет ВСЁ: машины, диски, образы, резервные копии, сеть NAT
```
Команда спрашивает подтверждение (`YES`); `--yes` пропускает вопрос. Пакеты KVM/QEMU/libvirt остаются в системе. ## Что не доделано
--- - Патч-архитектура вместо нормального кода. Функциональность панели разложена по ~28 скриптам `scripts/patch_*.py`, которые текстовым поиском/вставкой правят установленный `app.py`. Репозиторный `web/app.py` неполон, порядок применения патчей критичен, повторная сборка хрупкая. Это главный технический долг проекта.
- Тесты и CI отсутствуют полностью: нет ни тестов, ни `.github/workflows`, ни линтеров.
## Если что-то не работает - Ролевой модели нет: один Linux-пользователь = полный доступ к панели.
- HTTPS для панели не настраивается — только HTTP на порту 8088 (у Cockpit свой TLS).
1. Раздел **Сервер** в панели показывает, чего не хватает (KVM, пакеты, мост). Раздел **Задачи** — журнал каждой операции. Раздел **Журналы** — панель, обновления, установка, виртуализация. - Кластеризация, backup/snapshot, cloud-init/cloud-image шаблоны заявлены в `docs/ROADMAP.md`, но в коде отсутствуют.
2. Полная диагностика в терминале: - Каталог `/var/lib/virtuality/backups` создаётся, но функций резервного копирования в панели нет.
- Явных `TODO`/`FIXME` в коде нет; незавершённость выражена в расхождении между ROADMAP и реализацией.
```bash - Версия 0.9.6 так и не доведена до 1.0.
sudo vhealth # проверка процессора, служб, сети, firewall, libvirt, дисков
sudo virtuality-ctl status # версия, адрес панели, HTTPS, автообновление, хранилище
```
3. Отчёт для поддержки — кнопка в «Настройках» или `sudo virtuality-ctl support-bundle`: архив с настройками (без секретов), журналами служб и последними задачами.
4. Журналы:
```bash
journalctl -u virtuality-web -f # панель
sudo journalctl -fu virtuality-firstboot # первая загрузка после ISO
cat /var/log/virtuality/firstboot.log # то же, файлом
cat /var/log/virtuality/update.log # обновления
ls /var/log/virtuality/operations/ # задачи: JSON + журнал каждой
```
5. Для мониторинга: `GET /healthz` без авторизации отвечает `{"ok": true, "version": "..."}`.
Полезные команды libvirt: `virsh list --all`, `virsh net-list --all`, `virsh net-dhcp-leases virtuality-nat`, `virsh pool-list --all`.
---
## Где что лежит
```text
/opt/virtuality/source # исходники (git), отсюда обновления
/opt/virtuality/web # установленная панель (web.prev — предыдущая версия)
/opt/virtuality/venv # Python virtualenv панели
/var/lib/virtuality/iso # установочные ISO
/var/lib/virtuality/disk-images # готовые образы систем (каталог, ссылки, загрузки)
/var/lib/virtuality/images # диски машин (qcow2)
/var/lib/virtuality/backups # резервные копии машин и архивы настроек
/var/lib/virtuality/config # web.env, setup.json, wizard.json, host_profile.json, tls/
/var/lib/virtuality/network # port_forwards.json, virtuality-nat.xml
/var/lib/virtuality/tmp # временные файлы загрузок и cloud-init
/var/log/virtuality # firstboot.log, update.log, virtuality-ctl.log, install_*.log
/var/log/virtuality/operations # задачи панели: JSON и журнал каждой
/etc/virtuality/nftables/virtuality.nft # правила проброса портов
/usr/local/bin/virtuality-ctl # управление нодой
/usr/local/bin/vhealth # диагностика
```
Порты: панель `8088` (HTTP) и `8443` (HTTPS), SSH `22`. VNC-порты машин наружу не открываются.
---
## Разработка
```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)
```
Демо-панель без сервера виртуализации — с тремя машинами, задачами и загрузками:
```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).
GitHub Actions: `ci.yml` запускает pyflakes, shellcheck и тесты на Python 3.11–3.14; `image.yml` собирает ISO по тегу `v*` и вручную. Ветка `main` — рабочая ветка нод на канале `main`; выпуск для стабильного канала — тег `vX.Y.Z`.
---
## Лицензия
[MIT License](LICENSE).
+432
Просмотреть файл
@@ -0,0 +1,432 @@
# Virtuality
**Virtuality** — лёгкая серверная платформа виртуализации на базе **KVM**, **QEMU**, **libvirt**, **Cockpit** и собственной web-панели.
Цель проекта — собрать понятную, компактную и расширяемую систему для управления виртуальными машинами, ISO-образами, NAT/bridge-сетью, пробросом портов, диагностикой, web-console/noVNC, backup/snapshot-функциями и будущей кластеризацией.
> Текущий статус: ранняя стадия разработки. Уже есть one-command установка, автоопределение профиля хоста, web-панель, авторизация через Linux-пользователя, ISO-менеджер, создание VM из интерфейса, журнал операций, NAT Router для VPS/ARM edge nodes и port forwarding.
---
## Поддерживаемые профили хоста
Virtuality автоматически определяет профиль сборки во время установки:
```text
x86_64 # обычный сервер, домашний сервер, VPS
raspberry-arm64 # Raspberry Pi ARM64 edge node
orangepi5-arm64 # Orange Pi 5 / RK3588 ARM64 edge node
generic-arm64 # другая ARM64-плата
```
Профиль сохраняется здесь:
```text
/var/lib/virtuality/config/host_profile.json
```
В web-панели профиль виден на странице:
```text
/host
```
Для ARM64-плат правильный сценарий — **ARM64-гости**, NAT-сеть и позже cloud-image/cloud-init шаблоны. x86_64 ISO на Raspberry/Orange Pi не являются целевым режимом.
---
## Быстрая установка одной командой
Одна команда работает и под `root`, и под обычным пользователем с `sudo`:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | bash
```
Как выбирается пользователь для входа в web-панель:
```text
Если запуск под root → login: root
Если запуск через sudo → login: текущий sudo-пользователь
Если задан VIRTUALITY_USER → login: указанный Linux-пользователь
```
Принудительно выбрать пользователя:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=<linux_user> bash
```
Пример:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=admin bash
```
Установка с авторизацией под `root`:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=root bash
```
Если пароль выбранного Linux-пользователя не задан:
```bash
sudo passwd <linux_user>
```
Для root:
```bash
passwd root
```
---
## Что уже есть
- универсальная one-command установка через `install.sh`;
- автоопределение профиля хоста: x86_64, Raspberry Pi ARM64, Orange Pi 5 ARM64, generic ARM64;
- установка разных пакетных наборов под x86 и ARM64;
- красивый пошаговый installer с live-status и логами;
- preflight-проверка системных требований;
- установка KVM/QEMU/libvirt;
- установка Cockpit и Cockpit Machines;
- рабочая директория `/opt/virtuality/source`;
- libvirt storage pools `virtuality-images` и `virtuality-iso`;
- firewall-правила для SSH, Cockpit, VNC и web-панели;
- безопасная ручная настройка bridge `br0` через `netplan try`;
- NAT Router `virtuality-nat` для VPS/ARM edge nodes;
- port forwarding через nftables;
- автоматическое определение IP VM для проброса портов;
- healthcheck одной командой `vhealth`;
- консольный dashboard для физического монитора;
- web-панель Virtuality на FastAPI;
- авторизация web-панели через системного Linux-пользователя;
- `/host` — профиль хоста и проверки готовности;
- `/network` — NAT, DHCP leases, port forwarding, nftables preview;
- `/iso` — ISO-менеджер;
- `/operations` — журнал фоновых операций;
- список VM;
- управление VM: Start, Shutdown, Reboot, Power off, Autostart on/off, Delete VM with disks;
- создание VM из web-интерфейса;
- выбор сетевого режима при создании VM: `virtuality-nat` или `br0`;
- прогресс создания VM и live-лог `virt-install`;
- страница деталей VM: `dominfo`, VNC display, IP, диски, сетевые интерфейсы.
---
## Системные требования
Минимально для x86_64 тестового стенда:
```text
CPU: 2 ядра с Intel VT-x / AMD-V
RAM: 4 GB
/: минимум 8 GB свободно
/var/lib: минимум 20 GB свободно
OS: Ubuntu Server 24.04 LTS / Debian-like с apt
Network: один проводной интерфейс
```
Рекомендуемо для x86_64:
```text
CPU: 4+ ядра
RAM: 16+ GB
Storage: 100+ GB SSD/NVMe под /var/lib/virtuality
Network: 1 Gbit/s+
OS: Ubuntu Server 24.04 LTS
```
ARM64 edge nodes:
```text
Raspberry Pi 4/5 ARM64: желательно 8 GB RAM
Orange Pi 5 ARM64: желательно 8/16/32 GB RAM и NVMe
Гости: ARM64 Linux VM
Сеть: virtuality-nat по умолчанию
```
Подробнее: [`docs/REQUIREMENTS.md`](docs/REQUIREMENTS.md)
---
## Порты
```text
Cockpit: https://SERVER_IP:9090
Virtuality UI: http://SERVER_IP:8088
VNC: 5900-5999/tcp
SSH: 22/tcp
```
Изменить порт web-панели:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_WEB_PORT=8089 bash
```
Для VPS/ARM NAT-сценария входящие сервисы VM открываются через port forwarding в разделе `/network`.
---
## Директории
```text
/opt/virtuality/source # исходники проекта из GitHub
/opt/virtuality/web # установленная web-панель
/opt/virtuality/venv # Python virtualenv web-панели
/opt/virtuality/virtuality.env # базовый env ноды
/var/lib/virtuality/config # конфиги Virtuality
/var/lib/virtuality/config/host_profile.json
/var/lib/virtuality/iso # ISO-образы
/var/lib/virtuality/images # qcow2-диски VM
/var/lib/virtuality/network # port_forwards.json
/var/lib/virtuality/backups # backups
/var/log/virtuality # логи установки и диагностики
/var/log/virtuality/operations # JSON/log фоновых операций
/etc/virtuality/nftables/virtuality.nft # nftables-правила Virtuality
```
---
## Диагностика
```bash
sudo vhealth
```
Дополнительно:
```bash
cat /var/lib/virtuality/config/host_profile.json
systemctl status virtuality-web --no-pager
journalctl -u virtuality-web -n 120 --no-pager
virsh list --all
virsh pool-list --all
virsh net-list --all
virsh net-dhcp-leases virtuality-nat
ip -br a
ip route
```
---
## Web-панель
URL:
```text
http://SERVER_IP:8088
```
Основные разделы:
```text
/ # дашборд
/host # профиль хоста и проверки
/iso # ISO-менеджер
/network # NAT Router и проброс портов
/operations # журнал операций
/vm/create # создание VM
```
Переустановка web-панели:
```bash
cd /opt/virtuality/source
sudo bash scripts/install_web_panel.sh
```
Статус:
```bash
systemctl status virtuality-web --no-pager
```
Логи:
```bash
journalctl -u virtuality-web -f
```
Вход:
```text
Login: выбранный Linux-пользователь
Pass: пароль этого Linux-пользователя
```
---
## Сеть: Bridge и NAT Router
### Bridge br0
Подходит для домашнего/офисного сервера, где VM должны получать IP из локальной сети.
По умолчанию one-command установщик **не включает `br0` автоматически**, чтобы не уронить SSH-сессию.
Сначала нужно посмотреть сетевой интерфейс:
```bash
ip -br a
ip route
```
Затем запустить bridge setup, например для `enp2s0`:
```bash
cd /opt/virtuality/source
sudo bash scripts/setup_bridge_br0.sh enp2s0 static
```
Ожидаемый результат:
```text
enp2s0 UP
br0 UP SERVER_IP/24
default via GATEWAY dev br0
```
### Virtuality NAT Router
Подходит для VPS, Raspberry Pi, Orange Pi 5 и других ARM64 edge nodes.
Схема:
```text
Интернет / LAN
↓
Хост Virtuality
↓
virtuality-nat / virbr100
↓
VM 192.168.100.x
```
Создать или починить NAT-сеть можно в web-панели:
```text
/network → Создать / починить NAT-сеть
```
Параметры по умолчанию:
```text
Network: virtuality-nat
Bridge: virbr100
Subnet: 192.168.100.0/24
Gateway: 192.168.100.1
DHCP: 192.168.100.50–192.168.100.200
```
Проброс порта добавляется в `/network`. IP VM определяется автоматически через `virsh domifaddr`, а если не получилось — через DHCP leases и MAC-адрес VM.
Пример SSH-проброса:
```text
VM: ubuntu-test
Внешний порт сервера: 2222
Порт внутри VM: 22
Протокол: tcp
```
Подключение:
```bash
ssh user@SERVER_IP -p 2222
```
---
## ISO и создание VM
Загрузка ISO через web-панель:
```text
/iso
```
Создание VM:
```text
/vm/create
```
При создании VM можно выбрать сеть:
```text
VPS NAT Router — virtuality-nat
Bridge — br0 / локальная сеть
```
Для x86_64 можно использовать обычные x86_64 ISO. Для Raspberry Pi / Orange Pi 5 нужны ARM64 ISO или, в будущем, ARM64 cloud images.
Пример загрузки Alpine x86_64 ISO:
```bash
sudo wget -O /var/lib/virtuality/iso/alpine-standard-x86_64.iso https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-standard-3.20.3-x86_64.iso
sudo virsh pool-refresh virtuality-iso
```
---
## Обновление
```bash
cd /opt/virtuality/source
sudo git pull
sudo bash scripts/install_web_panel.sh
sudo systemctl restart virtuality-web
```
Полная повторная установка компонентов ноды:
```bash
cd /opt/virtuality/source
sudo bash install_virtuality_node.sh
sudo bash scripts/install_web_panel.sh
```
---
## Полезные команды
```bash
sudo vhealth
virsh list --all
virsh pool-list --all
virsh net-list --all
virsh net-dhcp-leases virtuality-nat
ip -br a
ip route
sudo nft list ruleset
sudo ufw status
systemctl status libvirtd --no-pager
systemctl status cockpit.socket --no-pager
systemctl status virtuality-web --no-pager
journalctl -u virtuality-web -f
```
---
## Roadmap
- ARM64 cloud-image templates;
- cloud-init для быстрых VM;
- web-console/noVNC;
- управление storage pools;
- backup/snapshot manager;
- сетевой менеджер bridge/VLAN;
- роли и права пользователей;
- журнал событий;
- автообновление;
- кластеризация.
---
## Лицензия
Рекомендуемая лицензия: **MIT License**.
+1 -1
Просмотреть файл
@@ -1 +1 @@
0.11.0 0.9.6
+296
Просмотреть файл
@@ -0,0 +1,296 @@
#!/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
+87 -137
Просмотреть файл
@@ -1,172 +1,122 @@
# Архитектура Virtuality # Архитектура Virtuality
Virtuality — одна нода: сервер с KVM/QEMU/libvirt, поверх которого работает панель на FastAPI. Панель не хранит собственную базу данных: источник правды о машинах — libvirt (`virsh`), о ноде — файлы в `/var/lib/virtuality/config`, о задачах — файлы в `/var/log/virtuality/operations`. Всё, что меняет сам сервер (службы, firewall, сеть, диски), делает один скрипт — `virtuality-ctl`, и панель вызывает его же. Virtuality — это платформа виртуализации в стиле Proxmox, но с более компактной и контролируемой архитектурой.
## Цель
Создать собственную систему управления виртуальными машинами на базе стабильных Linux-компонентов:
- KVM;
- QEMU;
- libvirt;
- Cockpit на раннем этапе;
- собственная веб-панель на следующих этапах.
## Базовая схема
```text ```text
Браузер Веб-интерфейс
│ HTTP 8088 / HTTPS 8443 ↓
▼ Backend API
virtuality-web.service — uvicorn, web/app.py (FastAPI, Jinja2) ↓
├── web/features/* мастер, настройки, снимки, копии, загрузки, операции с машиной Node services
├── web/core.py общие примитивы, хранилище задач ↓
├── web/nodectl.py ───────► /usr/local/bin/virtuality-ctl (systemd, TLS, ufw, br0, диск, пароль) libvirt / QEMU / KVM
├── web/network_core.py ──► virsh net-*, nft, ufw, iptables (сеть NAT и пробросы) ↓
└── virsh / virt-install / virt-clone / qemu-img ──► libvirtd ──► QEMU/KVM Host OS / Storage / Network
``` ```
--- ## Слои системы
## Код панели: `web/` ### 1. Web UI
| Модуль | Назначение | Будущая пользовательская панель 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;
- управление питанием;
- консоль;
- хранилища;
- сеть;
- бэкапы;
- пользователи и роли.
Долгие операции (создание машины, конвертация образа, скачивание, снимок, копия, клон, спящий режим) выполняются в потоках и записываются в `/var/log/virtuality/operations/<id>.json` (статус, прогресс, сообщение, метаданные) и `<id>.log` (журнал). Раздел «Задачи» и страницы машин читают эти файлы; `active_operations_for(vm, kinds)` защищает машину от двух одновременных операций. При старте панели `interrupt_orphaned_operations()` закрывает задачи, оставшиеся в состоянии «выполняется» после перезапуска. `virtuality-ctl maintenance` удаляет задачи старше 30 дней и оставляет не больше 300. ### 2. Backend API
--- Единая точка управления нодой.
## Управление нодой: `scripts/virtuality-ctl` Задачи:
Один bash-скрипт, устанавливается в `/usr/local/bin`. Панель (через `nodectl.py`) и администратор в терминале делают одно и то же: - принимать команды от Web UI;
- валидировать действия;
- работать с libvirt;
- вести журнал событий;
- отдавать состояние VM, storage и сети.
- **`reconfigure`** — читает `/var/lib/virtuality/config/web.env` и пересоздаёт systemd-юниты, logrotate, `/etc/default/libvirt-guests`, правила ufw; переводит VNC старых машин на `127.0.0.1`; перезапускает панель. ### 3. Node services
- **`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.
Журнал действий: `/var/log/virtuality/virtuality-ctl.log`. Системные сервисы Virtuality.
--- Задачи:
## Службы systemd - диагностика ноды;
- обновления;
- резервное копирование;
- мониторинг;
- фоновые задачи.
Создаются `virtuality-ctl reconfigure`: ### 4. libvirt / QEMU / KVM
| Юнит | Что делает | Нижний слой виртуализации.
|---|---|
| `virtuality-web.service` | Панель: `uvicorn app:app` от root из `/opt/virtuality/web` с venv `/opt/virtuality/venv`; `Restart=always`. |
| `virtuality-redirect.service` | Только при TLS: HTTP → HTTPS. |
| `virtuality-network.service` | При загрузке заново применяет пробросы портов (`restore_network.py`). |
| `virtuality-auto-update.timer` / `.service` | Ночная проверка обновлений (`scripts/auto_update_check.sh`), 04:00 ± 30 минут; включён при `VIRTUALITY_AUTO_UPDATE=1`. |
| `virtuality-maintenance.timer` / `.service` | Ежедневное обслуживание: старые задачи, временные файлы, недокачанные загрузки, `journalctl --vacuum-size=300M`, запись занятости диска в `config/health.json`. |
| `libvirt-guests.service` | При выключении сервера машины выключаются корректно (`ON_SHUTDOWN=shutdown`, до 180 секунд), при загрузке не запускаются сами — только те, у кого включён автозапуск. |
Отдельно, из образа: `virtuality-firstboot.service` — первая загрузка (см. [IMAGE.md](IMAGE.md)); временные юниты `virtuality-netplan-revert` (автооткат моста), `virtuality-apply-*` (отложенное применение настроек) и `virtuality-update-*` (обновление вне cgroup панели, чтобы перезапуск панели не убил сам процесс обновления). Задачи:
--- - запуск виртуальных машин;
- управление дисками;
- управление сетевыми интерфейсами;
- снапшоты;
- консольный доступ.
## Безопасность панели ### 5. Host OS
- Вход — по имени и паролю системного пользователя (`VIRTUALITY_AUTH_USER`); проверяется по `/etc/shadow`, поэтому панель работает от root. После 5 неверных паролей адрес блокируется на 5 минут; сессия (подписанная cookie, ключ в `config/session_secret`) живёт 12 часов. Операционная система сервера.
- POST-запросы принимаются только с того же origin; за reverse proxy нужно передавать исходный `Host` (и `Upgrade`/`Connection` для экрана машины).
- Экран машины: VNC у всех машин слушает `127.0.0.1`, панель проксирует его в WebSocket по подписанному токену и только при действующей сессии. Порты 5900–5999 в ufw закрыты.
- Пароль cloud-init хранится только как хеш SHA-512; секреты не попадают в журналы и отчёт для поддержки.
- Firewall: `install_virtuality_node.sh` включает ufw и разрешает SSH; `virtuality-ctl` открывает только порт панели (и HTTPS-порт), список открытых портов хранит в `config/ports.state`.
--- Целевые системы:
## Сеть машин - Debian 12;
- Ubuntu Server 22.04;
- Ubuntu Server 24.04.
### NAT: `virtuality-nat` ## Пути
libvirt-сеть с мостом `virbr100`, подсеть `192.168.100.0/24`, шлюз `192.168.100.1`, DHCP `192.168.100.50–200`. Описание сохраняется в `/var/lib/virtuality/network/virtuality-nat.xml`. Создаётся мастером, страницей «Сеть и доступ» или автоматически при создании первой машины в режиме NAT. Включается `net.ipv4.ip_forward` (`/etc/sysctl.d/99-virtuality-forward.conf`) и ослабляется rp_filter (`98-virtuality-rpfilter.conf`).
**Постоянные адреса.** Для каждой машины MAC-адрес вычисляется из её имени (`stable_mac`), и в сети создаётся резервация DHCP (`virsh net-update add ip-dhcp-host`) — машина всегда получает один и тот же адрес, панель знает его ещё до первого запуска. При удалении машины резервация освобождается.
**Пробросы портов** хранятся в `/var/lib/virtuality/network/port_forwards.json` и применяются тремя способами сразу, чтобы работать на любом дистрибутиве:
1. nftables — таблица `ip virtuality` в `/etc/virtuality/nftables/virtuality.nft` (DNAT, forward, masquerade);
2. ufw — правила `route allow`, список сохранённых правил в `network/ufw_rules.json`;
3. iptables — запасной вариант, все правила помечены комментарием `virtuality-forward`, чтобы их можно было найти и удалить.
Адрес машины определяется через гостевой агент, `virsh domifaddr`, аренды DHCP по MAC машины, либо задаётся вручную. «Проверить» рядом с правилом делает TCP-подключение к машине и показывает команды для диагностики.
### Домашняя сеть: мост `br0`
Сервер объединяет свой проводной интерфейс в мост `br0` (netplan), машины подключаются к нему и получают адреса от роутера. Wi-Fi в мост объединить нельзя — тогда доступен только NAT. Как устроен автооткат — выше, в разделе про `virtuality-ctl`.
---
## Хранилище
```text ```text
/var/lib/virtuality/ /opt/virtuality # код и конфигурация проекта
├── iso/ установочные образы; libvirt-пул virtuality-iso /var/lib/virtuality # runtime-хранилище
├── images/ диски машин <имя>.qcow2; libvirt-пул virtuality-images /var/lib/virtuality/iso # ISO-образы
├── disk-images/ готовые образы систем (каталог, ссылки, загрузки с компьютера) /var/lib/virtuality/images # диски VM
├── backups/ резервные копии машин (<машина>/<ГГГГММДД-ЧЧММ>/) и архивы настроек config-*.tar.gz /var/lib/virtuality/backups # резервные копии
├── config/ web.env, setup.json, setup_done, wizard.json, host_profile.json, /var/log/virtuality # логи
│ 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;
- архитектура документируется до активной разработки.
```text ## Этап v0.1
/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;
1. **Канал** (`VIRTUALITY_UPDATE_CHANNEL` в `web.env`): `stable` — последний тег `vX.Y.Z` (пока тегов нет — ветка `main`); `main` — ветка `main`. Логика одна и та же в `web/update_core.py` и `scripts/update_target.sh`. - установка Cockpit;
2. **Проверка** (`/update/check`, ночной `scripts/auto_update_check.sh`): `git fetch --tags`, сравнение текущего коммита с целевым; обновление предлагается, только если целевой коммит — потомок текущего (нода никогда не откатывается сама). Список изменений берётся из `updates/versions.json` целевой версии. - storage pools;
3. **Применение** (`scripts/apply_github_update.sh`, запускается через `systemd-run`): `git checkout -B main <цель>` и `git clean`; если git недоступен — скачивание ZIP-архива с GitHub и `rsync`; затем `scripts/install_web_panel.sh` (панель копируется в `web.new`, старая переименовывается в `web.prev`, venv обновляется) и `systemctl restart virtuality-web`. Состояние — `/var/lib/virtuality/update/state.json`, журнал — `/var/log/virtuality/update.log`. - структура проекта;
4. **Откат**: `virtuality-ctl rollback` меняет местами `web` и `web.prev`. - GitHub-синхронизация;
- базовая диагностика.
Установщики и скрипты вне `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.
+93 -127
Просмотреть файл
@@ -1,138 +1,104 @@
# Первая виртуальная машина # Первая тестовая виртуальная машина
Всё в этом документе делается через панель в браузере — терминал не понадобится. Считаем, что Virtuality установлена и мастер настройки пройден (см. README). Этот документ фиксирует проверку первого рабочего сценария Virtuality: сеть `br0`, libvirt storage pools и запуск тестовой VM.
Сначала — самый быстрый путь: готовая Ubuntu за минуту. Потом — установка Windows 11 с ISO, экран машины, доступ снаружи, снимки и резервные копии. ## Требования
--- Перед созданием VM должны быть готовы:
## 1. Быстрая машина из каталога: Ubuntu Server 26.04 - KVM/QEMU;
- libvirt;
- Cockpit;
- storage pools `virtuality-images` и `virtuality-iso`;
- bridge `br0`;
- default route через `br0`.
Готовые (облачные) образы — это уже установленная система без пароля. Панель скачивает образ, делает из него диск нужного размера и через cloud-init настраивает первый вход: пользователя, пароль, SSH-ключ. Проверка:
### Скачать образ
1. Откройте **Образы ОС** → вкладка **Готовые системы**.
2. В карточке **Каталог готовых систем** найдите **Ubuntu Server 26.04 LTS** (помечена «Рекомендуем») и нажмите **Скачать**. Сервер скачает образ с сайта Ubuntu сам и проверит контрольную сумму; ход виден в **Задачах** и на этой же странице.
3. Когда образ отмечен «Загружен», рядом появится кнопка **Создать машину** — она открывает мастер с уже выбранным образом.
В каталоге также Ubuntu Server 24.04 LTS, Debian 13 и Alpine Linux 3.23. Свой образ (`.qcow2`, `.img`, `.raw`, `.img.xz`) можно загрузить с компьютера перетаскиванием или скачать по ссылке в карточке **Скачать по ссылке**.
### Создать машину
Шаг **1. Название и система**:
- **Название машины** — латиница, цифры, точка, дефис, подчёркивание (например, `ubuntu-1`). Это же имя станет именем компьютера внутри системы.
- Источник **Готовая система** уже выбран, образ подставлен.
- Переключатель **Настроить первый вход** должен быть включён: у готовых образов нет пароля, без этого шага в машину не войти.
- **Пользователь** — панель подставляет стандартного пользователя образа (`ubuntu`, у Debian — `debian`, у Alpine — `alpine`); можно указать свой. Он получит права администратора (`sudo`).
- **Пароль** — не короче 6 символов.
- **SSH-ключ** — необязательно; вставьте содержимое `~/.ssh/id_ed25519.pub`. Если указать только ключ без пароля, вход по SSH будет только по ключу.
Шаг **2. Мощность**: выберите профиль — **Мини** (1 ядро, 1 ГБ, 10 ГБ), **Стандарт** (2 ядра, 4 ГБ, 40 ГБ), **Мощная** (4 ядра, 8 ГБ, 80 ГБ) — или задайте ядра, память и диск сами. Диск образа увеличивается до выбранного размера, а система при первой загрузке сама расширит файловую систему.
Шаг **3. Сеть**: **Автоматически** — машина получит постоянный адрес `192.168.100.x` в отдельной сети и выйдет в интернет через сервер; снаружи её не видно, пока вы не откроете доступ. **В локальной сети** (если в мастере настройки выбран мост) — машина станет устройством вашей домашней сети и получит адрес от роутера.
Справа в **Итого** проверьте сводку и нажмите **Создать машину**. Панель откроет страницу задачи с живым журналом. Обычно в пределах минуты появится «Машина создана. Вход: ubuntu по паролю. Адрес в сети машин: 192.168.100.x».
### Войти
Откройте страницу машины (из **Обзора**) → **Открыть экран** и войдите с указанными пользователем и паролем. В машину сразу ставится гостевой агент, поэтому её адрес виден на странице машины и в **Сети и доступе** — по нему можно зайти по SSH из локальной сети (если выбран мост) или после проброса порта (см. ниже).
---
## 2. Windows 11 с установочного ISO
### Загрузить ISO
1. Скачайте ISO Windows 11 с сайта Microsoft на свой компьютер.
2. **Образы ОС** → вкладка **Установочные образы** → перетащите файл в окно загрузки. Или вставьте прямую ссылку на `.iso` в карточку **Скачать по ссылке** — сервер скачает его сам.
### Создать машину
1. **Создать машину** → шаг 1: название (например, `win11`), источник **Установить с диска**, выберите ISO.
2. **Что будете устанавливать** → **Windows 11 / Server 2025**. Панель сама включит UEFI и виртуальный TPM 2.0 (без него Windows 11 отказывается ставиться), подберёт диск SATA и сетевую карту Intel — драйверы во время установки не нужны.
3. Шаг 2: профиль **Мощная** (4 ядра, 8 ГБ, 80 ГБ) — Windows 11 требует минимум 4 ГБ памяти и 64 ГБ диска.
4. Шаг 3: сеть — как и для Linux.
5. **Создать машину**. Задача завершится за несколько секунд — машина создана и запущена с установочного диска.
### Установить систему
1. На странице машины нажмите **Открыть экран**. Сразу после запуска Windows показывает «Press any key to boot from CD or DVD» — нажмите любую клавишу в окне экрана, иначе загрузка с диска пропустится. Если пропустили — **Перезагрузить** на странице машины и попробовать снова.
2. Дальше установка идёт как на обычном компьютере. Диск в установщике будет пустым — выберите его целиком.
3. После установки на странице машины откройте вкладку **Диски и загрузка** → **Привод CD/DVD** и извлеките установочный образ, чтобы машина больше не предлагала загрузку с него.
Для других систем выбирайте тип **Linux** (Ubuntu, Debian, Fedora и другие), **Windows 10 / Server 2019–2022** (классическая загрузка) или **Другая система**.
---
## 3. Экран машины
- Кнопка **Открыть экран** на странице машины открывает экран в новой вкладке браузера — это как монитор, подключённый к машине. Работает и для установки системы, и когда сеть внутри машины ещё не настроена.
- Если курсор «застрял» внутри экрана, нажмите **Ctrl + Alt**.
- Экран доступен только через панель: VNC-порты машин наружу не открываются, соединение идёт через сервер по вашей сессии.
- Для повседневной работы удобнее подключаться по SSH (Linux) или по удалённому рабочему столу (Windows) — как открыть к ним доступ, ниже.
---
## 4. Доступ к машине снаружи (проброс портов)
В сети **Автоматически (NAT)** машины не видны ни из домашней сети, ни из интернета, пока вы не откроете нужный порт на сервере.
1. Откройте **Сеть и доступ** → карточка **Открыть доступ**.
2. **Машина** — выберите машину (рядом показан её адрес).
3. **Что открыть** — сервис из списка: **SSH — удалённый доступ к Linux**, **Сайт (HTTP)**, **Сайт (HTTPS)**, **Удалённый рабочий стол Windows (RDP)**, **Minecraft**, MySQL, PostgreSQL, OpenVPN, WireGuard и другие — или **Другой порт**.
4. **Порт в машине** заполнится сам (для SSH — `22`), **Порт на сервере** — например, `2222`. Протокол TCP или UDP подставляется по сервису. Можно указать диапазон вида `10000-20000`.
5. Нажмите **Открыть доступ**. Теперь `адрес-сервера:2222` ведёт в машину:
```bash
ssh ubuntu@адрес-сервера -p 2222
```
Для Windows выберите **RDP** и подключайтесь приложением «Подключение к удалённому рабочему столу» по адресу `адрес-сервера:3389` (или другому порту сервера, который вы указали).
Кнопка **Проверить** рядом с правилом покажет, доходит ли трафик до машины и слушает ли она порт. Правила восстанавливаются после перезагрузки сервера сами. Чтобы сервис был доступен из интернета, дополнительно пробросьте тот же порт на роутере на адрес сервера.
Адреса в сети NAT закреплены за машинами по имени, поэтому пробросы не ломаются после перезагрузок. Если адрес машины не определился (например, система внутри ещё не запущена), в карточке **Адреса машин** его можно указать вручную.
Машинам в режиме **В локальной сети** проброс не нужен: у них свой адрес от роутера, как у ноутбука или телефона.
---
## 5. Снимок перед рискованными изменениями
Снимок — точка восстановления машины: перед обновлением системы, экспериментом с настройками или установкой сомнительной программы.
1. Страница машины → вкладка **Снимки** → карточка **Создать снимок**, при желании впишите описание («перед обновлением») → **Создать снимок**.
2. У работающей машины в снимок попадает и память — она на несколько секунд замрёт, зато вернётся ровно в то же состояние с открытыми программами. У выключенной сохраняется только диск.
3. Если что-то пошло не так — **Вернуться** рядом с нужным снимком. Машина откатится к тому моменту; всё, что сделано после снимка, пропадёт.
4. Ненужные снимки удаляйте: они занимают место внутри диска машины. На одну машину — до 8 снимков.
Ограничения: снимки работают только с дисками qcow2 (все машины, созданные панелью, такие). Для машин с UEFI-прошивкой (Windows 11, образы Alpine, все машины на ARM64) внутренние снимки в текущей версии libvirt недоступны — панель предупредит; для них используйте резервные копии.
---
## 6. Резервная копия
Резервная копия — полная копия настроек машины и всех её дисков в отдельной папке на сервере. В отличие от снимка, она не зависит от диска машины и переживёт его повреждение.
1. Страница машины → вкладка **Резервные копии** → **Создать копию** (заметка по желанию). Работающая машина будет корректно выключена, скопирована и запущена снова — в списке машин в это время видно, что происходит.
2. Копии всех машин собраны в разделе **Резервные копии**; они лежат в `/var/lib/virtuality/backups/<машина>/<дата-время>/`. Ход создания виден в **Задачах**, страницу можно закрыть.
3. **Восстановить**: выберите копию → укажите название — то же (тогда текущую машину придётся заменить, панель попросит подтверждение) или новое (получите вторую машину рядом с первой). Восстановленная машина создаётся выключенной. Файлы копии остаются на месте, восстанавливать из неё можно сколько угодно раз.
4. Перед восстановлением панель проверяет, хватит ли места в `/var/lib/virtuality`.
Копию можно делать и по расписанию из терминала сервера — например, задачей cron:
```bash ```bash
sudo /opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py web01 "ночная копия" sudo vhealth
ip -br a
ip route
virsh pool-list --all
``` ```
--- Нормальная сеть:
## Что дальше ```text
enp2s0 UP
br0 UP 10.0.0.200/24
default via 10.0.0.1 dev br0
```
- **Клонировать** (меню на странице машины) — точная копия с новым именем и своим адресом, удобно для тестов. ## Создание тестовой VM
- **Спящий режим** — сохранить память на диск и выключить; после запуска машина продолжит с того же места.
- **Автозапуск** — переключатель на странице машины: машина включится сама после перезагрузки сервера. ```bash
- **Ресурсы** — процессор и память можно поменять, когда машина выключена; диск — увеличить в любой момент во вкладке **Диски и загрузка**. cd ~/virtuality
- Раздел **Помощь** в панели повторяет главное из этого документа. git pull
sudo bash scripts/create_test_vm.sh
```
По умолчанию создаётся VM:
```text
Name: test-alpine
RAM: 1024 MB
CPU: 1 vCPU
Disk: 8 GB qcow2
ISO: Alpine Linux
Network: br0
Graphics: VNC
```
Можно задать имя:
```bash
sudo bash scripts/create_test_vm.sh test-alpine-2
```
## Проверка
```bash
virsh list --all
sudo virsh vncdisplay test-alpine
sudo vhealth
```
Если `vncdisplay` показывает `:0`, значит порт VNC — `5900`.
Если показывает `:1`, значит порт VNC — `5901`.
## Доступ через Cockpit
Открыть:
```text
https://SERVER_IP:9090
```
Дальше:
```text
Virtual machines → test-alpine → Console
```
## Что считается успешным результатом
Этап считается пройденным, если:
- VM появилась в `virsh list --all`;
- VM запущена;
- консоль открывается в Cockpit или через VNC;
- VM подключена к bridge `br0`;
- `sudo vhealth` не показывает критических ошибок по Virtuality.
## Следующий этап
После первой тестовой VM можно переходить к разработке собственной панели управления Virtuality:
- список VM;
- статусы VM;
- запуск/остановка;
- просмотр storage pools;
- создание VM через UI;
- базовый журнал событий.
-200
Просмотреть файл
@@ -1,200 +0,0 @@
# Установочный образ 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 без изменений.
---
## Где взять готовый образ
Workflow `.github/workflows/image.yml` собирает ISO в GitHub Actions:
- автоматически при выпуске — пуш тега `v*` (amd64 и arm64);
- при pull request, который меняет `image/**`, `scripts/virtuality_firstboot.sh`, `web/requirements.txt` или сам workflow;
- вручную: Actions → **Build ISO image** → Run workflow → архитектура `amd64`, `arm64` или `both`.
После сборки workflow проверяет, что в образе есть загрузочная запись UEFI, `autoinstall.yaml` разбирается как YAML, а в `grub.cfg` есть пункт Virtuality. Образы Ubuntu Server больше лимита GitHub Releases (2 ГБ на файл), поэтому ISO публикуются как **artifacts** запуска (`virtuality-iso-amd64`, `virtuality-iso-arm64`) и хранятся 30 дней. В артефакте — `.iso` и `.sha256`.
---
## Сборка своими руками
Нужна Linux-машина (root не нужен), около 10 ГБ свободного места и пакеты:
```bash
sudo apt install -y xorriso git curl python3-pip
```
Сборка:
```bash
git clone https://github.com/viktor138irk/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.0-ubuntu-26.04-amd64.iso
dist/virtuality-0.11.0-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 admin --password-hash '$(openssl passwd -6)' --hostname node01"
```
> ⚠️ Такой образ сам уходит в установку через 10 секунд в меню и **без подтверждения стирает самый большой диск** машины. Файл получает суффикс `-unattended`, пункты меню — пометку «автоматически, диск будет стёрт». Не оставляйте такую флешку в серверах.
---
## Что спрашивает установщик
Меню загрузки: **Установить Virtuality 0.11.0** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
Сценарий `image/autoinstall.yaml` оставляет интерактивными только три экрана установщика Ubuntu:
1. **Сеть** — обычно достаточно подтвердить адрес по DHCP.
2. **Диск** — какой диск использовать. Разметка: LVM на весь диск.
3. **Пользователь** — имя сервера, имя пользователя и пароль. Этот пользователь становится администратором панели (и входит по SSH).
Язык, раскладка, часовой пояс (по умолчанию определяется по сети), SSH-сервер с входом по паролю и всё остальное заданы заранее. В конце установщик копирует исходники в `/opt/virtuality/source`, `image.env` в `/etc/virtuality/` и включает `virtuality-firstboot.service`. Затем — перезагрузка.
---
## Первая загрузка
`scripts/virtuality_firstboot.sh` запускается службой `virtuality-firstboot.service` один раз. Порядок шагов сделан так, чтобы панель появилась первой и показывала прогресс всего остального:
| Шаг | Что происходит | Что видно |
|---|---|---|
| `panel` | `scripts/install_web_panel.sh` с `VIRTUALITY_PANEL_ONLY=1`: панель ставится офлайн из колёс и запускается. | Через минуту после загрузки панель отвечает на `http://адрес:8088`; на экране сервера — подсказка `Virtuality настраивается`. |
| `network` | Ожидание маршрута по умолчанию и DNS. | Если через 20 секунд сети нет, мастер пишет «Нет доступа в интернет. Подключите кабель или настройте сеть — установка продолжится сама». |
| `virtualization` | `install_virtuality_node.sh`: пакеты KVM/QEMU/libvirt, пулы, firewall. Самый долгий шаг, 5–15 минут. | Мастер показывает, какой пакет скачивается, распаковывается или настраивается. |
| `tools` | `scripts/install_healthcheck_command.sh`: команда `vhealth`. | |
| `finish` | `virtuality-ctl reconfigure`: юниты, firewall, перезапуск панели; на экран сервера выводится адрес панели и логин. | Мастер переходит к шагу «Сервер». |
Прогресс пишется в `/var/lib/virtuality/config/setup.json` (`stage`, `message`, список шагов со статусами), мастер опрашивает его через `/api/setup/state`. Каждый шаг идемпотентен и отмечается флагом в `/var/lib/virtuality/.firstboot-steps/`; если что-то упало, systemd перезапускает скрипт через 60 секунд, и он продолжает с первого незавершённого шага — мастер в это время показывает ошибку и хвост журнала. По завершении создаётся `/var/lib/virtuality/.firstboot-done`, и служба больше не запускается.
Если образ собран с `--no-wheels`, офлайн-установка панели не удастся; скрипт дождётся сети и поставит зависимости из PyPI.
Смотреть ход установки с сервера:
```bash
sudo journalctl -fu virtuality-firstboot
cat /var/log/virtuality/firstboot.log
```
Логин в панель — пользователь из установщика, пароль — его пароль. Дальше вас ведёт мастер настройки (см. README).
---
## Тема загрузочного меню
`image/grub/theme.txt` — тема GRUB (gfxmenu), `background.png` — фон 1024×768, `select_*.png` — подсветка выбранного пункта. `build-iso.sh` дописывает в `grub.cfg` подключение темы (`gfxterm`, шрифт `unicode.pf2`, `gfxmode=1024x768`); если модуль или шрифт недоступны, GRUB показывает обычное текстовое меню. Пункты меню переводятся на русский, таймаут — 10 секунд. Контрольная сумма изменённого `grub.cfg` обновляется в `md5sum.txt`, чтобы проверка целостности ISO проходила.
Фон рендерится из `image/grub/background.html` (тот же стиль, что у страницы входа панели), как описано в `image/grub/README.md`.
---
## Проверка образа
Сверьте контрольную сумму скачанного или собранного образа:
```bash
sha256sum -c virtuality-0.11.0-ubuntu-26.04-amd64.iso.sha256
```
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
```bash
xorriso -indev virtuality-0.11.0-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
xorriso -osirrox on -indev virtuality-0.11.0-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
```
---
## Запись на флешку
Нужна флешка от 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.0-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`.
+64 -46
Просмотреть файл
@@ -1,77 +1,95 @@
# Требования # Системные требования Virtuality
## Коротко Virtuality — это серверная система управления виртуализацией на базе KVM/QEMU/libvirt/Cockpit и собственной web-панели.
Подойдёт почти любой компьютер или мини-ПК не старше 10 лет с проводной сетью и включённой в BIOS/UEFI виртуализацией. Для одной-двух небольших машин достаточно 4 ГБ памяти; для Windows и нескольких машин сразу — 16 ГБ и SSD. ## Минимальные требования
## Операционная система Подходят только для тестового стенда и одной-двух лёгких VM.
Virtuality работает на: ```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: один проводной интерфейс
```
- **Ubuntu Server 26.04 LTS** — основа установочного ISO, рекомендуется; ## Рекомендуемые требования
- **Ubuntu Server 24.04 LTS**;
- **Debian 13**.
Другие системы на базе Debian/Ubuntu с `apt` скорее всего заработают, но не проверяются. Нужен systemd и netplan (для режима «домашняя сеть»). Установка с ISO делает всё сама; на готовую систему Virtuality ставится скриптом `install.sh` — ставьте её на чистый сервер, а не на рабочий компьютер. Для нормальной работы нескольких VM.
## Процессор и память ```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 Гбит/с |
Как считать память: сервер и панель занимают около 1 ГБ, остальное делят машины. Ubuntu Server внутри машины — от 1 ГБ, Windows 11 — от 4 ГБ (лучше 8). Диск машины занимает место постепенно, по мере заполнения, но резервные копии и снимки требуют запаса: планируйте хотя бы вдвое больше, чем суммарный размер дисков машин. `bootstrap.sh` и `install_virtuality_node.sh` проверяют:
**Виртуализация должна быть включена в BIOS/UEFI** (Intel VT-x / Virtualization Technology, AMD-V / SVM Mode). Без неё машины будут работать через медленную программную эмуляцию; панель предупредит об этом в разделе «Сервер», а `sudo vhealth` покажет ошибку `/dev/kvm missing`. - наличие root-прав;
- наличие `apt`;
- свободное место на `/`;
- свободное место для `/var/lib`;
- объём RAM;
- количество CPU cores;
- наличие флага аппаратной виртуализации `vmx` или `svm`.
Внутри другой виртуальной машины или на VPS Virtuality работает, только если включена вложенная виртуализация (nested virtualization). ## Значения по умолчанию
## ARM64
Платы и серверы на ARM64 (Raspberry Pi 4/5, Orange Pi 5, серверы с UEFI) поддерживаются: панель определяет профиль, ставит `qemu-system-arm` и прошивку UEFI для ARM. Гости на таких серверах — **только ARM64** (в каталоге готовых систем есть образы aarch64 для Ubuntu, Debian и Alpine); образы x86_64 на них не запускаются. Установка Windows на ARM64 не проверялась.
Для Raspberry Pi и Orange Pi желательно 8 ГБ памяти и загрузка с SSD/NVMe. Установочный ISO для них не подходит (они грузятся иначе) — используйте штатный Ubuntu Server для платы и `install.sh`. ARM64-ISO предназначен для серверов с UEFI.
## Сеть
- Проводное подключение к роутеру. По Wi-Fi сервер работать может, но режим «домашняя сеть» (мост) недоступен — только NAT.
- Доступ в интернет при установке: первая загрузка после ISO и `install.sh` скачивают пакеты из репозиториев Ubuntu/Debian (сотни мегабайт). Панель после установки в интернет не ходит, кроме проверки обновлений и скачивания образов по вашей команде.
- Порты на сервере: `8088` (панель по HTTP), `8443` (панель по HTTPS), `22` (SSH). Открывать их на роутере наружу не обязательно и без HTTPS не рекомендуется.
- Для сети NAT машинам выделяется подсеть `192.168.100.0/24` — она не должна совпадать с вашей домашней сетью. Если роутер раздаёт именно `192.168.100.x`, выбирайте режим «домашняя сеть» или смените подсеть роутера.
- Если хотите, чтобы сервисы машин были доступны из интернета, потребуется проброс портов на роутере на адрес сервера (и «белый» адрес от провайдера).
## Что проверяет установщик
`install.sh` и `install_virtuality_node.sh` перед установкой проверяют права root, наличие `apt`, свободное место на `/` и в `/var/lib`, память, количество ядер и флаг виртуализации `vmx`/`svm`. Пороги можно изменить переменными окружения:
```text ```text
VIRTUALITY_MIN_ROOT_FREE_MB=8192 VIRTUALITY_MIN_ROOT_FREE_MB=8192
VIRTUALITY_MIN_VAR_FREE_MB=20480 VIRTUALITY_MIN_VAR_FREE_MB=20480
VIRTUALITY_MIN_RAM_MB=4096 VIRTUALITY_MIN_RAM_MB=4096
VIRTUALITY_MIN_CPU_CORES=2 VIRTUALITY_MIN_CPU_CORES=2
VIRTUALITY_SKIP_REQUIREMENTS=1 # отключить проверки совсем — только для тестов
``` ```
Например, для тестового стенда с маленьким диском: ## Переопределение требований
Для тестового стенда можно временно уменьшить требования:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_MIN_VAR_FREE_MB=8192 bash curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/bootstrap.sh | sudo VIRTUALITY_MIN_VAR_FREE_MB=8192 bash
``` ```
Установка с ISO эти пороги не применяет: диск и память проверяются мастером настройки, который покажет предупреждение, но не остановит установку. Полностью отключить preflight-проверки можно так:
Освободить место перед установкой на существующей системе: ```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/bootstrap.sh | sudo VIRTUALITY_SKIP_REQUIREMENTS=1 bash
```
Использовать отключение проверок стоит только для тестов. Для реального сервера это плохая идея: VM быстро съедают диск.
## Как быстро проверить место
```bash ```bash
df -h df -h
sudo apt clean && sudo apt autoremove -y 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 journalctl --vacuum-time=3d sudo journalctl --vacuum-time=3d
``` ```
## Браузер ## Где Virtuality хранит данные
Панель работает в актуальных Chrome, Firefox, Safari и Edge, в том числе на телефоне. Экран машины (noVNC) требует поддержки WebSocket — она есть во всех перечисленных. При включённом HTTPS сертификат выпускается самим сервером, поэтому браузер один раз спросит, доверять ли ему. ```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 # логи установки и диагностики
```
+103 -34
Просмотреть файл
@@ -1,46 +1,115 @@
# Планы # Roadmap Virtuality
Virtuality — панель для одного домашнего сервера. Этот документ честно фиксирует, что уже есть в версии 0.11 и что планируется дальше. Порядок в списке планов — примерный приоритет, сроков нет. Подробный журнал изменений по версиям — в `updates/versions.json` и в разделе «Обновления» панели. Документ фиксирует направление разработки, чтобы проект не превратился в хаотичный комбайн.
## Сделано в 0.11 ## v0.1 — Фундамент ноды
**Установка и настройка** Цель: получить рабочую базу для запуска виртуализации.
- Установочный ISO на базе Ubuntu Server 26.04 LTS (amd64, arm64), сборка `make iso` и в GitHub Actions, полностью автоматический режим. Задачи:
- Первая загрузка: панель ставится первой и показывает ход установки в браузере, при ошибке продолжает сама.
- Мастер настройки: часовой пояс, пароль, HTTPS, сеть машин (NAT или домашняя сеть с автооткатом), отдельный диск под машины, обновления.
- Страница «Настройки», команда `virtuality-ctl`, установка одной командой на Ubuntu 24.04 / 26.04 и Debian 13.
**Машины** - базовый установщик `install_virtuality_node.sh`;
- установка KVM/QEMU/libvirt;
- установка Cockpit и Cockpit Machines;
- создание директорий хранения;
- создание libvirt storage pools;
- базовая настройка firewall;
- подготовка GitHub-синхронизации;
- документация проекта.
- Создание с ISO с выбором типа системы: Linux, Windows 11 / Server 2025 (UEFI + TPM), Windows 10, другая. ## v0.2 — Сеть и диагностика
- Машина из готового образа за минуту: каталог (Ubuntu 26.04/24.04, Debian 13, Alpine), скачивание по ссылке, cloud-init с пользователем, паролем и SSH-ключом, гостевой агент.
- Снимки (до 8 на машину), резервные копии с восстановлением, клонирование, увеличение диска, пауза и спящий режим, заметки, живая нагрузка.
- Экран машины в браузере только через панель (VNC закрыт снаружи).
**Сеть и сервер** Цель: безопасно подготовить сетевую основу.
- Сеть NAT с постоянными адресами, пробросы портов с проверкой и восстановлением после перезагрузки, домашняя сеть через мост `br0`. Задачи:
- HTTPS для панели, стабильный канал обновлений и ранний доступ, откат, ежедневное обслуживание, отчёт для поддержки, архив настроек.
- Убрано: Cockpit, консольный dashboard, эмуляция ARM64 на x86, загрузка по PXE, старые вспомогательные скрипты.
## Планируется - диагностический скрипт ноды;
- определение активного сетевого интерфейса;
- безопасная настройка bridge `br0`;
- резервная копия сетевых конфигов перед изменением;
- откат сетевой конфигурации;
- улучшение логов установщика.
- **Резервные копии по расписанию из панели** — сейчас копия по расписанию делается только командой в терминале; нужен график и хранение N последних копий. ## v0.3 — Прототип панели
- **Копии на внешний диск или сетевую папку** — сейчас копии лежат только в `/var/lib/virtuality/backups`.
- **Контейнеры LXC** — лёгкие Linux-контейнеры рядом с машинами, как в Proxmox.
- **Проброс устройств (PCI/USB passthrough)** — видеокарты, USB-ключи, диски целиком в машину.
- **Второй сервер (кластер из двух нод)** — общий список машин, перенос машины между серверами, копии на соседний сервер.
- **Пользователи и роли** — сейчас в панель входит один администратор; нужны дополнительные пользователи с ограниченными правами.
- **Уведомления** — о завершении задач, ошибках, нехватке места и доступных обновлениях (почта, Telegram).
- **Шаблоны машин** — сохранить настроенную машину как шаблон и создавать из него новые.
- **Дополнительные диски и сети у машины** — второй диск, вторая сетевая карта, изменение сети существующей машины.
- **Сертификат Let's Encrypt** — вместо самоподписанного, для доступа к панели по доменному имени.
- **История нагрузки** — графики процессора, памяти и диска за день и неделю по серверу и машинам.
- **Проверка обновлений образов каталога** — предлагать обновить скачанный облачный образ, когда вышел новый.
## Не планируется Цель: начать собственную панель управления.
- Многосерверные кластеры с общим хранилищем, Ceph, живая миграция — это уровень Proxmox для дата-центров, не для дома. Задачи:
- Поддержка других гипервизоров, кроме KVM/QEMU/libvirt.
- Гости x86_64 на ARM64-серверах через эмуляцию — слишком медленно, чтобы этим пользоваться. - лёгкая веб-панель;
- авторизация;
- список виртуальных машин;
- статус VM;
- старт/стоп/перезагрузка VM через libvirt;
- отображение storage pools;
- базовый журнал событий.
## v0.4 — Создание VM
Цель: добавить мастер создания виртуальных машин.
Задачи:
- менеджер ISO;
- форма создания VM;
- выбор CPU/RAM/disk;
- выбор ISO;
- выбор сети;
- создание qcow2-диска;
- запуск установки ОС;
- базовая консоль.
## v0.5 — Обновления и бэкапы
Цель: сделать платформу обслуживаемой.
Задачи:
- автообновлятор;
- резервная копия конфигов перед обновлением;
- история обновлений;
- ручной backup VM;
- список бэкапов;
- восстановление из backup.
## v0.6 — Пользователи и роли
Цель: подготовить систему к реальному использованию несколькими пользователями.
Задачи:
- роли: admin, operator, viewer;
- ограничения действий по ролям;
- журнал действий пользователей;
- базовые настройки безопасности.
## v0.7 — Мониторинг
Цель: видеть состояние ноды без терминального шаманства.
Задачи:
- CPU/RAM/disk usage;
- статус libvirt;
- статус Cockpit;
- количество VM;
- состояние storage pools;
- алерты;
- Telegram/email-уведомления в будущем.
## v1.0 — Первый стабильный релиз
Цель: рабочая одиночная нода Virtuality.
Минимальный состав:
- установка одной командой;
- управление VM;
- создание VM;
- сеть bridge;
- ISO manager;
- backup/restore;
- web dashboard;
- базовая безопасность;
- документация;
- обновлятор.
Двоичные данные
Двоичный файл не отображается.

До

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

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

До

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

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

До

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

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

До

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

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

До

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

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

До

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

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

До

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

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

До

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

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

До

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

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

До

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

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

До

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

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

До

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

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

До

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

-31
Просмотреть файл
@@ -1,31 +0,0 @@
#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
-274
Просмотреть файл
@@ -1,274 +0,0 @@
#!/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://github.com/viktor138irk/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 GitHub auto-update on installed nodes
--locale LOCALE Installer/system locale (default: ${LOCALE})
--keyboard LAYOUT Keyboard layout (default: ${KEYBOARD})
--timezone TZ System timezone (default: ${TIMEZONE} — 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"
[[ "$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
Просмотреть файл
@@ -1,19 +0,0 @@
[Unit]
Description=Virtuality first boot setup
Documentation=https://github.com/viktor138irk/virtuality/blob/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
Просмотреть файл
@@ -1,12 +0,0 @@
# Тема загрузочного меню
`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
Просмотреть файл
@@ -1,19 +0,0 @@
<!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
Просмотреть файл
@@ -1,41 +0,0 @@
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 с"
}
+56 -14
Просмотреть файл
@@ -21,7 +21,7 @@ if [[ "${EUID}" -ne 0 ]]; then
exit 1 exit 1
fi fi
chmod +x "$tmp_installer" chmod +x "$tmp_installer"
exec sudo --preserve-env=VIRTUALITY_INSTALL_URL,VIRTUALITY_USER,VIRTUALITY_WEB_PORT,VIRTUALITY_MIN_ROOT_FREE_MB,VIRTUALITY_MIN_VAR_FREE_MB,VIRTUALITY_MIN_RAM_MB,VIRTUALITY_MIN_CPU_CORES,VIRTUALITY_SKIP_REQUIREMENTS,VIRTUALITY_REPO_URL,VIRTUALITY_PROJECT_BASE_DIR,VIRTUALITY_PROJECT_DIR,VIRTUALITY_INSTALL_VERBOSE,VIRTUALITY_AUTO_UPDATE,VIRTUALITY_BRANCH 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_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" "$@"
fi fi
echo "Root or sudo is required. Run: curl -fsSL $SELF_INSTALL_URL | sudo bash" >&2 echo "Root or sudo is required. Run: curl -fsSL $SELF_INSTALL_URL | sudo bash" >&2
exit 1 exit 1
@@ -32,7 +32,9 @@ DEFAULT_INSTALL_USER="${SUDO_USER:-root}"
INSTALL_USER="${VIRTUALITY_USER:-$DEFAULT_INSTALL_USER}" INSTALL_USER="${VIRTUALITY_USER:-$DEFAULT_INSTALL_USER}"
PROJECT_BASE_DIR="${VIRTUALITY_PROJECT_BASE_DIR:-/opt/virtuality}" PROJECT_BASE_DIR="${VIRTUALITY_PROJECT_BASE_DIR:-/opt/virtuality}"
PROJECT_DIR="${VIRTUALITY_PROJECT_DIR:-${PROJECT_BASE_DIR}/source}" PROJECT_DIR="${VIRTUALITY_PROJECT_DIR:-${PROJECT_BASE_DIR}/source}"
BRANCH="${VIRTUALITY_BRANCH:-main}" 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}" WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}"
LOG_DIR="/var/log/virtuality" LOG_DIR="/var/log/virtuality"
LOG_FILE="${LOG_DIR}/install_$(date +%Y%m%d_%H%M%S).log" LOG_FILE="${LOG_DIR}/install_$(date +%Y%m%d_%H%M%S).log"
@@ -42,7 +44,7 @@ MIN_RAM_MB="${VIRTUALITY_MIN_RAM_MB:-4096}"
MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}" MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}"
SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}" SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}"
INSTALL_VERBOSE="${VIRTUALITY_INSTALL_VERBOSE:-0}" INSTALL_VERBOSE="${VIRTUALITY_INSTALL_VERBOSE:-0}"
export DEBIAN_FRONTEND=noninteractive CLEAN_BEFORE_INSTALL="${VIRTUALITY_CLEAN_BEFORE_INSTALL:-0}"
ESC="\033" ESC="\033"
RESET="${ESC}[0m" RESET="${ESC}[0m"
@@ -115,7 +117,7 @@ header() {
clear 2>/dev/null || true clear 2>/dev/null || true
echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}" echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Installer${RESET} ${CYAN}${BOLD}│${RESET}" echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Installer${RESET} ${CYAN}${BOLD}│${RESET}"
echo -e "${CYAN}${BOLD}│${RESET} One-command KVM / libvirt / web panel setup ${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 -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
echo echo
echo -e "${GRAY}Repository:${RESET} ${REPO_URL}" echo -e "${GRAY}Repository:${RESET} ${REPO_URL}"
@@ -248,6 +250,13 @@ ensure_user
step "Готовим рабочую директорию в /opt" step "Готовим рабочую директорию в /opt"
prepare_project_dir prepare_project_dir
if [[ "$CLEAN_BEFORE_INSTALL" == "1" && -x "$PROJECT_DIR/scripts/clean_install.sh" ]]; then
step "Очищаем старую кривую установку перед установкой"
run_logged "Старая установка очищена" bash "$PROJECT_DIR/scripts/clean_install.sh" --yes
elif [[ "$CLEAN_BEFORE_INSTALL" == "1" ]]; then
warn "Очистка запрошена, но clean_install.sh пока недоступен. Продолжаю установку."
fi
step "Проверяем место после подготовки пользователя" step "Проверяем место после подготовки пользователя"
root_free_after="$(free_mb_for_path /)" root_free_after="$(free_mb_for_path /)"
if (( root_free_after < MIN_ROOT_FREE_MB )); then if (( root_free_after < MIN_ROOT_FREE_MB )); then
@@ -257,17 +266,25 @@ ok "Место после подготовки пользователя: ${root_
step "Клонируем или обновляем репозиторий" step "Клонируем или обновляем репозиторий"
if [[ -d "$PROJECT_DIR/.git" ]]; then if [[ -d "$PROJECT_DIR/.git" ]]; then
run_logged "Репозиторий обновлён (${BRANCH})" run_as_user "cd '$PROJECT_DIR' && git fetch origin '$BRANCH' && git checkout -q -B '$BRANCH' 'origin/$BRANCH'" run_logged "Репозиторий обновлён" run_as_user "cd '$PROJECT_DIR' && git fetch origin main && git reset --hard origin/main && git pull --ff-only"
else else
run_logged "Репозиторий склонирован (${BRANCH})" run_as_user "cd '$PROJECT_BASE_DIR' && git clone --branch '$BRANCH' '$REPO_URL' source" 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
fi fi
step "Запускаем основной установщик ноды" step "Запускаем основной установщик ноды"
run_logged "Virtuality Node установлен" env VIRTUALITY_USER="$INSTALL_USER" bash "$PROJECT_DIR/install_virtuality_node.sh" run_logged "Virtuality Node установлен" bash "$PROJECT_DIR/install_virtuality_node.sh"
step "Устанавливаем healthcheck-команду" step "Устанавливаем healthcheck-команду"
run_logged "Healthcheck установлен" bash "$PROJECT_DIR/scripts/install_healthcheck_command.sh" run_logged "Healthcheck установлен" bash "$PROJECT_DIR/scripts/install_healthcheck_command.sh"
step "Устанавливаем консольный dashboard"
run_logged "Console dashboard установлен" bash "$PROJECT_DIR/scripts/install_console_dashboard.sh"
step "Устанавливаем web-панель" step "Устанавливаем web-панель"
if [[ "$INSTALL_VERBOSE" == "1" ]]; then if [[ "$INSTALL_VERBOSE" == "1" ]]; then
VIRTUALITY_AUTH_USER="$INSTALL_USER" VIRTUALITY_WEB_PORT="$WEB_PORT" bash "$PROJECT_DIR/scripts/install_web_panel.sh" 2>&1 | tee -a "$LOG_FILE" VIRTUALITY_AUTH_USER="$INSTALL_USER" VIRTUALITY_WEB_PORT="$WEB_PORT" bash "$PROJECT_DIR/scripts/install_web_panel.sh" 2>&1 | tee -a "$LOG_FILE"
@@ -295,6 +312,25 @@ else
[[ "$web_code" -eq 0 ]] && ok "Web-панель установлена" || fail "Web-панель не установлена" [[ "$web_code" -eq 0 ]] && ok "Web-панель установлена" || fail "Web-панель не установлена"
fi fi
if [[ "$RUN_BRIDGE" == "1" ]]; then
step "Настраиваем bridge br0"
if [[ -z "$BRIDGE_IFACE" ]]; then
BRIDGE_IFACE="$(ip route | awk '/default/ {print $5; exit}')"
fi
if [[ -z "$BRIDGE_IFACE" ]]; then
warn "Не удалось определить интерфейс для br0. Пропускаю bridge setup"
else
warn "Bridge меняет сеть. Если SSH зависнет, нужен физический доступ или консоль провайдера."
bash "$PROJECT_DIR/scripts/setup_bridge_br0.sh" "$BRIDGE_IFACE" static >> "$LOG_FILE" 2>&1 && ok "Bridge br0 настроен на ${BRIDGE_IFACE}" || warn "Bridge setup завершился с предупреждением. Проверь лог"
fi
else
warn "Bridge br0 не настраивался автоматически. Запуск вручную: sudo bash scripts/setup_bridge_br0.sh INTERFACE static"
fi
if [[ "$RUN_TEST_VM" == "1" ]]; then
step "Создаём тестовую VM"
run_logged "Тестовая VM создана" bash "$PROJECT_DIR/scripts/create_test_vm.sh" || warn "Тестовая VM не создана. Проверь ISO/bridge/log"
fi
step "Финальная диагностика" step "Финальная диагностика"
if command -v vhealth >/dev/null 2>&1; then if command -v vhealth >/dev/null 2>&1; then
@@ -310,12 +346,18 @@ echo -e "${GREEN}${BOLD}╭─────────────────
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Virtuality установка завершена${RESET} ${GREEN}${BOLD}│${RESET}" echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Virtuality установка завершена${RESET} ${GREEN}${BOLD}│${RESET}"
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}" echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
echo echo
echo -e "${BOLD}Панель:${RESET} http://${SERVER_IP}:${WEB_PORT}" echo -e "${BOLD}Cockpit:${RESET} https://${SERVER_IP}:9090"
echo -e "${BOLD}Логин:${RESET} ${INSTALL_USER} / пароль пользователя Linux" echo -e "${BOLD}Web Panel:${RESET} http://${SERVER_IP}:${WEB_PORT}"
echo -e "${BOLD}Исходники:${RESET} ${PROJECT_DIR}" echo -e "${BOLD}Login:${RESET} ${INSTALL_USER} / пароль Linux-пользователя"
echo -e "${BOLD}Лог:${RESET} ${LOG_FILE}" echo -e "${BOLD}Project:${RESET} ${PROJECT_DIR}"
echo -e "${BOLD}Log:${RESET} ${LOG_FILE}"
echo echo
echo -e "${BOLD}Дальше:${RESET} откройте панель в браузере — мастер настройки поможет включить HTTPS," echo -e "${BOLD}После установки:${RESET}"
echo "выбрать сеть для машин (NAT или ваша локальная сеть) и хранилище." echo " passwd ${INSTALL_USER} # если пароль ещё не задан или заблокирован"
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 echo
+33 -48
Просмотреть файл
@@ -3,7 +3,7 @@ set -euo pipefail
# ========================================================== # ==========================================================
# Virtuality Node Installer # Virtuality Node Installer
# Clean step-by-step installer for KVM/QEMU/libvirt # Clean step-by-step installer for KVM/QEMU/libvirt/Cockpit
# ========================================================== # ==========================================================
PROJECT_NAME="Virtuality" PROJECT_NAME="Virtuality"
@@ -13,17 +13,15 @@ ISO_DIR="${STORAGE_DIR}/iso"
IMAGES_DIR="${STORAGE_DIR}/images" IMAGES_DIR="${STORAGE_DIR}/images"
BACKUP_DIR="${STORAGE_DIR}/backups" BACKUP_DIR="${STORAGE_DIR}/backups"
LOG_DIR="/var/log/virtuality" LOG_DIR="/var/log/virtuality"
COCKPIT_PORT="9090"
LOG_FILE="${LOG_DIR}/install_node_$(date +%Y%m%d_%H%M%S).log" LOG_FILE="${LOG_DIR}/install_node_$(date +%Y%m%d_%H%M%S).log"
TOTAL_STEPS=12 TOTAL_STEPS=13
CURRENT_STEP=0 CURRENT_STEP=0
MIN_ROOT_FREE_MB="${VIRTUALITY_MIN_ROOT_FREE_MB:-8192}" MIN_ROOT_FREE_MB="${VIRTUALITY_MIN_ROOT_FREE_MB:-8192}"
MIN_VAR_FREE_MB="${VIRTUALITY_MIN_VAR_FREE_MB:-20480}" MIN_VAR_FREE_MB="${VIRTUALITY_MIN_VAR_FREE_MB:-20480}"
MIN_RAM_MB="${VIRTUALITY_MIN_RAM_MB:-4096}" MIN_RAM_MB="${VIRTUALITY_MIN_RAM_MB:-4096}"
MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}" MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}"
SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}" SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}"
export DEBIAN_FRONTEND=noninteractive
# Keep existing configuration files without asking: there is nobody to answer on first boot.
APT_OPTS=(-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold)
ESC="\033" ESC="\033"
RESET="${ESC}[0m" RESET="${ESC}[0m"
@@ -46,11 +44,12 @@ print_header() {
clear 2>/dev/null || true clear 2>/dev/null || true
echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}" echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Node Installer${RESET} ${CYAN}${BOLD}│${RESET}" echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Node Installer${RESET} ${CYAN}${BOLD}│${RESET}"
echo -e "${CYAN}${BOLD}│${RESET} KVM / QEMU / libvirt virtualization node ${CYAN}${BOLD}│${RESET}" echo -e "${CYAN}${BOLD}│${RESET} KVM / QEMU / libvirt / Cockpit virtualization node ${CYAN}${BOLD}│${RESET}"
echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}" echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
echo echo
echo -e "${GRAY}Лог установки:${RESET} ${LOG_FILE}" echo -e "${GRAY}Лог установки:${RESET} ${LOG_FILE}"
echo -e "${GRAY}Storage:${RESET} ${STORAGE_DIR}" echo -e "${GRAY}Storage:${RESET} ${STORAGE_DIR}"
echo -e "${GRAY}Cockpit:${RESET} ${COCKPIT_PORT}/tcp"
echo echo
} }
@@ -76,18 +75,15 @@ run_logged() {
spinner=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏") spinner=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
i=0 i=0
while kill -0 "$pid" 2>/dev/null; do while kill -0 "$pid" 2>/dev/null; do
# The spinner only makes sense on a terminal; first boot writes this output to a log.
if [[ -t 1 ]]; then
elapsed=$(( $(date +%s) - start_ts )) elapsed=$(( $(date +%s) - start_ts ))
symbol="${spinner[$((i % ${#spinner[@]}))]}" symbol="${spinner[$((i % ${#spinner[@]}))]}"
printf "\r ${CYAN}%s${RESET} %s... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "$symbol" "$description" "$elapsed" "$LOG_FILE" printf "\r ${CYAN}%s${RESET} %s... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "$symbol" "$description" "$elapsed" "$LOG_FILE"
fi
sleep 1 sleep 1
i=$((i + 1)) i=$((i + 1))
done done
code=0 wait "$pid"
wait "$pid" || code=$? code=$?
[[ -t 1 ]] && printf "\r%*s\r" 120 "" printf "\r%*s\r" 120 ""
if [[ "$code" -eq 0 ]]; then if [[ "$code" -eq 0 ]]; then
ok "$description" ok "$description"
else else
@@ -95,22 +91,6 @@ run_logged() {
fi 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_state() { systemctl is-active "$1" 2>/dev/null || echo "inactive"; }
service_enabled() { systemctl is-enabled "$1" 2>/dev/null || echo "disabled"; } 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}'; } free_mb_for_path() { local path="$1"; mkdir -p "$path" 2>/dev/null || true; df -Pm "$path" | awk 'NR==2 {print $4}'; }
@@ -169,27 +149,21 @@ else
fi fi
step "Обновляем apt cache" step "Обновляем apt cache"
run_logged "apt update выполнен" apt-get update run_logged "apt update выполнен" apt update
# A previous attempt may have stopped half way (power loss, a question from dpkg): finish it first.
run_logged "Незавершённые установки пакетов доделаны" dpkg --configure -a --force-confdef --force-confold
step "Устанавливаем базовые утилиты и зависимости диагностики" step "Устанавливаем базовые утилиты и зависимости диагностики"
install_available "Базовые пакеты и диагностические утилиты установлены" \ run_logged "Базовые пакеты и диагностические утилиты установлены" apt install -y \
curl wget git nano htop btop tree ncdu jq unzip xz-utils tar gzip ca-certificates gnupg lsb-release \ 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 bind9-dnsutils net-tools openssh-client software-properties-common apt-transport-https ufw rsync iproute2 iptables nftables dnsutils net-tools openssh-client
step "Устанавливаем KVM/QEMU/libvirt" step "Устанавливаем KVM/QEMU/libvirt"
case "$(uname -m)" in run_logged "Пакеты виртуализации установлены" apt install -y \
aarch64|arm64) QEMU_SYSTEM_PACKAGES=(qemu-system-arm qemu-efi-aarch64) ;; qemu-kvm qemu-utils libvirt-daemon-system libvirt-clients virtinst \
*) QEMU_SYSTEM_PACKAGES=(qemu-system-x86 ovmf) ;; bridge-utils dnsmasq-base ovmf swtpm cloud-image-utils
esac
install_available "Пакеты виртуализации установлены" \
"${QEMU_SYSTEM_PACKAGES[@]}" qemu-utils libvirt-daemon-system libvirt-clients virtinst \
bridge-utils dnsmasq-base swtpm cloud-image-utils xorriso
step "Устанавливаем зависимости панели управления" step "Устанавливаем Cockpit и модули"
install_available "Пакеты панели установлены" \ run_logged "Cockpit установлен" apt install -y \
python3 python3-venv rsync openssl nftables novnc python3-websockify cockpit cockpit-machines cockpit-networkmanager cockpit-storaged cockpit-packagekit
step "Создаём структуру Virtuality" step "Создаём структуру Virtuality"
run_logged "Директории Virtuality созданы" mkdir -p "$PROJECT_DIR" "$STORAGE_DIR" "$ISO_DIR" "$IMAGES_DIR" "$BACKUP_DIR" "$LOG_DIR" run_logged "Директории Virtuality созданы" mkdir -p "$PROJECT_DIR" "$STORAGE_DIR" "$ISO_DIR" "$IMAGES_DIR" "$BACKUP_DIR" "$LOG_DIR"
@@ -200,26 +174,28 @@ ok "Images: ${IMAGES_DIR}"
ok "Backups: ${BACKUP_DIR}" ok "Backups: ${BACKUP_DIR}"
step "Добавляем пользователя в группы libvirt/kvm" step "Добавляем пользователя в группы libvirt/kvm"
REAL_USER="${VIRTUALITY_USER:-${SUDO_USER:-root}}" REAL_USER="${SUDO_USER:-root}"
if [[ "$REAL_USER" != "root" ]]; then if [[ "$REAL_USER" != "root" ]]; then
usermod -aG libvirt "$REAL_USER" >> "$LOG_FILE" 2>&1 || warn "Не удалось добавить ${REAL_USER} в libvirt" 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" usermod -aG kvm "$REAL_USER" >> "$LOG_FILE" 2>&1 || warn "Не удалось добавить ${REAL_USER} в kvm"
ok "Пользователь ${REAL_USER} добавлен в группы libvirt/kvm" ok "Пользователь ${REAL_USER} добавлен в группы libvirt/kvm"
warn "Чтобы группы применились, нужно выйти из SSH и зайти снова" warn "Чтобы группы применились, нужно выйти из SSH и зайти снова"
else else
warn "Запуск под root без SUDO_USER/VIRTUALITY_USER; пользователь не добавлен в группы" warn "Запуск под root без SUDO_USER; пользователь не добавлен в группы"
fi fi
step "Включаем systemd-сервисы" step "Включаем systemd-сервисы"
run_logged "libvirtd включён и запущен" systemctl enable --now libvirtd run_logged "libvirtd включён и запущен" systemctl enable --now libvirtd
run_logged "virtlogd включён и запущен" systemctl enable --now virtlogd run_logged "virtlogd включён и запущен" systemctl enable --now virtlogd
run_logged "cockpit.socket включён и запущен" systemctl enable --now cockpit.socket
ok "libvirtd: $(service_state libvirtd) / $(service_enabled libvirtd)" ok "libvirtd: $(service_state libvirtd) / $(service_enabled libvirtd)"
ok "virtlogd: $(service_state virtlogd) / $(service_enabled virtlogd)" ok "virtlogd: $(service_state virtlogd) / $(service_enabled virtlogd)"
ok "cockpit.socket: $(service_state cockpit.socket) / $(service_enabled cockpit.socket)"
step "Настраиваем firewall" step "Настраиваем firewall"
run_logged "Разрешён OpenSSH" ufw allow OpenSSH run_logged "Разрешён OpenSSH" ufw allow OpenSSH
# Consoles are proxied by the panel; VNC ports stay closed from outside. run_logged "Разрешён Cockpit ${COCKPIT_PORT}/tcp" ufw allow "${COCKPIT_PORT}/tcp"
ufw delete allow 5900:5999/tcp >> "$LOG_FILE" 2>&1 || true run_logged "Разрешены VNC-порты 5900:5999/tcp" ufw allow 5900:5999/tcp
if ufw status | grep -qi inactive; then if ufw status | grep -qi inactive; then
echo "y" | ufw enable >> "$LOG_FILE" 2>&1 && ok "UFW включён" || warn "Не удалось включить UFW" echo "y" | ufw enable >> "$LOG_FILE" 2>&1 && ok "UFW включён" || warn "Не удалось включить UFW"
else else
@@ -246,6 +222,7 @@ ISO_DIR="${ISO_DIR}"
IMAGES_DIR="${IMAGES_DIR}" IMAGES_DIR="${IMAGES_DIR}"
BACKUP_DIR="${BACKUP_DIR}" BACKUP_DIR="${BACKUP_DIR}"
LOG_DIR="${LOG_DIR}" LOG_DIR="${LOG_DIR}"
COCKPIT_PORT="${COCKPIT_PORT}"
EOF EOF
ok "Создан ${PROJECT_DIR}/virtuality.env" ok "Создан ${PROJECT_DIR}/virtuality.env"
@@ -256,6 +233,11 @@ if command -v virsh >/dev/null 2>&1; then
else else
warn "virsh не найден после установки" warn "virsh не найден после установки"
fi fi
if ss -tulpn 2>/dev/null | grep -q ':9090'; then
ok "Cockpit слушает порт 9090"
else
warn "Порт 9090 не виден в ss; Cockpit может быть socket-activated"
fi
if [[ -e /dev/kvm ]]; then if [[ -e /dev/kvm ]]; then
ok "/dev/kvm доступен" ok "/dev/kvm доступен"
else else
@@ -269,6 +251,7 @@ echo -e "${GREEN}${BOLD}╭─────────────────
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Установка Virtuality Node завершена${RESET} ${GREEN}${BOLD}│${RESET}" echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Установка Virtuality Node завершена${RESET} ${GREEN}${BOLD}│${RESET}"
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}" echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
echo echo
echo -e "${BOLD}Cockpit:${RESET} https://${SERVER_IP}:${COCKPIT_PORT}"
echo -e "${BOLD}Project:${RESET} ${PROJECT_DIR}" echo -e "${BOLD}Project:${RESET} ${PROJECT_DIR}"
echo -e "${BOLD}ISO:${RESET} ${ISO_DIR}" echo -e "${BOLD}ISO:${RESET} ${ISO_DIR}"
echo -e "${BOLD}Images:${RESET} ${IMAGES_DIR}" echo -e "${BOLD}Images:${RESET} ${IMAGES_DIR}"
@@ -279,7 +262,9 @@ echo -e "${BOLD}Проверка:${RESET}"
echo " virsh list --all" echo " virsh list --all"
echo " virsh pool-list --all" echo " virsh pool-list --all"
echo " systemctl status libvirtd --no-pager" echo " systemctl status libvirtd --no-pager"
echo " systemctl status cockpit.socket --no-pager"
echo echo
line line
echo -e "${DIM}Дальше: sudo bash scripts/install_web_panel.sh — панель управления и мастер настройки${RESET}" 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}"
line line
-3
Просмотреть файл
@@ -1,3 +0,0 @@
[pytest]
testpaths = tests
addopts = -p no:cacheprovider
-4
Просмотреть файл
@@ -1,4 +0,0 @@
# Development and CI tools
pytest==8.3.4
httpx==0.28.1
pyflakes==3.2.0
+7 -11
Просмотреть файл
@@ -7,11 +7,8 @@ STATE_FILE="${STATE_DIR}/state.json"
LOG_FILE="/var/log/virtuality/update.log" LOG_FILE="/var/log/virtuality/update.log"
REMOTE="${VIRTUALITY_UPDATE_REMOTE:-origin}" REMOTE="${VIRTUALITY_UPDATE_REMOTE:-origin}"
BRANCH="${VIRTUALITY_UPDATE_BRANCH:-main}" BRANCH="${VIRTUALITY_UPDATE_BRANCH:-main}"
REPO_ZIP_URL="${VIRTUALITY_UPDATE_ZIP_URL:-https://github.com/viktor138irk/virtuality/archive/refs/heads/${BRANCH}.zip}"
TMP_DIR="${STATE_DIR}/tmp" TMP_DIR="${STATE_DIR}/tmp"
# shellcheck source=scripts/update_target.sh
. "$(dirname "${BASH_SOURCE[0]}")/update_target.sh"
resolve_update_target
REPO_ZIP_URL="${VIRTUALITY_UPDATE_ZIP_URL:-$TARGET_ZIP_URL}"
mkdir -p "$STATE_DIR" "$TMP_DIR" "$(dirname "$LOG_FILE")" mkdir -p "$STATE_DIR" "$TMP_DIR" "$(dirname "$LOG_FILE")"
@@ -51,7 +48,7 @@ try_run() {
fetch_zip_update() { fetch_zip_update() {
local zip_file extract_dir extracted_root local zip_file extract_dir extracted_root
zip_file="${TMP_DIR}/virtuality-${TARGET_LABEL}.zip" zip_file="${TMP_DIR}/virtuality-${BRANCH}.zip"
extract_dir="${TMP_DIR}/zip-update" extract_dir="${TMP_DIR}/zip-update"
rm -rf "$zip_file" "$extract_dir" rm -rf "$zip_file" "$extract_dir"
mkdir -p "$extract_dir" mkdir -p "$extract_dir"
@@ -130,10 +127,9 @@ GIT_UPDATED=0
if [ -d "$SOURCE_DIR/.git" ]; then if [ -d "$SOURCE_DIR/.git" ]; then
cd "$SOURCE_DIR" || exit 1 cd "$SOURCE_DIR" || exit 1
write_state "running" "Получаем изменения из GitHub через git" write_state "running" "Получаем изменения из GitHub через git"
if try_run git fetch --tags --force "$REMOTE" "$BRANCH"; then if try_run git fetch "$REMOTE" "$BRANCH"; then
resolve_update_target TARGET_REF="${REMOTE}/${BRANCH}"
log "channel: ${UPDATE_CHANNEL}, target: ${TARGET_LABEL}" TARGET_COMMIT="$(git rev-parse "$TARGET_REF" 2>/dev/null || true)"
TARGET_COMMIT="$(git rev-parse "${TARGET_REF}^{commit}" 2>/dev/null || true)"
if [ -n "$TARGET_COMMIT" ]; then if [ -n "$TARGET_COMMIT" ]; then
LOCAL_STATUS="$(git status --porcelain 2>/dev/null || true)" LOCAL_STATUS="$(git status --porcelain 2>/dev/null || true)"
if [ -n "$LOCAL_STATUS" ]; then if [ -n "$LOCAL_STATUS" ]; then
@@ -141,8 +137,8 @@ if [ -d "$SOURCE_DIR/.git" ]; then
printf '%s\n' "$LOCAL_STATUS" >> "$LOG_FILE" printf '%s\n' "$LOCAL_STATUS" >> "$LOG_FILE"
fi fi
write_state "running" "Синхронизируем исходники с GitHub через git reset" write_state "running" "Синхронизируем исходники с GitHub через git reset"
log "Deploy mode: git checkout -B ${BRANCH} ${TARGET_LABEL}" log "Deploy mode: git reset --hard $TARGET_REF"
if try_run git checkout -q -f -B "$BRANCH" "$TARGET_COMMIT" && try_run git clean -fd; then if try_run git reset --hard "$TARGET_REF" && try_run git clean -fd; then
GIT_UPDATED=1 GIT_UPDATED=1
else else
log "git reset/clean failed, ZIP fallback will be used" log "git reset/clean failed, ZIP fallback will be used"
+5 -13
Просмотреть файл
@@ -9,8 +9,7 @@ LOG_FILE="/var/log/virtuality/update.log"
REMOTE="${VIRTUALITY_UPDATE_REMOTE:-origin}" REMOTE="${VIRTUALITY_UPDATE_REMOTE:-origin}"
BRANCH="${VIRTUALITY_UPDATE_BRANCH:-main}" BRANCH="${VIRTUALITY_UPDATE_BRANCH:-main}"
APPLY_SCRIPT="${SOURCE_DIR}/scripts/apply_github_update.sh" APPLY_SCRIPT="${SOURCE_DIR}/scripts/apply_github_update.sh"
# shellcheck source=scripts/update_target.sh ZIP_URL="${VIRTUALITY_UPDATE_ZIP_URL:-https://github.com/viktor138irk/virtuality/archive/refs/heads/${BRANCH}.zip}"
. "${SOURCE_DIR}/scripts/update_target.sh"
mkdir -p "$STATE_DIR" "$(dirname "$LOG_FILE")" mkdir -p "$STATE_DIR" "$(dirname "$LOG_FILE")"
@@ -90,20 +89,13 @@ diag_network() {
fi fi
if [ -d "$SOURCE_DIR/.git" ]; then if [ -d "$SOURCE_DIR/.git" ]; then
if git fetch --quiet --tags --force "$REMOTE" "$BRANCH" >> "$LOG_FILE" 2>&1; then if git fetch --quiet "$REMOTE" "$BRANCH" >> "$LOG_FILE" 2>&1; then
resolve_update_target latest="$(git rev-parse "${REMOTE}/${BRANCH}" 2>/dev/null || true)"
log "channel: ${UPDATE_CHANNEL}, target: ${TARGET_LABEL}"
latest="$(git rev-parse "${TARGET_REF}^{commit}" 2>/dev/null || true)"
if [ -n "$latest" ] && [ "$current" = "$latest" ]; then if [ -n "$latest" ] && [ "$current" = "$latest" ]; then
log "no updates: ${current:0:12} / version $current_version" log "no updates: ${current:0:12} / version $current_version"
write_state "idle" "Автообновление: новых обновлений нет" write_state "idle" "Автообновление: новых обновлений нет"
exit 0 exit 0
fi fi
if [ -n "$latest" ] && [ "$current" != "unknown" ] && ! git merge-base --is-ancestor "$current" "$latest" 2>/dev/null; then
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}" log "update may be available or current commit unknown: ${current:0:12} -> ${latest:0:12}"
else else
log "git fetch failed; diagnostics and apply ZIP fallback will be used" log "git fetch failed; diagnostics and apply ZIP fallback will be used"
@@ -120,6 +112,6 @@ diag_network() {
exit 0 exit 0
fi fi
write_state "running" "Автообновление: устанавливаем обновление" write_state "running" "Автообновление: проверяем и устанавливаем обновление через устойчивый механизм"
VIRTUALITY_SOURCE_DIR="$SOURCE_DIR" VIRTUALITY_UPDATE_REMOTE="$REMOTE" VIRTUALITY_UPDATE_BRANCH="$BRANCH" bash "$APPLY_SCRIPT" VIRTUALITY_SOURCE_DIR="$SOURCE_DIR" VIRTUALITY_UPDATE_REMOTE="$REMOTE" VIRTUALITY_UPDATE_BRANCH="$BRANCH" VIRTUALITY_UPDATE_ZIP_URL="$ZIP_URL" bash "$APPLY_SCRIPT"
) 9>"$LOCK_FILE" ) 9>"$LOCK_FILE"
+90
Просмотреть файл
@@ -0,0 +1,90 @@
#!/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
Просмотреть файл
@@ -0,0 +1,111 @@
#!/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
Просмотреть файл
@@ -0,0 +1,71 @@
#!/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
Просмотреть файл
@@ -0,0 +1,104 @@
#!/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
Просмотреть файл
@@ -0,0 +1,81 @@
#!/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 "============================================================"
+20
Просмотреть файл
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="${VIRTUALITY_APP_DIR:-/opt/virtuality/web}"
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DISK_IMAGES_DIR="/var/lib/virtuality/disk-images"
mkdir -p "$DISK_IMAGES_DIR"
chmod 755 "$DISK_IMAGES_DIR"
if [[ -f "${REPO_DIR}/scripts/patch_disk_images.py" ]]; then
python3 "${REPO_DIR}/scripts/patch_disk_images.py" "${APP_DIR}/app.py"
else
echo "patch_disk_images.py not found" >&2
exit 1
fi
systemctl restart virtuality-web.service
echo "Disk image support installed. Open: /disk-images"
+1 -1
Просмотреть файл
@@ -4,7 +4,7 @@ set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC="${REPO_DIR}/scripts/virtuality_healthcheck.sh" SRC="${REPO_DIR}/scripts/virtuality_healthcheck.sh"
DST="/usr/local/bin/vhealth" DST="/usr/local/bin/vhealth"
TARGET_USER="${VIRTUALITY_USER:-${SUDO_USER:-${USER:-root}}}" TARGET_USER="${SUDO_USER:-${USER:-root}}"
if [[ "$EUID" -ne 0 ]]; then if [[ "$EUID" -ne 0 ]]; then
echo "Ошибка: запусти через sudo: sudo bash scripts/install_healthcheck_command.sh" echo "Ошибка: запусти через sudo: sudo bash scripts/install_healthcheck_command.sh"
+332 -153
Просмотреть файл
@@ -1,65 +1,26 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# ========================================================== # Virtuality Web Panel Installer
# Virtuality web panel installer # Auto-detects x86_64 / Raspberry Pi ARM64 / Orange Pi 5 ARM64 / generic ARM64 profile.
# Copies web/ to /opt/virtuality/web (keeping the previous
# version for rollback), builds the virtualenv (offline from
# bundled wheels when available), installs virtuality-ctl and
# lets it create the systemd units, TLS and firewall rules.
#
# VIRTUALITY_PANEL_ONLY=1 skip apt entirely (first boot from the image,
# before the network is up)
# VIRTUALITY_AUTH_USER Linux user that logs in to the panel
# VIRTUALITY_WEB_PORT HTTP port (default 8088)
# ==========================================================
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WEB_DIR="${REPO_DIR}/web" WEB_DIR="${REPO_DIR}/web"
APP_DIR="/opt/virtuality/web" APP_DIR="/opt/virtuality/web"
VENV_DIR="/opt/virtuality/venv" VENV_DIR="/opt/virtuality/venv"
CTL_SRC="${REPO_DIR}/scripts/virtuality-ctl" SERVICE_FILE="/etc/systemd/system/virtuality-web.service"
CTL_DST="/usr/local/bin/virtuality-ctl" AUTO_UPDATE_SERVICE_FILE="/etc/systemd/system/virtuality-auto-update.service"
AUTO_UPDATE_TIMER_FILE="/etc/systemd/system/virtuality-auto-update.timer"
PORT="${VIRTUALITY_WEB_PORT:-8088}"
AUTH_USER="${VIRTUALITY_AUTH_USER:-${SUDO_USER:-viktor}}"
LOG_DIR="/var/log/virtuality" LOG_DIR="/var/log/virtuality"
LOG_FILE="${LOG_DIR}/install_web_panel_$(date +%Y%m%d_%H%M%S).log" LOG_FILE="${LOG_DIR}/install_web_panel_$(date +%Y%m%d_%H%M%S).log"
CONFIG_DIR="/var/lib/virtuality/config" PROFILE_DIR="/var/lib/virtuality/config"
PROFILE_FILE="${CONFIG_DIR}/host_profile.json" PROFILE_FILE="${PROFILE_DIR}/host_profile.json"
SESSION_SECRET_FILE="${CONFIG_DIR}/session_secret" SESSION_SECRET_FILE="${PROFILE_DIR}/session_secret"
NODE_CONFIG_FILE="${CONFIG_DIR}/web.env"
SETUP_STATE_FILE="${CONFIG_DIR}/setup.json"
UPLOAD_TMP_DIR="/var/lib/virtuality/tmp" UPLOAD_TMP_DIR="/var/lib/virtuality/tmp"
WHEELS_DIR="${VIRTUALITY_WHEELS_DIR:-${REPO_DIR}/wheels}" TOTAL_STEPS=13
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 CURRENT_STEP=0
TOTAL_STEPS=9
ESC="\033" ESC="\033"
RESET="${ESC}[0m" RESET="${ESC}[0m"
@@ -72,22 +33,36 @@ CYAN="${ESC}[36m"
BLUE="${ESC}[34m" BLUE="${ESC}[34m"
GRAY="${ESC}[90m" GRAY="${ESC}[90m"
mkdir -p "$LOG_DIR" "$CONFIG_DIR" "$UPLOAD_TMP_DIR" mkdir -p "$LOG_DIR" "$PROFILE_DIR" "$UPLOAD_TMP_DIR"
chmod 1777 "$UPLOAD_TMP_DIR" chmod 1777 "$UPLOAD_TMP_DIR"
timestamp() { date '+%Y-%m-%d %H:%M:%S'; } timestamp() { date '+%Y-%m-%d %H:%M:%S'; }
line() { printf '%*s\n' 72 '' | tr ' ' '─'; } line() { printf '%*s\n' 72 '' | tr ' ' '─'; }
log() { echo "[$(timestamp)] $*" >> "$LOG_FILE"; } log() { echo "[$(timestamp)] $*" >> "$LOG_FILE"; }
print_header() {
clear 2>/dev/null || true
echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Web Panel Installer${RESET} ${CYAN}${BOLD}│${RESET}"
echo -e "${CYAN}${BOLD}│${RESET} Auto profile: x86 / VPS / Raspberry / Orange Pi 5 ${CYAN}${BOLD}│${RESET}"
echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
echo
echo -e "${GRAY}Лог установки:${RESET} ${LOG_FILE}"
echo -e "${GRAY}Порт панели:${RESET} ${PORT}"
echo -e "${GRAY}Linux auth user:${RESET} ${AUTH_USER}"
echo
}
step() { step() {
CURRENT_STEP=$((CURRENT_STEP + 1)) CURRENT_STEP=$((CURRENT_STEP + 1))
echo echo
echo -e "${BLUE}${BOLD}[${CURRENT_STEP}/${TOTAL_STEPS}]${RESET} ${BOLD}$*${RESET}" echo -e "${BLUE}${BOLD}[${CURRENT_STEP}/${TOTAL_STEPS}]${RESET} ${BOLD}$*${RESET}"
log "STEP ${CURRENT_STEP}/${TOTAL_STEPS}: $*" log "STEP ${CURRENT_STEP}/${TOTAL_STEPS}: $*"
} }
ok() { echo -e " ${GREEN}✓${RESET} $*"; log "OK: $*"; } ok() { echo -e " ${GREEN}✓${RESET} $*"; log "OK: $*"; }
warn() { echo -e " ${YELLOW}!${RESET} $*"; log "WARN: $*"; } warn() { echo -e " ${YELLOW}!${RESET} $*"; log "WARN: $*"; }
fail() { echo -e " ${RED}✗${RESET} $*"; log "ERROR: $*"; echo; echo -e "${RED}${BOLD}Установка остановлена.${RESET} Подробности в логе: ${LOG_FILE}"; tail -n 20 "$LOG_FILE" 2>/dev/null || true; exit 1; } fail() { echo -e " ${RED}✗${RESET} $*"; log "ERROR: $*"; echo; echo -e "${RED}${BOLD}Установка остановлена.${RESET} Подробности в логе: ${LOG_FILE}"; exit 1; }
run_logged() { run_logged() {
local description="$1" local description="$1"
@@ -100,149 +75,353 @@ run_logged() {
fi fi
} }
restore_canonical_templates() {
local templates=("_sidebar.html" "dashboard.html" "vm_create.html" "vm_detail.html" "iso.html" "disk_images.html" "operations.html" "operation_detail.html" "host.html" "network.html" "logs.html" "update.html")
local name=""
for name in "${templates[@]}"; do
if [[ -f "${WEB_DIR}/templates/${name}" ]]; then
cp "${WEB_DIR}/templates/${name}" "${APP_DIR}/templates/${name}"
fi
done
}
service_state() { systemctl is-active "$1" 2>/dev/null || echo "inactive"; }
require_root() { [[ "$EUID" -eq 0 ]] || fail "Запусти через sudo: sudo bash scripts/install_web_panel.sh"; }
json_value() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], ""))' "$PROFILE_FILE" "$1" 2>/dev/null || true; } 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; }
echo -e "${CYAN}${BOLD}Virtuality — установка панели управления${RESET}" print_header
echo -e "${GRAY}Лог: ${LOG_FILE}${RESET}"
step "Проверяем права и исходники" step "Проверяем права и структуру проекта"
[[ "$EUID" -eq 0 ]] || fail "Запустите через sudo: sudo bash scripts/install_web_panel.sh" require_root
[[ -f "$WEB_DIR/app.py" && -f "$WEB_DIR/requirements.txt" ]] || fail "Не найдены исходники панели в ${WEB_DIR}" ok "Права root подтверждены"
[[ -f "$CTL_SRC" ]] || fail "Не найден ${CTL_SRC}" [[ -d "$WEB_DIR" ]] || fail "Не найдена директория web: $WEB_DIR"
for cmd in systemctl rsync python3 hostname openssl; do ok "Исходники web найдены: $WEB_DIR"
command -v "$cmd" >/dev/null 2>&1 || fail "Не найдена команда ${cmd}" [[ -f "$WEB_DIR/app.py" ]] || fail "Не найден web/app.py"
done ok "FastAPI entrypoint найден: web/app.py"
ok "Исходники: ${WEB_DIR}" [[ -f "$WEB_DIR/requirements.txt" ]] || fail "Не найден web/requirements.txt"
if id "$AUTH_USER" >/dev/null 2>&1; then ok "requirements.txt найден"
ok "Пользователь для входа: ${AUTH_USER}"
else
fail "Пользователь ${AUTH_USER} не найден. Создайте его или передайте VIRTUALITY_AUTH_USER=имя"
fi
if getent shadow "$AUTH_USER" | cut -d: -f2 | grep -Eq '^(!|\*|!!)?$'; then
warn "У пользователя ${AUTH_USER} нет пароля — задайте его: sudo passwd ${AUTH_USER}"
fi
step "Определяем профиль сервера" step "Автоматически определяем профиль сборки"
if [[ -x "${REPO_DIR}/scripts/detect_host_profile.sh" ]]; then if [[ -x "${REPO_DIR}/scripts/detect_host_profile.sh" ]]; then
PROFILE="$("${REPO_DIR}/scripts/detect_host_profile.sh" 2>>"$LOG_FILE" | tail -n1 || true)" PROFILE="$(${REPO_DIR}/scripts/detect_host_profile.sh 2>>"$LOG_FILE" | tail -n1)"
else else
bash "${REPO_DIR}/scripts/detect_host_profile.sh" >>"$LOG_FILE" 2>&1 || true bash "${REPO_DIR}/scripts/detect_host_profile.sh" >>"$LOG_FILE" 2>&1 || true
PROFILE="$(json_value profile)" PROFILE="$(json_value profile)"
fi fi
ok "Профиль: $(json_value label)${PROFILE:+ (${PROFILE})}" [[ -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"
step "Системные пакеты" step "Проверяем Linux-пользователя для входа"
if [[ "$PANEL_ONLY" == "1" ]]; then if id "$AUTH_USER" >/dev/null 2>&1; then
ok "Пропущено (VIRTUALITY_PANEL_ONLY=1): пакеты поставит установщик ноды" ok "Пользователь найден: $AUTH_USER"
elif command -v apt-get >/dev/null 2>&1; then
run_logged "apt update выполнен" apt-get update
run_logged "Пакеты панели установлены" apt-get install -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold python3 python3-venv rsync openssl curl nftables novnc python3-websockify qemu-utils virtinst libvirt-clients cloud-image-utils
else else
warn "apt не найден — пакеты не устанавливались" 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"
fi fi
step "Копируем панель в ${APP_DIR}" step "Проверяем базовые команды"
run_logged "Новая версия скопирована" rsync -a --delete --exclude='__pycache__/' --exclude='.env' "$WEB_DIR/" "${APP_DIR}.new/" for cmd in apt systemctl rsync python3 hostname openssl; do
install -m 0644 "${REPO_DIR}/VERSION" "${APP_DIR}.new/VERSION" if command -v "$cmd" >/dev/null 2>&1; then
FRESH_INSTALL=1 ok "$cmd найден: $(command -v "$cmd")"
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 "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 else
# Ubuntu Server without python3-venv: pip is bootstrapped from the bundled wheel. fail "$cmd не найден"
run_logged "Virtualenv создан (без pip)" python3 -m venv --without-pip "$VENV_DIR" fi
done
step "Обновляем apt cache"
run_logged "apt update выполнен" apt update
step "Устанавливаем системные зависимости под выбранную сборку"
COMMON_PACKAGES=(python3 python3-venv python3-pip rsync openssl curl wget unzip nftables novnc python3-websockify)
X86_PACKAGES=(qemu-system-x86 qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils)
ARM_PACKAGES=(qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils)
PACKAGES=("${COMMON_PACKAGES[@]}")
case "$PACKAGE_PROFILE" in
raspberry|orangepi5|arm64)
PACKAGES+=("${ARM_PACKAGES[@]}")
;;
*)
PACKAGES+=("${X86_PACKAGES[@]}")
;;
esac
run_logged "Системные пакеты установлены для профиля ${PACKAGE_PROFILE:-x86}" apt install -y "${PACKAGES[@]}"
if [[ "${PACKAGE_PROFILE:-x86}" != "raspberry" && "${PACKAGE_PROFILE:-x86}" != "orangepi5" && "${PACKAGE_PROFILE:-x86}" != "arm64" ]]; then
if command -v qemu-system-aarch64 >/dev/null 2>&1; then
ok "ARM64 QEMU эмулятор найден: $(command -v qemu-system-aarch64)"
else
fail "Не найден qemu-system-aarch64. Установи пакет: sudo apt install -y qemu-system-arm qemu-efi-aarch64"
fi fi
fi fi
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 "Копируем web-панель в /opt/virtuality"
touch "$NODE_CONFIG_FILE" run_logged "Создана директория /opt/virtuality" mkdir -p /opt/virtuality
grep -q '^#' "$NODE_CONFIG_FILE" || sed -i '1i # Настройки ноды Virtuality. Менять: sudo virtuality-ctl set KEY=VALUE' "$NODE_CONFIG_FILE" run_logged "Файлы панели синхронизированы в $APP_DIR" rsync -a --delete "$WEB_DIR/" "$APP_DIR/"
env_set VIRTUALITY_WEB_PORT "$PORT" if [[ -f "${REPO_DIR}/scripts/patch_web_console.py" ]]; then
env_set VIRTUALITY_AUTH_USER "$AUTH_USER" run_logged "noVNC web-console patch применён" python3 "${REPO_DIR}/scripts/patch_web_console.py" "${APP_DIR}/app.py"
env_set VIRTUALITY_AUTO_UPDATE "$AUTO_UPDATE" else
env_set VIRTUALITY_SOURCE_DIR "$REPO_DIR" warn "patch_web_console.py не найден, noVNC console patch пропущен"
[[ -n "$(saved_setting VIRTUALITY_WEB_HOST)" ]] || env_set VIRTUALITY_WEB_HOST 0.0.0.0 fi
[[ -n "$(saved_setting VIRTUALITY_TLS)" ]] || env_set VIRTUALITY_TLS 0 if [[ -f "${REPO_DIR}/scripts/patch_upload_compat.py" ]]; then
[[ -n "$(saved_setting VIRTUALITY_UPDATE_CHANNEL)" ]] || env_set VIRTUALITY_UPDATE_CHANNEL stable run_logged "upload compatibility patch применён" python3 "${REPO_DIR}/scripts/patch_upload_compat.py" "${APP_DIR}/app.py"
chmod 644 "$NODE_CONFIG_FILE" else
ok "Сохранено в ${NODE_CONFIG_FILE}" warn "patch_upload_compat.py не найден, совместимость загрузки файлов пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_upload_navigation_guard.py" ]]; then
run_logged "upload navigation guard patch применён" python3 "${REPO_DIR}/scripts/patch_upload_navigation_guard.py" "${APP_DIR}/app.py"
else
warn "patch_upload_navigation_guard.py не найден, защита загрузок от переходов пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_disk_images.py" ]]; then
run_logged "disk images patch применён" python3 "${REPO_DIR}/scripts/patch_disk_images.py" "${APP_DIR}/app.py"
else
warn "patch_disk_images.py не найден, менеджер дисковых образов пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_vm_boot_order.py" ]]; then
run_logged "VM boot order patch применён" python3 "${REPO_DIR}/scripts/patch_vm_boot_order.py" "${APP_DIR}/app.py"
else
warn "patch_vm_boot_order.py не найден, порядок загрузки VM пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_boot_order.py" ]]; then
run_logged "existing VM boot order patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_boot_order.py" "${APP_DIR}/app.py"
else
warn "patch_existing_vm_boot_order.py не найден, порядок загрузки существующих VM пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_resources.py" ]]; then
run_logged "existing VM resources patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_resources.py" "${APP_DIR}/app.py"
else
warn "patch_existing_vm_resources.py не найден, ресурсы существующих VM пропущены"
fi
if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_iso_mount.py" ]]; then
run_logged "existing VM ISO mount patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_iso_mount.py" "${APP_DIR}/app.py"
else
warn "patch_existing_vm_iso_mount.py не найден, монтирование ISO в VM пропущено"
fi
if [[ -f "${REPO_DIR}/scripts/patch_vm_detail_resource_layout.py" ]]; then
run_logged "VM detail resource layout patch применён" python3 "${REPO_DIR}/scripts/patch_vm_detail_resource_layout.py" "${APP_DIR}/app.py"
else
warn "patch_vm_detail_resource_layout.py не найден, раскладка ресурсов VM пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_remove_legacy_boot_order_card.py" ]]; then
run_logged "legacy boot order cleanup patch применён" python3 "${REPO_DIR}/scripts/patch_remove_legacy_boot_order_card.py" "${APP_DIR}/app.py"
else
warn "patch_remove_legacy_boot_order_card.py не найден, удаление старого блока порядка загрузки пропущено"
fi
run_logged "Канонические шаблоны панели восстановлены после VM-патчей" restore_canonical_templates
if [[ -f "${REPO_DIR}/scripts/patch_disk_archives.py" ]]; then
run_logged "disk archive import patch применён" python3 "${REPO_DIR}/scripts/patch_disk_archives.py" "${APP_DIR}/app.py"
else
warn "patch_disk_archives.py не найден, импорт архивов дисков пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_dhcp_leases_empty.py" ]]; then
run_logged "DHCP leases empty-state patch применён" python3 "${REPO_DIR}/scripts/patch_dhcp_leases_empty.py" "${APP_DIR}/app.py"
else
warn "patch_dhcp_leases_empty.py не найден, диагностика DHCP leases пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_network_diagnostics.py" ]]; then
run_logged "network diagnostics patch применён" python3 "${REPO_DIR}/scripts/patch_network_diagnostics.py" "${APP_DIR}/app.py"
else
warn "patch_network_diagnostics.py не найден, диагностика сети пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_network_ranges.py" ]]; then
run_logged "network port ranges patch применён" python3 "${REPO_DIR}/scripts/patch_network_ranges.py" "${APP_DIR}/app.py"
else
warn "patch_network_ranges.py не найден, поддержка диапазонов портов пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_network_bridge_forwards.py" ]]; then
run_logged "network bridge forward patch применён" python3 "${REPO_DIR}/scripts/patch_network_bridge_forwards.py" "${APP_DIR}/app.py"
else
warn "patch_network_bridge_forwards.py не найден, проброс bridge/static VM пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_network_nat_errors.py" ]]; then
run_logged "network NAT error patch применён" python3 "${REPO_DIR}/scripts/patch_network_nat_errors.py" "${APP_DIR}/app.py"
else
warn "patch_network_nat_errors.py не найден, безопасные ошибки NAT пропущены"
fi
if [[ -f "${REPO_DIR}/scripts/patch_logs_center.py" ]]; then
run_logged "logs center patch применён" python3 "${REPO_DIR}/scripts/patch_logs_center.py" "${APP_DIR}/app.py"
else
warn "patch_logs_center.py не найден, центр журналов пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_vm_network_guard.py" ]]; then
run_logged "VM network guard patch применён" python3 "${REPO_DIR}/scripts/patch_vm_network_guard.py" "${APP_DIR}/app.py"
else
warn "patch_vm_network_guard.py не найден, защита от отсутствующего bridge пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_update_center.py" ]]; then
run_logged "update center patch применён" python3 "${REPO_DIR}/scripts/patch_update_center.py" "${APP_DIR}/app.py"
else
warn "patch_update_center.py не найден, центр обновлений пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_live_status.py" ]]; then
run_logged "live status patch применён" python3 "${REPO_DIR}/scripts/patch_live_status.py" "${APP_DIR}/app.py"
else
warn "patch_live_status.py не найден, live-статусы пропущены"
fi
if [[ -f "${REPO_DIR}/scripts/patch_vm_autostart.py" ]]; then
run_logged "VM autostart patch применён" python3 "${REPO_DIR}/scripts/patch_vm_autostart.py" "${APP_DIR}/app.py"
else
warn "patch_vm_autostart.py не найден, нормализация автозапуска VM пропущена"
fi
run_logged "Канонические шаблоны панели финально восстановлены" restore_canonical_templates
run_logged "Конфиг профиля доступен web-панели" mkdir -p "$PROFILE_DIR"
if [[ -f "$PROFILE_FILE" ]]; then
ok "Профиль уже сохранён: $PROFILE_FILE"
else
warn "Профиль не найден после детекта, будет создан web-панелью при старте"
fi
step "Создаём конфиг авторизации"
if [[ -s "$SESSION_SECRET_FILE" ]]; then if [[ -s "$SESSION_SECRET_FILE" ]]; then
SESSION_SECRET="$(cat "$SESSION_SECRET_FILE")" SESSION_SECRET="$(cat "$SESSION_SECRET_FILE")"
ok "Используем существующий session secret: $SESSION_SECRET_FILE"
else else
SESSION_SECRET="$(openssl rand -hex 32)" SESSION_SECRET="$(openssl rand -hex 32)"
printf '%s\n' "$SESSION_SECRET" > "$SESSION_SECRET_FILE" printf '%s\n' "$SESSION_SECRET" > "$SESSION_SECRET_FILE"
chmod 600 "$SESSION_SECRET_FILE" chmod 600 "$SESSION_SECRET_FILE"
ok "Создан ключ сессий: ${SESSION_SECRET_FILE}" ok "Создан постоянный session secret: $SESSION_SECRET_FILE"
fi fi
cat > "${APP_DIR}/.env" <<EOF cat > "${APP_DIR}/.env" <<EOF
VIRTUALITY_AUTH_USER=${AUTH_USER} VIRTUALITY_AUTH_USER=${AUTH_USER}
VIRTUALITY_SESSION_SECRET=${SESSION_SECRET} VIRTUALITY_SESSION_SECRET=${SESSION_SECRET}
VIRTUALITY_COOKIE_SECURE=$(saved_setting VIRTUALITY_COOKIE_SECURE)
TMPDIR=${UPLOAD_TMP_DIR} TMPDIR=${UPLOAD_TMP_DIR}
TEMP=${UPLOAD_TMP_DIR} TEMP=${UPLOAD_TMP_DIR}
TMP=${UPLOAD_TMP_DIR} TMP=${UPLOAD_TMP_DIR}
EOF EOF
chmod 600 "${APP_DIR}/.env" chmod 600 "${APP_DIR}/.env"
ok "Создан ${APP_DIR}/.env"
ok "Временный каталог загрузок: ${UPLOAD_TMP_DIR}"
ok "Вход будет по Linux-пользователю: ${AUTH_USER}"
step "Устанавливаем virtuality-ctl" step "Создаём Python virtualenv"
install -m 0755 "$CTL_SRC" "$CTL_DST" if [[ -d "$VENV_DIR" ]]; then
ok "${CTL_DST}" warn "Virtualenv уже существует, будет переиспользован: $VENV_DIR"
else
run_logged "Virtualenv создан: $VENV_DIR" python3 -m venv "$VENV_DIR"
fi
step "Службы, firewall и запуск" step "Устанавливаем Python-зависимости"
run_logged "Юниты созданы, панель запущена" env VIRTUALITY_SOURCE_DIR="$REPO_DIR" "$CTL_DST" reconfigure run_logged "pip обновлён" "$VENV_DIR/bin/pip" install --upgrade pip
run_logged "Python-зависимости установлены" "$VENV_DIR/bin/pip" install -r "$APP_DIR/requirements.txt"
step "Создаём systemd service"
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=Virtuality Web Panel
After=network-online.target libvirtd.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=${APP_DIR}
ExecStart=${VENV_DIR}/bin/uvicorn app:app --host 0.0.0.0 --port ${PORT}
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
step "Запускаем Virtuality Web Panel"
run_logged "virtuality-web.service включён и запущен" systemctl enable --now virtuality-web.service
sleep 2 sleep 2
if systemctl is-active --quiet virtuality-web.service; then STATE="$(service_state virtuality-web.service)"
ok "virtuality-web.service работает" if [[ "$STATE" == "active" ]]; then
ok "virtuality-web.service active"
else else
warn "virtuality-web.service состояние: $STATE"
journalctl -u virtuality-web.service -n 40 --no-pager >> "$LOG_FILE" 2>&1 || true journalctl -u virtuality-web.service -n 40 --no-pager >> "$LOG_FILE" 2>&1 || true
fail "virtuality-web.service не запустился"
fi fi
step "Мастер настройки" step "Настраиваем firewall и проверяем порт"
if [[ -f "$SETUP_STATE_FILE" ]]; then if command -v ufw >/dev/null 2>&1; then
ok "Состояние установки уже ведётся" ufw allow "${PORT}/tcp" >> "$LOG_FILE" 2>&1 || warn "Не удалось добавить UFW правило для ${PORT}/tcp"
elif [[ "${VIRTUALITY_FIRSTBOOT:-0}" == "1" ]]; then ok "UFW правило добавлено/проверено: ${PORT}/tcp"
ok "Прогресс пишет первая загрузка"
elif [[ "$FRESH_INSTALL" != "1" ]]; then
ok "Обновление уже настроенного сервера — мастер не нужен (его можно запустить в Настройках)"
else else
# A node installed by hand gets the same first-run wizard as one installed from the image. warn "ufw не установлен, firewall-правило пропущено"
printf '{"stage": "done", "message": "", "steps": []}\n' > "$SETUP_STATE_FILE" fi
ok "Мастер настройки откроется при первом входе в панель" if command -v ss >/dev/null 2>&1 && ss -tulpn | grep -q ":${PORT}"; then
ok "Порт ${PORT} слушается"
else
warn "Порт ${PORT} пока не найден в ss; проверь service logs"
fi fi
PANEL_URL="$("$CTL_DST" url 2>/dev/null || echo "http://$(hostname -I | awk '{print $1}'):${PORT}")" SERVER_IP="$(hostname -I | awk '{print $1}')"
echo
echo -e "${GREEN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Установка Virtuality Web Panel завершена${RESET} ${GREEN}${BOLD}│${RESET}"
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
echo
echo -e "${BOLD}URL:${RESET} http://${SERVER_IP}:${PORT}"
echo -e "${BOLD}Login:${RESET} ${AUTH_USER} / пароль Linux-пользователя"
echo -e "${BOLD}Profile:${RESET} ${LABEL:-$PROFILE}"
echo -e "${BOLD}Arch:${RESET} ${ARCH:-unknown}"
echo -e "${BOLD}Service:${RESET} virtuality-web.service"
echo -e "${BOLD}Auto update:${RESET} 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}"
echo echo
echo -e "${GREEN}${BOLD}Панель управления установлена${RESET}"
line line
echo -e "${BOLD}Адрес:${RESET} ${PANEL_URL}" echo -e "${DIM}Следующий шаг: /host, /network, /logs, /update, /vm/create, /vm/NAME/console.${RESET}"
echo -e "${BOLD}Логин:${RESET} ${AUTH_USER} (пароль пользователя Linux)"
echo -e "${BOLD}Служба:${RESET} systemctl status virtuality-web --no-pager"
echo -e "${BOLD}Журнал:${RESET} journalctl -u virtuality-web -f"
echo -e "${BOLD}Настройки:${RESET} sudo virtuality-ctl status"
line line
echo -e "${DIM}Автообновление: $([[ "$AUTO_UPDATE" == "1" ]] && echo "включено (ночью)" || echo "отключено")${RESET}"
+210
Просмотреть файл
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helpers = r'''
def is_x86_host(profile: dict[str, Any]) -> bool:
return str(profile.get("arch", "")) in ("x86_64", "amd64")
def is_arm64_guest_on_x86(guest_arch: str, profile: dict[str, Any]) -> bool:
return guest_arch == "aarch64" and is_x86_host(profile)
def available_memory_mib() -> int:
try:
for line in Path('/proc/meminfo').read_text().splitlines():
if line.startswith('MemAvailable:'):
return int(line.split()[1]) // 1024
except Exception:
return 0
return 0
def arm64_emulation_memory_error(memory: int) -> str | None:
available = available_memory_mib()
requested = int(memory)
reserve = 768
if available <= 0:
return None
if requested + reserve <= available:
return None
recommended = max(512, available - reserve)
return (
f"Недостаточно RAM для ARM64-эмуляции: запрошено {requested} MB, "
f"доступно около {available} MB, нужен запас минимум {reserve} MB. "
f"Уменьши RAM VM примерно до {recommended} MB или освободи память на хосте."
)
def xml_escape(value: str) -> str:
return str(value).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def arm64_emulation_xml(name: str, memory: int, vcpus: int, disk_path: Path, network_mode: str, bridge: str) -> str:
if network_mode == "nat":
interface_xml = f"""
<interface type='network'>
<source network='{xml_escape(network_core.NETWORK_NAME)}'/>
<model type='virtio'/>
</interface>"""
else:
interface_xml = f"""
<interface type='bridge'>
<source bridge='{xml_escape(bridge)}'/>
<model type='virtio'/>
</interface>"""
return f"""<domain type='qemu'>
<name>{xml_escape(name)}</name>
<memory unit='MiB'>{int(memory)}</memory>
<currentMemory unit='MiB'>{int(memory)}</currentMemory>
<vcpu placement='static'>{int(vcpus)}</vcpu>
<os>
<type arch='aarch64' machine='virt'>hvm</type>
</os>
<cpu mode='custom' match='exact'>
<model fallback='allow'>cortex-a57</model>
</cpu>
<features>
<gic version='3'/>
</features>
<clock offset='utc'/>
<on_poweroff>destroy</on_poweroff>
<on_reboot>restart</on_reboot>
<on_crash>restart</on_crash>
<devices>
<emulator>/usr/bin/qemu-system-aarch64</emulator>
<disk type='file' device='disk'>
<driver name='qemu' type='qcow2'/>
<source file='{xml_escape(str(disk_path))}'/>
<target dev='vda' bus='virtio'/>
</disk>{interface_xml}
<graphics type='vnc' port='-1' autoport='yes' listen='0.0.0.0'>
<listen type='address' address='0.0.0.0'/>
</graphics>
<video>
<model type='virtio'/>
</video>
<console type='pty'>
<target type='serial' port='0'/>
</console>
</devices>
</domain>
"""
def make_arm64_emulation_script(name: str, memory: int, vcpus: int, disk_path: Path, network_mode: str, bridge: str) -> str:
xml_path = Path('/tmp') / f"virtuality-{name}-arm64.xml"
xml_path.write_text(arm64_emulation_xml(name, memory, vcpus, disk_path, network_mode, bridge))
return f"virsh define {xml_path} && virsh start {name}"
'''
if 'def arm64_emulation_xml(' not in text:
marker = '\n\ndef valid_vm_name(name: str) -> bool:'
if marker not in text:
raise SystemExit('valid_vm_name marker not found')
text = text.replace(marker, helpers + marker, 1)
changed.append('ARM64 emulation XML helpers added')
else:
if 'def arm64_emulation_memory_error(' not in text:
marker = '\n\ndef xml_escape(value: str) -> str:'
if marker not in text:
raise SystemExit('xml_escape marker not found')
memory_helpers = r'''
def available_memory_mib() -> int:
try:
for line in Path('/proc/meminfo').read_text().splitlines():
if line.startswith('MemAvailable:'):
return int(line.split()[1]) // 1024
except Exception:
return 0
return 0
def arm64_emulation_memory_error(memory: int) -> str | None:
available = available_memory_mib()
requested = int(memory)
reserve = 768
if available <= 0:
return None
if requested + reserve <= available:
return None
recommended = max(512, available - reserve)
return (
f"Недостаточно RAM для ARM64-эмуляции: запрошено {requested} MB, "
f"доступно около {available} MB, нужен запас минимум {reserve} MB. "
f"Уменьши RAM VM примерно до {recommended} MB или освободи память на хосте."
)
'''
text = text.replace(marker, memory_helpers + marker, 1)
changed.append('ARM64 memory guard helpers added')
else:
changed.append('ARM64 memory guard helpers already present')
changed.append('ARM64 emulation XML helpers already present')
old_disk_cmd = ''' if source_type == "disk_image":
source_disk = Path(disk_image_path).resolve()
source_format = disk_image_format(source_disk)
convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}"
virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"])
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"]
else:
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
'''
new_disk_cmd = ''' if source_type == "disk_image":
source_disk = Path(disk_image_path).resolve()
source_format = disk_image_format(source_disk)
convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}"
if is_arm64_guest_on_x86(resolved_guest_arch, profile):
memory_error = arm64_emulation_memory_error(memory)
if memory_error:
return vm_form_context(request, error=memory_error, form=form, status_code=400)
create_cmd = make_arm64_emulation_script(name, memory, vcpus, disk_path, network_mode, bridge)
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {create_cmd}"]
else:
virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"])
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"]
else:
if is_arm64_guest_on_x86(resolved_guest_arch, profile):
return vm_form_context(request, error="ARM64 ISO на x86-хосте пока не поддерживается. Загрузи готовый ARM64 .img/.qcow2 в разделе «Диски» и создай VM из готового диска.", form=form, status_code=400)
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
'''
old_arm64_xml_cmd = ''' if is_arm64_guest_on_x86(resolved_guest_arch, profile):
create_cmd = make_arm64_emulation_script(name, memory, vcpus, disk_path, network_mode, bridge)
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {create_cmd}"]
else:
'''
new_arm64_xml_cmd = ''' if is_arm64_guest_on_x86(resolved_guest_arch, profile):
memory_error = arm64_emulation_memory_error(memory)
if memory_error:
return vm_form_context(request, error=memory_error, form=form, status_code=400)
create_cmd = make_arm64_emulation_script(name, memory, vcpus, disk_path, network_mode, bridge)
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {create_cmd}"]
else:
'''
if old_disk_cmd in text:
text = text.replace(old_disk_cmd, new_disk_cmd, 1)
changed.append('ARM64 disk image on x86 uses direct libvirt XML with memory guard')
elif old_arm64_xml_cmd in text:
text = text.replace(old_arm64_xml_cmd, new_arm64_xml_cmd, 1)
changed.append('ARM64 memory guard added to existing XML path')
elif 'memory_error = arm64_emulation_memory_error(memory)' in text:
changed.append('ARM64 memory guard already present')
elif 'make_arm64_emulation_script' in text:
changed.append('ARM64 disk image XML path already present')
else:
raise SystemExit('disk image create command marker not found')
app_path.write_text(text)
print('arm64 emulation xml patch applied:')
for item in changed:
print(f'- {item}')
+271
Просмотреть файл
@@ -0,0 +1,271 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
changed: list[str] = []
warnings: list[str] = []
if not app_path.exists():
print(f'WARN: app.py not found: {app_path}')
raise SystemExit(0)
templates_dir = app_path.parent / 'templates'
dashboard_template_path = templates_dir / 'dashboard.html'
network_template_path = templates_dir / 'network.html'
if not dashboard_template_path.exists():
print(f'WARN: dashboard.html not found: {dashboard_template_path}')
raise SystemExit(0)
text = app_path.read_text(encoding='utf-8')
text, removed_helpers = re.subn(
r"\n\ndef parse_dhcp_leases_output\(output: str\).*?\n\ndef parse_virsh_list\(\) -> list\[dict\[str, str\]\]:",
"\n\ndef parse_virsh_list() -> list[dict[str, str]]:",
text,
count=1,
flags=re.S,
)
if removed_helpers:
changed.append('old VM IP helper block was removed')
new_parse_virsh_list = '''def parse_virsh_list() -> list[dict[str, str]]:
def clean_ip(ip: str) -> str:
ip = (ip or "").split("/")[0].strip()
if not ip or ip.startswith("127.") or ip.startswith("169.254.") or ip == "0.0.0.0":
return ""
return ip
def manual_ip_map() -> dict[str, str]:
path = Path("/var/lib/virtuality/network/vm_ips.json")
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict):
return {str(k): clean_ip(str(v)) for k, v in data.items() if clean_ip(str(v))}
except Exception:
pass
return {}
def vm_macs(name: str) -> list[str]:
try:
result = run_cmd(["virsh", "domiflist", name], timeout=8)
if not result.get("ok"):
return []
return [mac.lower() for mac in re.findall(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", result.get("stdout", ""))]
except Exception:
return []
def ip_from_domifaddr(name: str) -> str:
try:
result = run_cmd(["virsh", "domifaddr", name], timeout=8)
if result.get("ok"):
for ip in re.findall(r"\\b(\\d{1,3}(?:\\.\\d{1,3}){3})/\\d+", result.get("stdout", "")):
ip = clean_ip(ip)
if ip:
return ip
except Exception:
pass
return ""
def ip_from_network_core(name: str) -> str:
try:
resolved = network_core.resolve_vm_ip(name)
if resolved:
return clean_ip(str(resolved))
except Exception:
pass
return ""
def ip_from_dnsmasq_leases(macs: list[str]) -> str:
if not macs:
return ""
try:
for lease_file in Path("/var/lib/libvirt/dnsmasq").glob("*.leases"):
for line in lease_file.read_text(errors="ignore").splitlines():
low = line.lower()
if not any(mac in low for mac in macs):
continue
parts = line.split()
if len(parts) >= 3:
ip = clean_ip(parts[2])
if ip:
return ip
except Exception:
pass
return ""
def ip_from_neighbor_tables(macs: list[str]) -> str:
if not macs:
return ""
commands = [["ip", "neigh", "show"], ["ip", "neigh", "show", "dev", "virbr100"], ["ip", "neigh", "show", "dev", "br0"], ["arp", "-an"]]
for cmd in commands:
try:
result = run_cmd(cmd, timeout=8)
if not result.get("ok"):
continue
for line in result.get("stdout", "").splitlines():
low = line.lower()
if not any(mac in low for mac in macs):
continue
for value in re.findall(r"\\b(\\d{1,3}(?:\\.\\d{1,3}){3})\\b", line):
ip = clean_ip(value)
if ip:
return ip
except Exception:
pass
return ""
manual_ips = manual_ip_map()
def resolve_ip(name: str) -> str:
if not name:
return "—"
if manual_ips.get(name):
return manual_ips[name]
macs = vm_macs(name)
for resolver in (lambda: ip_from_domifaddr(name), lambda: ip_from_network_core(name), lambda: ip_from_dnsmasq_leases(macs), lambda: ip_from_neighbor_tables(macs)):
try:
ip = resolver()
if ip:
return ip
except Exception:
pass
return "—"
result = run_cmd(["virsh", "list", "--all"])
rows = []
if not result["ok"]:
return rows
for line in result["stdout"].splitlines()[2:]:
parts = line.strip().split(None, 2)
if len(parts) == 3:
rows.append({"id": parts[0], "name": parts[1], "state": parts[2]})
elif len(parts) == 2:
rows.append({"id": "-", "name": parts[0], "state": parts[1]})
for row in rows:
row["ip"] = resolve_ip(row.get("name", ""))
row["manual_ip"] = manual_ips.get(row.get("name", ""), "")
return rows
'''
pattern = r"def parse_virsh_list\(\) -> list\[dict\[str, str\]\]:.*?\n\ndef parse_pool_list\(\) -> list\[dict\[str, str\]\]:"
replacement = new_parse_virsh_list + "\n\ndef parse_pool_list() -> list[dict[str, str]]:"
text, replaced = re.subn(pattern, lambda _match: replacement, text, count=1, flags=re.S)
if replaced:
changed.append('parse_virsh_list was replaced with manual-first VM IP resolver')
else:
warnings.append('parse_virsh_list block not found, app.py IP injection skipped')
manual_route = '''
@app.post("/network/vm-ip/save")
def network_vm_ip_save(request: Request, vm_name: str = Form(...), manual_ip: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
if not valid_vm_name(vm_name):
return RedirectResponse(url="/network", status_code=303)
manual_ip = (manual_ip or "").strip()
if manual_ip and not re.fullmatch(r"(25[0-5]|2[0-4]\\d|1?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|1?\\d?\\d)){3}", manual_ip):
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": "Некорректный ручной IP VM"}, status_code=400)
path = Path("/var/lib/virtuality/network/vm_ips.json")
path.parent.mkdir(parents=True, exist_ok=True)
try:
data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
if not isinstance(data, dict):
data = {}
except Exception:
data = {}
if manual_ip:
data[vm_name] = manual_ip
else:
data.pop(vm_name, None)
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
return RedirectResponse(url="/network", status_code=303)
'''
if '/network/vm-ip/save' not in text:
marker = '\n\n@app.post("/network/nat/setup")'
if marker in text:
text = text.replace(marker, manual_route + marker, 1)
changed.append('manual VM IP route was added')
else:
warnings.append('network NAT route marker not found, manual IP route skipped')
else:
changed.append('manual VM IP route was already present')
app_path.write_text(text, encoding='utf-8')
dashboard_html = dashboard_template_path.read_text(encoding='utf-8')
header_old = '<tr><th>ID</th><th>Name</th><th>State</th><th>Actions</th></tr>'
header_new = '<tr><th>ID</th><th>Name</th><th>IP</th><th>State</th><th>Actions</th></tr>'
name_cell = '''<td class="strong"><a class="table-link" href="/vm/{{ vm.name }}">{{ vm.name }}</a></td>
<td><span'''
name_ip_cell = '''<td class="strong"><a class="table-link" href="/vm/{{ vm.name }}">{{ vm.name }}</a></td>
<td class="strong">{{ vm.ip|default("—") }}</td>
<td><span'''
if '<th>IP</th>' not in dashboard_html:
dashboard_html = dashboard_html.replace(header_old, header_new).replace(name_cell, name_ip_cell).replace('colspan="4" class="muted">Виртуальных машин пока нет', 'colspan="5" class="muted">Виртуальных машин пока нет')
changed.append('Dashboard VM IP column was added')
elif 'vm.ip' not in dashboard_html:
dashboard_html = dashboard_html.replace(name_cell, name_ip_cell)
changed.append('Dashboard VM IP cell was added')
else:
changed.append('Dashboard VM IP column was already present')
dashboard_template_path.write_text(dashboard_html, encoding='utf-8')
if network_template_path.exists():
network_html = network_template_path.read_text(encoding='utf-8')
manual_card = '''
<article class="card">
<div class="card-head">
<h2>Ручные IP VM</h2>
<span class="pill">override</span>
</div>
<div class="notice">Если VM в bridge/static-сети и IP не виден через DHCP/ARP, укажи адрес вручную. Этот IP будет первым источником для таблицы и проброса портов.</div>
<table class="top-space">
<thead><tr><th>VM</th><th>Текущий IP</th><th>Ручной IP</th><th>Действие</th></tr></thead>
<tbody>
{% for vm in vms %}
<tr>
<td class="strong">{{ vm.name }}</td>
<td>{{ vm.ip|default("—") }}</td>
<td>
<form method="post" action="/network/vm-ip/save" class="inline-form">
<input type="hidden" name="vm_name" value="{{ vm.name }}">
<input type="text" name="manual_ip" value="{{ vm.manual_ip|default("") }}" placeholder="например 10.0.0.50" pattern="(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])){3}">
</td>
<td><button>Сохранить</button></form></td>
</tr>
{% else %}
<tr><td colspan="4" class="muted">VM пока нет</td></tr>
{% endfor %}
</tbody>
</table>
</article>
'''
if 'Ручные IP VM' not in network_html:
marker = ' <article class="card">\n <div class="card-head">\n <h2>Диагностика публичного доступа</h2>'
if marker in network_html:
network_html = network_html.replace(marker, manual_card + '\n' + marker, 1)
changed.append('manual VM IP card was added to network page')
else:
warnings.append('network diagnostics card marker not found, manual IP card skipped')
else:
changed.append('manual VM IP card was already present')
network_template_path.write_text(network_html, encoding='utf-8')
else:
warnings.append('network.html not found, manual IP card skipped')
print('DHCP leases empty-state patch completed:')
for item in changed:
print(f'- {item}')
for item in warnings:
print(f'WARN: {item}')
raise SystemExit(0)
+316
Просмотреть файл
@@ -0,0 +1,316 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
if 'import zipfile' not in text:
text = text.replace('import uuid\n', 'import uuid\nimport lzma\nimport tarfile\nimport zipfile\n', 1)
changed.append('archive imports added')
elif 'import lzma' not in text:
text = text.replace('import tarfile\n', 'import lzma\nimport tarfile\n', 1) if 'import tarfile\n' in text else text.replace('import zipfile\n', 'import lzma\nimport zipfile\n', 1)
changed.append('lzma import added')
else:
changed.append('archive imports already present')
helpers = r'''
def safe_disk_upload_filename(filename: str) -> str | None:
original = Path(filename or "").name.strip()
lower = original.lower()
if lower.endswith(".tar.gz"):
suffix = ".tar.gz"
stem = original[:-7]
elif lower.endswith(".img.xz"):
suffix = ".img.xz"
stem = original[:-7]
elif lower.endswith(".tgz"):
suffix = ".tgz"
stem = original[:-4]
else:
suffix = Path(original).suffix.lower()
stem = Path(original).stem
if suffix not in (".img", ".raw", ".qcow2", ".img.xz", ".zip", ".tar.gz", ".tgz"):
return None
stem = re.sub(r"\s+", "-", stem.strip())
stem = re.sub(r"[^a-zA-Z0-9_.-]", "_", stem)
stem = stem.strip("._-")[:120]
if not stem:
stem = "virtuality-disk"
return f"{stem}{suffix}"
def disk_upload_is_archive(name: str) -> bool:
lower = str(name or "").lower()
return lower.endswith((".zip", ".tar.gz", ".tgz"))
def disk_upload_is_xz_image(name: str) -> bool:
return str(name or "").lower().endswith(".img.xz")
def disk_upload_is_image(name: str) -> bool:
lower = str(name or "").lower()
return lower.endswith((".img", ".raw", ".qcow2", ".img.xz"))
def archive_member_is_safe(name: str) -> bool:
if not name:
return False
p = Path(name)
if p.is_absolute():
return False
return ".." not in p.parts
def archive_member_basename(name: str) -> str | None:
return safe_disk_image_filename(Path(name).name)
def unique_disk_image_path(name: str) -> Path:
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
safe = safe_disk_image_filename(name)
if not safe:
raise ValueError("Некорректное имя образа диска")
target = DISK_IMAGES_DIR / safe
if not target.exists():
return target
stem = target.stem
suffix = target.suffix
for index in range(1, 1000):
candidate = DISK_IMAGES_DIR / f"{stem}-{index}{suffix}"
if not candidate.exists():
return candidate
raise ValueError("Не удалось подобрать свободное имя файла")
def find_archive_disk_members(archive_path: Path) -> list[dict[str, Any]]:
members: list[dict[str, Any]] = []
lower = archive_path.name.lower()
if lower.endswith(".zip"):
with zipfile.ZipFile(archive_path) as archive:
for info in archive.infolist():
if info.is_dir() or not archive_member_is_safe(info.filename):
continue
safe_name = archive_member_basename(info.filename)
if safe_name:
members.append({"kind": "zip", "name": info.filename, "safe_name": safe_name, "size": int(info.file_size)})
elif lower.endswith((".tar.gz", ".tgz")):
with tarfile.open(archive_path, "r:gz") as archive:
for info in archive.getmembers():
if not info.isfile() or not archive_member_is_safe(info.name):
continue
safe_name = archive_member_basename(info.name)
if safe_name:
members.append({"kind": "tar", "name": info.name, "safe_name": safe_name, "size": int(info.size)})
else:
raise ValueError("Поддерживаются только .zip, .tar.gz и .tgz")
return sorted(members, key=lambda item: item.get("size", 0), reverse=True)
def extract_disk_archive(archive_path: Path) -> list[Path]:
members = find_archive_disk_members(archive_path)
if not members:
raise ValueError("В архиве не найдено .img, .raw или .qcow2 файлов")
extracted: list[Path] = []
lower = archive_path.name.lower()
if lower.endswith(".zip"):
with zipfile.ZipFile(archive_path) as archive:
for member in members:
target = unique_disk_image_path(member["safe_name"])
with archive.open(member["name"], "r") as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
extracted.append(target)
else:
with tarfile.open(archive_path, "r:gz") as archive:
for member in members:
file_obj = archive.extractfile(member["name"])
if file_obj is None:
continue
target = unique_disk_image_path(member["safe_name"])
with file_obj as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
extracted.append(target)
return extracted
def extract_xz_disk_image(compressed_path: Path, safe_name: str | None = None) -> Path:
source_name = safe_name or compressed_path.name
if not source_name.lower().endswith(".img.xz"):
raise ValueError("Поддерживаются только сжатые образы .img.xz")
raw_name = source_name[:-3]
target = unique_disk_image_path(raw_name)
try:
with lzma.open(compressed_path, "rb") as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
except Exception:
target.unlink(missing_ok=True)
raise
return target
def disk_convert_target_path(source_path: Path) -> Path:
if source_path.suffix.lower() == ".qcow2":
return source_path
target = source_path.with_suffix(".qcow2")
if not target.exists():
return target
for index in range(1, 1000):
candidate = source_path.with_name(f"{source_path.stem}-{index}.qcow2")
if not candidate.exists():
return candidate
raise ValueError("Не удалось подобрать имя qcow2 для конвертации")
def disk_convert_progress(line: str, current: int) -> int:
match = re.search(r"\((\d+(?:\.\d+)?)/100%\)", line or "")
if match:
return max(current, int(float(match.group(1))))
match = re.search(r"(\d+(?:\.\d+)?)%", line or "")
if match:
return max(current, int(float(match.group(1))))
return current
def run_disk_convert_worker(operation_id: str, source_path: str, target_path: str) -> None:
operation = read_operation(operation_id)
if not operation:
return
source = Path(source_path)
target = Path(target_path)
update_operation(operation, status="running", progress=1, message=f"Конвертация {source.name} в qcow2", started_at=utc_now())
cmd = ["qemu-img", "convert", "-p", "-f", disk_image_format(source), "-O", "qcow2", str(source), str(target)]
append_operation_log(operation_id, "Запуск конвертации:")
append_operation_log(operation_id, " ".join(cmd))
try:
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
progress = 1
if process.stdout:
for line in process.stdout:
append_operation_log(operation_id, line)
progress = disk_convert_progress(line, progress)
fresh = read_operation(operation_id) or operation
update_operation(fresh, progress=progress, message=f"Конвертация {source.name}: {progress}%")
exit_code = process.wait()
fresh = read_operation(operation_id) or operation
if exit_code == 0:
append_operation_log(operation_id, f"Конвертация завершена: {target}")
update_operation(fresh, status="success", progress=100, exit_code=exit_code, message=f"Готово: {target.name}", finished_at=utc_now(), target_path=str(target))
else:
append_operation_log(operation_id, f"qemu-img завершился с ошибкой. Exit code: {exit_code}")
target.unlink(missing_ok=True)
update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=f"qemu-img завершился с ошибкой: {exit_code}", finished_at=utc_now())
except Exception as exc:
fresh = read_operation(operation_id) or operation
append_operation_log(operation_id, f"Ошибка конвертации: {exc}")
target.unlink(missing_ok=True)
update_operation(fresh, status="error", progress=100, exit_code=-1, message=str(exc), finished_at=utc_now())
def start_disk_convert_operation(source_path: Path) -> dict[str, Any] | None:
if source_path.suffix.lower() == ".qcow2":
return None
target_path = disk_convert_target_path(source_path)
operation_id = str(uuid.uuid4())
operation = {
"id": operation_id,
"type": "disk_convert",
"title": f"Конвертация {source_path.name}",
"status": "queued",
"progress": 0,
"message": "Конвертация поставлена в очередь",
"created_at": utc_now(),
"updated_at": utc_now(),
"created_by": AUTH_USER,
"source_path": str(source_path),
"target_path": str(target_path),
}
write_operation(operation)
append_operation_log(operation_id, "Операция поставлена в очередь.")
threading.Thread(target=run_disk_convert_worker, args=(operation_id, str(source_path), str(target_path)), daemon=True).start()
return operation
def disk_upload_response(request: Request, payload: dict[str, Any]):
if request.headers.get("x-requested-with") == "XMLHttpRequest" or "application/json" in request.headers.get("accept", ""):
return JSONResponse(payload)
return RedirectResponse(url="/disk-images", status_code=303)
'''
if 'def safe_disk_upload_filename(' not in text:
marker = '\n\ndef valid_vm_name(name: str) -> bool:'
if marker not in text:
marker = '\n\ndef bridge_exists(name: str) -> bool:'
if marker not in text:
raise SystemExit('helper insert marker not found')
text = text.replace(marker, helpers + marker, 1)
changed.append('disk archive helpers added')
else:
pattern = r"\n\ndef safe_disk_upload_filename\(filename: str\).*?\n\ndef disk_upload_response\(request: Request, payload: dict\[str, Any\]\):.*?\n return RedirectResponse\(url=\"/disk-images\", status_code=303\)\n"
text, count = re.subn(pattern, helpers, text, count=1, flags=re.S)
changed.append('disk archive helpers replaced with img.xz support' if count else 'disk archive helpers already present')
old_route_start = text.find('@app.post("/disk-images/upload"')
if old_route_start == -1:
raise SystemExit('disk image upload route not found')
next_route = text.find('\n\n@app.post("/disk-images/{name}/delete")', old_route_start)
if next_route == -1:
raise SystemExit('disk image delete route marker not found')
new_route = r'''@app.post("/disk-images/upload", response_class=HTMLResponse)
def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
safe_name = safe_disk_upload_filename(image_file.filename or "")
if not safe_name:
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw, .qcow2, .img.xz, .zip, .tar.gz или .tgz файлы."}, status_code=400)
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
saved_paths: list[Path] = []
try:
with tmp_target.open("wb") as out:
shutil.copyfileobj(image_file.file, out, length=1024 * 1024)
if disk_upload_is_archive(safe_name):
saved_paths = extract_disk_archive(tmp_target)
tmp_target.unlink(missing_ok=True)
elif disk_upload_is_xz_image(safe_name):
saved_paths = [extract_xz_disk_image(tmp_target, safe_name)]
tmp_target.unlink(missing_ok=True)
else:
target = unique_disk_image_path(safe_name)
tmp_target.rename(target)
saved_paths = [target]
except Exception as exc:
tmp_target.unlink(missing_ok=True)
for path in saved_paths:
path.unlink(missing_ok=True)
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки/распаковки образа: {exc}"}, status_code=500)
operations = []
for path in saved_paths:
operation = start_disk_convert_operation(path)
if operation:
operations.append(operation)
payload = {
"ok": True,
"mode": "converting" if operations else "ready",
"operation_id": operations[0]["id"] if operations else None,
"operation_ids": [op["id"] for op in operations],
"files": [path.name for path in saved_paths],
"message": f"Загружено файлов: {len(saved_paths)}. Конвертаций запущено: {len(operations)}.",
}
return disk_upload_response(request, payload)
'''
text = text[:old_route_start] + new_route + text[next_route:]
changed.append('disk image upload route supports img.xz and archives')
app_path.write_text(text)
print('disk archive import patch applied:')
for item in changed:
print(f'- {item}')
+171
Просмотреть файл
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
print(f'WARN: app.py not found: {app_path}')
raise SystemExit(0)
text = app_path.read_text()
changed = []
warnings = []
old_progress = '''def progress_from_line(current: int, line: str) -> int:
low = line.lower()
if "allocating" in low or "creating storage" in low:
return max(current, 30)
if "starting install" in low or "installing" in low:
return max(current, 50)
if "creating domain" in low:
return max(current, 75)
if "domain creation completed" in low or "installation continues" in low:
return max(current, 90)
return current
'''
new_progress = '''def progress_from_line(current: int, line: str) -> int:
low = line.lower()
convert_match = re.search(r"\\((\\d+(?:\\.\\d+)?)\\s*/\\s*100%\\)", line)
if convert_match:
return max(1, min(99, int(float(convert_match.group(1)))))
percent_match = re.search(r"(\\d+(?:\\.\\d+)?)%", line)
if "qemu-img" in low and percent_match:
return max(1, min(99, int(float(percent_match.group(1)))))
if "allocating" in low or "creating storage" in low:
return max(current, 30)
if "starting install" in low or "installing" in low:
return max(current, 50)
if "creating domain" in low:
return max(current, 75)
if "domain creation completed" in low or "installation continues" in low:
return max(current, 90)
return current
'''
if old_progress in text:
text = text.replace(old_progress, new_progress, 1)
changed.append('qemu-img percent parser added')
elif 'convert_match = re.search' in text:
changed.append('qemu-img percent parser already present')
else:
warnings.append('progress_from_line marker not found, progress parser skipped')
helpers = r'''
def qemu_img_format_for_path(path: Path) -> str:
suffix = path.suffix.lower()
if suffix == ".qcow2":
return "qcow2"
return "raw"
def qcow2_name_for_upload(safe_name: str) -> str:
base = Path(safe_name).stem
return f"{base}.qcow2"
def start_disk_image_convert_operation(source_path: Path, target_path: Path, source_format: str) -> dict[str, Any]:
operation_id = str(uuid.uuid4())
cmd = ["qemu-img", "convert", "-p", "-f", source_format, "-O", "qcow2", str(source_path), str(target_path)]
operation = {
"id": operation_id,
"type": "disk_image_convert",
"title": f"Конвертация образа {source_path.name}",
"status": "queued",
"progress": 0,
"message": "Ожидание конвертации образа",
"created_at": utc_now(),
"updated_at": utc_now(),
"created_by": AUTH_USER,
"source_path": str(source_path),
"target_path": str(target_path),
"source_format": source_format,
"cmd": " ".join(cmd),
}
start_background_operation(operation, cmd)
return operation
'''
if 'def start_disk_image_convert_operation(' not in text:
marker = '\n\ndef valid_vm_name(name: str) -> bool:'
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('disk conversion operation helpers added')
else:
warnings.append('valid_vm_name marker not found, conversion helpers skipped')
else:
changed.append('disk conversion operation helpers already present')
old_upload = r'''@app.post("/disk-images/upload", response_class=HTMLResponse)
def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
safe_name = safe_disk_image_filename(image_file.filename or "")
if not safe_name:
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw или .qcow2 файлы с безопасным именем."}, status_code=400)
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
target = DISK_IMAGES_DIR / safe_name
if target.exists():
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Образ уже существует: {safe_name}"}, status_code=400)
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
try:
with tmp_target.open("wb") as out:
shutil.copyfileobj(image_file.file, out)
tmp_target.rename(target)
except Exception as exc:
tmp_target.unlink(missing_ok=True)
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки образа: {exc}"}, status_code=500)
return RedirectResponse(url="/disk-images", status_code=303)
'''
new_upload = r'''@app.post("/disk-images/upload")
def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
safe_name = safe_disk_image_filename(image_file.filename or "")
wants_json = "application/json" in request.headers.get("accept", "") or request.headers.get("x-requested-with") == "XMLHttpRequest"
def disk_error(message: str, status_code: int = 400):
if wants_json:
return JSONResponse({"ok": False, "error": message}, status_code=status_code)
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": message}, status_code=status_code)
if not safe_name:
return disk_error("Можно загружать только .img, .raw или .qcow2 файлы с безопасным именем.")
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
target = DISK_IMAGES_DIR / safe_name
final_qcow2 = DISK_IMAGES_DIR / qcow2_name_for_upload(safe_name)
if target.exists() or final_qcow2.exists():
return disk_error(f"Образ уже существует: {safe_name}")
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
try:
with tmp_target.open("wb") as out:
shutil.copyfileobj(image_file.file, out)
tmp_target.rename(target)
if target.suffix.lower() == ".qcow2":
payload = {"ok": True, "mode": "ready", "name": target.name, "redirect": "/disk-images"}
else:
operation = start_disk_image_convert_operation(target, final_qcow2, qemu_img_format_for_path(target))
payload = {"ok": True, "mode": "converting", "name": target.name, "target": final_qcow2.name, "operation_id": operation["id"], "redirect": "/disk-images"}
except Exception as exc:
tmp_target.unlink(missing_ok=True)
return disk_error(f"Ошибка загрузки образа: {exc}", status_code=500)
if wants_json:
return JSONResponse(payload)
return RedirectResponse(url="/disk-images", status_code=303)
'''
if old_upload in text:
text = text.replace(old_upload, new_upload, 1)
changed.append('disk upload returns JSON and starts conversion operation')
elif 'mode": "converting"' in text and ('start_disk_image_convert_operation' in text or 'start_disk_convert_operation' in text):
changed.append('disk upload conversion already present')
else:
warnings.append('disk_image_upload marker not found, upload conversion patch skipped')
app_path.write_text(text)
print('disk convert progress patch completed:')
for item in changed:
print(f'- {item}')
for item in warnings:
print(f'WARN: {item}')
raise SystemExit(0)
+298
Просмотреть файл
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
warnings = []
if 'DISK_IMAGES_DIR = Path("/var/lib/virtuality/disk-images")' not in text:
text = text.replace('IMAGES_DIR = Path("/var/lib/virtuality/images")\n', 'IMAGES_DIR = Path("/var/lib/virtuality/images")\nDISK_IMAGES_DIR = Path("/var/lib/virtuality/disk-images")\n', 1)
changed.append('DISK_IMAGES_DIR added')
else:
changed.append('DISK_IMAGES_DIR already present')
helpers = r'''
def vm_arch_options() -> list[dict[str, str]]:
return [
{"value": "auto", "label": "Auto — по профилю хоста"},
{"value": "x86_64", "label": "x86_64 / amd64"},
{"value": "aarch64", "label": "ARM64 / aarch64"},
{"value": "generic", "label": "Generic / no arch override"},
]
def normalize_guest_arch(value: str, profile: dict[str, Any]) -> str:
value = (value or "auto").strip()
if value == "auto":
return str(profile.get("recommended_guest_arch") or "x86_64")
if value in ("x86_64", "amd64"):
return "x86_64"
if value in ("aarch64", "arm64"):
return "aarch64"
if value == "generic":
return "generic"
return str(profile.get("recommended_guest_arch") or "x86_64")
def list_disk_image_files() -> list[dict[str, str]]:
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
files = []
for item in sorted(list(DISK_IMAGES_DIR.glob("*.img")) + list(DISK_IMAGES_DIR.glob("*.raw")) + list(DISK_IMAGES_DIR.glob("*.qcow2"))):
try:
stat = item.stat()
size_gb = stat.st_size / 1024 / 1024 / 1024
updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
except OSError:
size_gb = 0
updated = "unknown"
files.append({"name": item.name, "path": str(item), "format": item.suffix.lower().lstrip('.'), "size": f"{size_gb:.2f} GB", "updated": updated})
return files
def safe_disk_image_filename(filename: str) -> str | None:
name = Path(filename or "").name.strip().replace(" ", "-")
name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name)
if not name.lower().endswith((".img", ".raw", ".qcow2")):
return None
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.(img|raw|qcow2)", name, re.IGNORECASE):
return None
return name
def disk_image_path_by_name(name: str) -> Path | None:
safe_name = safe_disk_image_filename(name)
if not safe_name:
return None
path = (DISK_IMAGES_DIR / safe_name).resolve()
if DISK_IMAGES_DIR.resolve() not in path.parents:
return None
return path
def disk_image_format(path: Path) -> str:
suffix = path.suffix.lower().lstrip('.')
if suffix == 'qcow2':
return 'qcow2'
return 'raw'
def bridge_exists(name: str) -> bool:
if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):
return False
return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]
'''
if 'def list_disk_image_files() -> list[dict[str, str]]:' not in text:
marker = '\n\ndef valid_vm_name(name: str) -> bool:'
if marker not in text:
raise SystemExit('valid_vm_name marker not found')
text = text.replace(marker, helpers + marker, 1)
changed.append('disk image and architecture helpers added')
else:
if 'def vm_arch_options()' not in text:
insert = helpers.split('\n\ndef list_disk_image_files()', 1)[0]
marker = '\n\ndef list_disk_image_files() -> list[dict[str, str]]:'
text = text.replace(marker, insert + marker, 1)
changed.append('architecture helpers added')
else:
changed.append('architecture helpers already present')
changed.append('disk image helpers already present')
text = text.replace('\n\ndef bridge_exists(name: str) -> bool:\n if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):\n return False\n return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]\n\n\ndef bridge_exists(name: str) -> bool:\n if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):\n return False\n return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]\n', '\n\ndef bridge_exists(name: str) -> bool:\n if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):\n return False\n return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]\n')
old_context = '"isos": list_iso_files(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
new_context = '"isos": list_iso_files(), "disk_images": list_disk_image_files(), "arch_options": vm_arch_options(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "guest_arch": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
if old_context in text:
text = text.replace(old_context, new_context, 1)
changed.append('vm form context gets disk images and arch options')
elif '"disk_images": list_disk_image_files()' in text and '"arch_options": vm_arch_options()' not in text:
text = text.replace('"disk_images": list_disk_image_files(),', '"disk_images": list_disk_image_files(), "arch_options": vm_arch_options(),')
text = text.replace('"source_type": "iso", "network_mode"', '"source_type": "iso", "guest_arch": "auto", "network_mode"')
changed.append('vm form context upgraded with arch options')
elif '"arch_options": vm_arch_options()' in text:
changed.append('vm form context already has arch options')
else:
warnings.append('vm_form_context marker not found')
routes = r'''
@app.get("/disk-images", response_class=HTMLResponse)
def disk_images_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": error})
@app.post("/disk-images/upload", response_class=HTMLResponse)
def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
safe_name = safe_disk_image_filename(image_file.filename or "")
if not safe_name:
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw или .qcow2 файлы с безопасным именем."}, status_code=400)
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
target = DISK_IMAGES_DIR / safe_name
if target.exists():
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Образ уже существует: {safe_name}"}, status_code=400)
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
try:
with tmp_target.open("wb") as out:
shutil.copyfileobj(image_file.file, out)
tmp_target.rename(target)
except Exception as exc:
tmp_target.unlink(missing_ok=True)
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки образа: {exc}"}, status_code=500)
return RedirectResponse(url="/disk-images", status_code=303)
@app.post("/disk-images/{name}/delete")
def disk_image_delete(request: Request, name: str):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
path = disk_image_path_by_name(name)
if path and path.exists() and path.is_file():
path.unlink()
return RedirectResponse(url="/disk-images", status_code=303)
'''
if '@app.get("/disk-images"' not in text:
marker = '\n\n@app.get("/network", response_class=HTMLResponse)'
if marker not in text:
raise SystemExit('network route marker not found')
text = text.replace(marker, routes + marker, 1)
changed.append('disk image routes added')
else:
changed.append('disk image routes already present')
old_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(...), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
new_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
if old_sig in text:
text = text.replace(old_sig, new_sig, 1)
changed.append('vm create signature supports disk images and guest arch')
elif 'source_type: str = Form("iso")' in text and 'guest_arch: str = Form("auto")' not in text:
text = text.replace('source_type: str = Form("iso"), network_mode:', 'source_type: str = Form("iso"), guest_arch: str = Form("auto"), network_mode:', 1)
changed.append('vm create signature upgraded with guest arch')
elif 'guest_arch: str = Form("auto")' in text:
changed.append('vm create signature already supports guest arch')
else:
warnings.append('vm_create_submit signature marker not found')
old_body = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "network_mode": network_mode, "bridge": bridge}'
new_body = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
if old_body in text:
text = text.replace(old_body, new_body, 1)
changed.append('vm create form state supports source type and guest arch')
elif '"source_type": source_type' in text and '"guest_arch": guest_arch' not in text:
text = text.replace('"source_type": source_type, "network_mode"', '"source_type": source_type, "guest_arch": guest_arch, "network_mode"')
changed.append('vm create form state upgraded with guest arch')
old_iso_validation = ''' else:
iso = Path(iso_path).resolve()
if ISO_DIR.resolve() not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
error = "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
'''
new_iso_validation = ''' elif source_type not in ("iso", "disk_image"):
error = "Некорректный источник VM."
elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):
error = "Некорректная архитектура VM."
elif network_mode == "bridge" and not bridge_exists(bridge):
error = f"Bridge {bridge} не найден на сервере. Для VPS выбери режим NAT Router — virtuality-nat, либо сначала создай bridge {bridge}."
else:
if source_type == "iso":
iso = Path(iso_path).resolve()
if ISO_DIR.resolve() not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
error = "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
else:
disk_image = Path(disk_image_path).resolve()
if DISK_IMAGES_DIR.resolve() not in disk_image.parents or disk_image.suffix.lower() not in (".img", ".raw", ".qcow2") or not disk_image.exists():
error = "Образ диска должен быть существующим .img, .raw или .qcow2 файлом из /var/lib/virtuality/disk-images."
'''
if old_iso_validation in text:
text = text.replace(old_iso_validation, new_iso_validation, 1)
changed.append('vm create validation supports disk image source and guest arch')
elif 'guest_arch not in' not in text and 'source_type == "iso"' in text:
text = text.replace('elif network_mode not in ("nat", "bridge"):\n error = "Некорректный режим сети."', 'elif network_mode not in ("nat", "bridge"):\n error = "Некорректный режим сети."\n elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):\n error = "Некорректная архитектура VM."', 1)
changed.append('vm create validation upgraded with guest arch')
elif 'guest_arch not in' in text:
changed.append('vm create validation already supports guest arch')
old_cmd = ''' IMAGES_DIR.mkdir(parents=True, exist_ok=True)
disk_path = IMAGES_DIR / f"{name}.qcow2"
if disk_path.exists():
return vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400)
profile = host_profile.load_host_profile()
is_arm = profile.get("recommended_guest_arch") == "aarch64"
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus)]
if is_arm:
cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host", "--virt-type", "kvm", "--boot", "uefi"]
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
'''
new_cmd = ''' IMAGES_DIR.mkdir(parents=True, exist_ok=True)
disk_path = IMAGES_DIR / f"{name}.qcow2"
if disk_path.exists():
return vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400)
profile = host_profile.load_host_profile()
selected_arch = normalize_guest_arch(guest_arch, profile)
host_arch = str(profile.get("arch") or platform.machine() or "")
is_arm = selected_arch == "aarch64"
# ARM64 guest on x86 host cannot use KVM. It must use QEMU emulation.
virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else ("kvm" if profile.get("kvm_device") else "qemu")
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus), "--virt-type", virt_type]
if selected_arch == "x86_64":
cmd += ["--arch", "x86_64"]
elif is_arm:
cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57", "--boot", "uefi"]
if source_type == "disk_image":
source_disk = Path(disk_image_path).resolve()
source_format = disk_image_format(source_disk)
convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}"
virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"])
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"]
else:
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
'''
if old_cmd in text:
text = text.replace(old_cmd, new_cmd, 1)
changed.append('vm create command supports ARM64 qemu fallback, guest arch and disk image import')
elif 'selected_arch = normalize_guest_arch' in text:
# Upgrade existing selected_arch block if it still uses kvm for aarch64 on x86.
text = re.sub(
r'virt_type = "kvm" if profile\.get\("kvm_device"\) else "qemu"',
'host_arch = str(profile.get("arch") or platform.machine() or "")\n # ARM64 guest on x86 host cannot use KVM. It must use QEMU emulation.\n virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else ("kvm" if profile.get("kvm_device") else "qemu")',
text,
count=1,
)
changed.append('existing vm create command was upgraded with ARM64 qemu fallback')
elif 'virt_type = "kvm" if profile.get("kvm_device") else "qemu"' in text:
text = text.replace('is_arm = profile.get("recommended_guest_arch") == "aarch64"\n virt_type =', 'selected_arch = normalize_guest_arch(guest_arch, profile)\n host_arch = str(profile.get("arch") or platform.machine() or "")\n is_arm = selected_arch == "aarch64"\n virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else', 1)
text = text.replace('if is_arm:\n cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57", "--boot", "uefi"]', 'if selected_arch == "x86_64":\n cmd += ["--arch", "x86_64"]\n elif is_arm:\n cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57", "--boot", "uefi"]', 1)
changed.append('existing vm create command was upgraded with guest arch and ARM64 qemu fallback')
else:
warnings.append('vm command marker not found')
if '"guest_arch": selected_arch' not in text and '"guest_arch": profile.get("recommended_guest_arch")' in text:
text = text.replace('"guest_arch": profile.get("recommended_guest_arch")', '"guest_arch": selected_arch')
if '"disk_image_path": disk_image_path' not in text:
text = text.replace('"iso_path": iso_path, "host_profile"', '"iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": selected_arch, "host_profile"')
app_path.write_text(text)
print('disk images patch applied:')
for item in changed:
print(f'- {item}')
for item in warnings:
print(f'WARN: {item}')
+224
Просмотреть файл
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
app_dir = app_path.resolve().parent
template_path = app_dir / 'templates' / 'vm_detail.html'
changed = []
warnings = []
text = app_path.read_text()
if 'import tempfile' not in text:
text = text.replace('import subprocess\n', 'import subprocess\nimport tempfile\n', 1)
changed.append('tempfile import added')
else:
changed.append('tempfile import already present')
if 'import xml.etree.ElementTree as ET' not in text:
text = text.replace('from typing import Any\n', 'from typing import Any\nimport xml.etree.ElementTree as ET\n', 1)
changed.append('ElementTree import added')
else:
changed.append('ElementTree import already present')
helpers = r'''
def vm_boot_order_label(value: str) -> str:
labels = {item["value"]: item["label"] for item in vm_boot_order_options()}
return labels.get(value or "auto", "Auto — по источнику VM")
def boot_order_to_devs(value: str) -> list[str]:
value = normalize_boot_order(value, "disk_image")
mapping = {
"disk": ["hd"],
"cdrom_disk": ["cdrom", "hd"],
"disk_cdrom": ["hd", "cdrom"],
"network_disk": ["network", "hd"],
}
return mapping.get(value, ["hd"])
def boot_devs_to_order(devs: list[str]) -> str:
clean = [item for item in devs if item in ("hd", "cdrom", "network")]
if clean[:2] == ["cdrom", "hd"]:
return "cdrom_disk"
if clean[:2] == ["hd", "cdrom"]:
return "disk_cdrom"
if clean[:2] == ["network", "hd"]:
return "network_disk"
if clean[:1] == ["hd"]:
return "disk"
return "auto"
def current_vm_boot_order(name: str) -> str:
result = run_cmd(["virsh", "dumpxml", name], timeout=12)
if not result.get("ok"):
return "auto"
try:
root = ET.fromstring(result.get("stdout") or "")
except Exception:
return "auto"
os_node = root.find("os")
if os_node is None:
return "auto"
devs = []
for boot in os_node.findall("boot"):
dev = boot.attrib.get("dev", "").strip()
if dev:
devs.append(dev)
return boot_devs_to_order(devs)
def apply_vm_boot_order(name: str, boot_order: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
if boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk"):
return False, "Некорректный порядок загрузки VM."
selected = normalize_boot_order(boot_order, "disk_image")
result = run_cmd(["virsh", "dumpxml", name], timeout=15)
if not result.get("ok"):
return False, result.get("stderr") or "Не удалось получить XML VM."
try:
root = ET.fromstring(result.get("stdout") or "")
except Exception as exc:
return False, f"Не удалось разобрать XML VM: {exc}"
os_node = root.find("os")
if os_node is None:
os_node = ET.SubElement(root, "os")
for boot in list(os_node.findall("boot")):
os_node.remove(boot)
insert_at = 0
for idx, child in enumerate(list(os_node)):
if child.tag in ("type", "loader", "nvram", "firmware", "smbios", "bootmenu"):
insert_at = idx + 1
for dev in reversed(boot_order_to_devs(selected)):
boot_node = ET.Element("boot", {"dev": dev})
os_node.insert(insert_at, boot_node)
xml_text = ET.tostring(root, encoding="unicode")
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".xml", delete=False) as handle:
handle.write(xml_text)
tmp_name = handle.name
try:
define = run_cmd(["virsh", "define", tmp_name], timeout=30)
finally:
Path(tmp_name).unlink(missing_ok=True)
if not define.get("ok"):
return False, define.get("stderr") or "virsh define завершился ошибкой."
return True, f"Порядок загрузки применён: {vm_boot_order_label(selected)}. Если VM запущена, изменение сработает после перезапуска."
'''
if 'def apply_vm_boot_order(name: str, boot_order: str)' not in text:
marker = '\n\ndef vm_details(name: str) -> dict[str, Any]:'
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('existing VM boot order helpers added')
else:
warnings.append('vm_details marker not found, helpers skipped')
else:
changed.append('existing VM boot order helpers already present')
old = '"host_ip": system_summary()["ip"]})'
new = '"host_ip": system_summary()["ip"], "boot_options": vm_boot_order_options(), "current_boot_order": current_vm_boot_order(name), "boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", "")})'
if '"current_boot_order": current_vm_boot_order(name)' not in text:
if old in text:
text = text.replace(old, new, 1)
changed.append('vm detail context gets boot order')
else:
warnings.append('vm_detail context marker not found, skipped')
else:
changed.append('vm detail context already has boot order')
route = r'''
@app.post("/vm/{name}/boot-order")
def vm_boot_order_apply(request: Request, name: str, boot_order: str = Form("auto")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
ok, message = apply_vm_boot_order(name, boot_order)
if ok:
return RedirectResponse(url=f"/vm/{name}?boot_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?boot_error={message}", status_code=303)
'''
if '@app.post("/vm/{name}/boot-order")' not in text:
marker = '\n\n@app.post("/vm/{name}/{action}")'
if marker in text:
text = text.replace(marker, route + marker, 1)
changed.append('existing VM boot order route added')
else:
warnings.append('generic vm action marker not found, route skipped')
else:
changed.append('existing VM boot order route already present')
app_path.write_text(text)
if template_path.exists():
tpl = template_path.read_text()
original = tpl
if 'action="/vm/{{ vm.name }}/boot-order"' not in tpl:
block = r'''
{% if boot_message %}
<div class="alert success">{{ boot_message }}</div>
{% endif %}
{% if boot_error %}
<div class="alert danger">{{ boot_error }}</div>
{% endif %}
<section class="card">
<div class="card-head">
<h2>Порядок загрузки</h2>
<span class="pill">boot order</span>
</div>
<form method="post" action="/vm/{{ vm.name }}/boot-order" class="form-grid">
<label>
<span>Порядок загрузки VM</span>
<select name="boot_order" required>
{% for boot in boot_options %}
<option value="{{ boot.value }}" {% if current_boot_order == boot.value %}selected{% endif %}>{{ boot.label }}</option>
{% endfor %}
</select>
</label>
<button class="primary wide" type="submit">Применить порядок загрузки</button>
</form>
<p class="muted small-note">Настройка меняет XML-конфигурацию VM через virsh define. Если машина сейчас запущена, новый порядок загрузки сработает после перезапуска.</p>
</section>
'''
markers = ['\n\n <section class="grid two">', '\n\n <section class="grid two">', '\n\n <section class="card">']
inserted = False
for marker in markers:
if marker in tpl:
tpl = tpl.replace(marker, block + marker, 1)
inserted = True
changed.append('boot order card added to vm_detail.html')
break
if not inserted:
warnings.append('vm detail insertion marker not found, boot order card skipped')
else:
changed.append('boot order card already present in vm_detail.html')
if tpl != original:
template_path.write_text(tpl)
else:
warnings.append(f'vm_detail.html not found: {template_path}')
print('existing VM boot order patch applied:')
for item in changed:
print(f'- {item}')
if warnings:
print('Warnings:')
for item in warnings:
print(f'- {item}')
+280
Просмотреть файл
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
app_dir = app_path.resolve().parent
template_path = app_dir / 'templates' / 'vm_detail.html'
changed = []
text = app_path.read_text()
helpers = r'''
def vm_state(name: str) -> str:
result = run_cmd(["virsh", "domstate", name], timeout=8)
return (result.get("stdout") or "unknown").strip().lower()
def current_vm_iso(name: str) -> str:
result = run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)
if not result.get("ok"):
return ""
for line in (result.get("stdout") or "").splitlines():
if ".iso" not in line.lower():
continue
parts = line.split()
if parts:
return parts[-1]
return ""
def detach_vm_iso(name: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
xml = run_cmd(["virsh", "dumpxml", name], timeout=12)
cdrom_targets: list[str] = []
if xml.get("ok"):
try:
root = ET.fromstring(xml.get("stdout") or "")
devices = root.find("devices")
if devices is not None:
for disk in devices.findall("disk"):
if disk.attrib.get("device") != "cdrom":
continue
target = disk.find("target")
dev = target.attrib.get("dev") if target is not None else ""
if dev:
cdrom_targets.append(dev)
except Exception:
pass
if not cdrom_targets:
cdrom_targets = ["sda", "hda", "sdb", "hdc"]
running = vm_state(name) == "running"
errors = []
changed_any = False
for target in cdrom_targets:
commands = []
if running:
commands.append(["virsh", "detach-disk", name, target, "--live"])
commands.append(["virsh", "detach-disk", name, target, "--config"])
for cmd in commands:
result = run_cmd(cmd, timeout=30)
if result.get("ok"):
changed_any = True
elif result.get("stderr"):
errors.append(result.get("stderr"))
if changed_any:
return True, "ISO был отмонтирован."
return False, errors[-1] if errors else "Подключенный ISO не найден."
def mount_vm_iso(name: str, iso_path: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
iso = Path(iso_path or "").resolve()
try:
iso_root = ISO_DIR.resolve()
except Exception:
iso_root = Path("/var/lib/virtuality/iso")
if iso_root not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
return False, "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
detach_vm_iso(name)
running = vm_state(name) == "running"
base = ["virsh", "attach-disk", name, str(iso), "sda", "--type", "cdrom", "--mode", "readonly"]
if running:
live = run_cmd(base + ["--live"], timeout=30)
if not live.get("ok"):
return False, live.get("stderr") or "Не удалось подключить ISO к запущенной VM."
config = run_cmd(base + ["--config"], timeout=30)
if not config.get("ok"):
return False, config.get("stderr") or "Не удалось сохранить ISO в конфигурации VM."
return True, "ISO был смонтирован в VM. Если гостевая ОС его не увидела сразу, перезагрузи VM или обнови устройства внутри гостевой ОС."
'''
if 'def mount_vm_iso(name: str, iso_path: str)' not in text:
marker = '\n\ndef vm_details(name: str) -> dict[str, Any]:'
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('existing VM ISO mount helpers added')
else:
changed.append('ISO helpers skipped: vm_details marker not found')
else:
changed.append('existing VM ISO mount helpers already present')
if '"current_iso": current_vm_iso(name)' not in text:
replacements = [
(
'"resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})',
'"resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", ""), "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})',
),
(
'"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", "")})',
'"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", ""), "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})',
),
(
'"host_ip": system_summary()["ip"]})',
'"host_ip": system_summary()["ip"], "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})',
),
]
for old, new in replacements:
if old in text:
text = text.replace(old, new, 1)
changed.append('vm detail context gets ISO mount data')
break
else:
changed.append('ISO context skipped: vm detail context marker not found')
else:
changed.append('vm detail context already has ISO mount data')
routes = r'''
@app.post("/vm/{name}/iso/mount")
def vm_iso_mount_apply(request: Request, name: str, iso_path: str = Form(...)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
ok, message = mount_vm_iso(name, iso_path)
if ok:
return RedirectResponse(url=f"/vm/{name}?iso_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?iso_error={message}", status_code=303)
@app.post("/vm/{name}/iso/unmount")
def vm_iso_unmount_apply(request: Request, name: str):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
ok, message = detach_vm_iso(name)
if ok:
return RedirectResponse(url=f"/vm/{name}?iso_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?iso_error={message}", status_code=303)
'''
if '@app.post("/vm/{name}/iso/mount")' not in text:
markers = ['\n\n@app.post("/vm/{name}/{action}")', '\n\n@app.get("/vm/create"', '\n\n@app.get("/operations"']
for marker in markers:
if marker in text:
text = text.replace(marker, routes + marker, 1)
changed.append('existing VM ISO mount routes added')
break
else:
changed.append('ISO routes skipped: safe route marker not found')
else:
changed.append('existing VM ISO mount routes already present')
app_path.write_text(text)
iso_card = r'''
<article class="card">
<div class="card-head">
<h2>ISO-привод</h2>
<span class="pill">cdrom</span>
</div>
{% if current_iso %}
<div class="notice">Сейчас подключен ISO: <b>{{ current_iso }}</b></div>
{% else %}
<div class="muted small-note">ISO сейчас не подключён.</div>
{% endif %}
<form method="post" action="/vm/{{ vm.name }}/iso/mount" class="form-grid">
<label>
<span>ISO образ</span>
<select name="iso_path" required>
{% for iso in isos %}
<option value="{{ iso.path }}" {% if current_iso == iso.path %}selected{% endif %}>{{ iso.name }} — {{ iso.size }}</option>
{% endfor %}
</select>
</label>
<button class="primary wide" type="submit" {% if not isos %}disabled{% endif %}>Смонтировать ISO в VM</button>
</form>
<form method="post" action="/vm/{{ vm.name }}/iso/unmount" class="form-grid" onsubmit="return confirm('Отмонтировать ISO из VM {{ vm.name }}?');">
<button type="submit" class="ghost wide">Отмонтировать ISO</button>
</form>
{% if not isos %}
<div class="alert danger">ISO-образов пока нет. Сначала загрузи .iso в разделе ISO.</div>
{% endif %}
<p class="muted small-note">Для запущенной VM ISO подключается live и сохраняется в конфигурации. Для выключенной VM ISO будет доступен при следующем старте.</p>
</article>
'''
boot_card = r'''
<article class="card">
<div class="card-head">
<h2>Порядок загрузки</h2>
<span class="pill">drag boot</span>
</div>
<form method="post" action="/vm/{{ vm.name }}/boot-order" class="form-grid boot-order-form">
<input type="hidden" name="boot_order" id="boot-order-value" value="{{ current_boot_order }}">
<div class="boot-order-list" id="boot-order-list" data-current="{{ current_boot_order }}">
<div class="boot-order-item" draggable="true" data-device="cdrom"><span class="drag-handle">☰</span><div><b>ISO / CD-ROM</b><small>Установщик или rescue-образ</small></div></div>
<div class="boot-order-item" draggable="true" data-device="hd"><span class="drag-handle">☰</span><div><b>Диск</b><small>Основной qcow2/raw диск VM</small></div></div>
<div class="boot-order-item" draggable="true" data-device="network"><span class="drag-handle">☰</span><div><b>Сеть / PXE</b><small>Загрузка по сети</small></div></div>
</div>
<button class="primary wide" type="submit">Применить порядок загрузки</button>
</form>
<p class="muted small-note">Перетащи нужный источник выше. Для загрузки с ISO поставь ISO / CD-ROM первым. Изменение сработает после перезапуска VM.</p>
<script>
(function(){
const list = document.getElementById('boot-order-list');
const input = document.getElementById('boot-order-value');
if (!list || !input) return;
const initialMap = {'cdrom_disk':['cdrom','hd','network'],'disk_cdrom':['hd','cdrom','network'],'network_disk':['network','hd','cdrom'],'disk':['hd','cdrom','network'],'auto':['hd','cdrom','network']};
const order = initialMap[list.dataset.current || 'auto'] || initialMap.auto;
const nodes = Array.from(list.querySelectorAll('.boot-order-item'));
order.forEach(device => { const node = nodes.find(item => item.dataset.device === device); if (node) list.appendChild(node); });
function updateValue(){
const devices = Array.from(list.querySelectorAll('.boot-order-item')).map(item => item.dataset.device);
const first = devices[0]; const second = devices[1];
if (first === 'cdrom' && second === 'hd') input.value = 'cdrom_disk';
else if (first === 'hd' && second === 'cdrom') input.value = 'disk_cdrom';
else if (first === 'network' && second === 'hd') input.value = 'network_disk';
else if (first === 'hd') input.value = 'disk';
else input.value = 'auto';
}
let dragged = null;
list.addEventListener('dragstart', event => { dragged = event.target.closest('.boot-order-item'); if (!dragged) return; dragged.classList.add('dragging'); event.dataTransfer.effectAllowed = 'move'; });
list.addEventListener('dragend', () => { if (dragged) dragged.classList.remove('dragging'); dragged = null; updateValue(); });
list.addEventListener('dragover', event => { event.preventDefault(); const after = Array.from(list.querySelectorAll('.boot-order-item:not(.dragging)')).find(item => { const box = item.getBoundingClientRect(); return event.clientY < box.top + box.height / 2; }); if (!dragged) return; if (after) list.insertBefore(dragged, after); else list.appendChild(dragged); });
updateValue();
})();
</script>
</article>
'''
if template_path.exists():
tpl = template_path.read_text()
original = tpl
settings_grid = '\n\n <section class="grid two vm-boot-iso-grid">\n' + iso_card + boot_card + ' </section>\n'
if 'action="/vm/{{ vm.name }}/iso/mount"' not in tpl and 'vm-boot-iso-grid' not in tpl:
markers = ['\n\n <section class="grid three vm-resource-boot-iso-grid">', '\n\n <section class="grid two">', '\n\n <section class="card">']
for marker in markers:
if marker in tpl:
if 'vm-resource-boot-iso-grid' in marker:
insert_pos = tpl.find(marker) + len(marker)
tpl = tpl[:insert_pos] + '\n' + iso_card + boot_card + tpl[insert_pos:]
changed.append('ISO and boot cards inserted into three-column grid')
else:
tpl = tpl.replace(marker, settings_grid + marker, 1)
changed.append('ISO and boot cards inserted by fallback')
break
else:
changed.append('ISO layout skipped: safe template marker not found')
elif 'vm-boot-iso-grid' in tpl and 'boot-order-list' not in tpl:
changed.append('ISO grid exists but draggable boot marker absent; left unchanged safely')
else:
changed.append('ISO mount template already present')
if tpl != original:
template_path.write_text(tpl)
else:
changed.append('ISO template skipped: vm_detail.html not found')
print('existing VM ISO mount patch applied:')
for item in changed:
print(f'- {item}')
+205
Просмотреть файл
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
changed = []
warnings = []
text = app_path.read_text()
if 'import tempfile' not in text:
text = text.replace('import subprocess\n', 'import subprocess\nimport tempfile\n', 1)
changed.append('tempfile import added')
else:
changed.append('tempfile import already present')
if 'import xml.etree.ElementTree as ET' not in text:
text = text.replace('from typing import Any\n', 'from typing import Any\nimport xml.etree.ElementTree as ET\n', 1)
changed.append('ElementTree import added')
else:
changed.append('ElementTree import already present')
helpers = r'''
def parse_dominfo_value(dominfo: str, label: str) -> str:
for line in (dominfo or "").splitlines():
if line.strip().lower().startswith(label.lower()):
return line.split(":", 1)[1].strip()
return ""
def kib_text_to_mb(value: str) -> int:
match = re.search(r"(\d+)", value or "")
if not match:
return 0
return max(0, int(int(match.group(1)) / 1024))
def current_vm_arch(name: str) -> str:
result = run_cmd(["virsh", "dumpxml", name], timeout=12)
if not result.get("ok"):
return "unknown"
try:
root = ET.fromstring(result.get("stdout") or "")
except Exception:
return "unknown"
os_type = root.find("os/type")
return (os_type.attrib.get("arch") if os_type is not None else "") or "unknown"
def vm_runtime_state(name: str) -> str:
result = run_cmd(["virsh", "domstate", name], timeout=8)
return (result.get("stdout") or "unknown").strip().lower()
def vm_resource_settings(name: str) -> dict[str, Any]:
dominfo = run_cmd(["virsh", "dominfo", name], timeout=10).get("stdout") or ""
state = (parse_dominfo_value(dominfo, "State") or vm_runtime_state(name)).lower()
vcpus_raw = parse_dominfo_value(dominfo, "CPU(s)")
used_memory_raw = parse_dominfo_value(dominfo, "Used memory")
max_memory_raw = parse_dominfo_value(dominfo, "Max memory")
memory_mb = kib_text_to_mb(used_memory_raw) or kib_text_to_mb(max_memory_raw) or 1024
try:
vcpus = int(re.search(r"\d+", vcpus_raw or "1").group(0))
except Exception:
vcpus = 1
return {
"state": state,
"is_shutoff": state in ("shut off", "shutoff", "shut-off"),
"memory_mb": memory_mb,
"vcpus": vcpus,
"arch": current_vm_arch(name),
}
def apply_vm_resources(name: str, memory_mb: int, vcpus: int, guest_arch: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
resources = vm_resource_settings(name)
if not resources.get("is_shutoff"):
return False, "CPU/RAM/архитектуру можно менять только когда VM выключена. Сначала выключи VM."
if memory_mb < 512 or memory_mb > 262144:
return False, "RAM должна быть от 512 MB до 262144 MB."
if vcpus < 1 or vcpus > 128:
return False, "CPU должен быть от 1 до 128 vCPU."
if guest_arch not in ("keep", "x86_64", "aarch64"):
return False, "Некорректная архитектура VM."
result = run_cmd(["virsh", "dumpxml", name], timeout=15)
if not result.get("ok"):
return False, result.get("stderr") or "Не удалось получить XML VM."
try:
root = ET.fromstring(result.get("stdout") or "")
except Exception as exc:
return False, f"Не удалось разобрать XML VM: {exc}"
memory_kib = str(int(memory_mb) * 1024)
for tag in ("memory", "currentMemory"):
node = root.find(tag)
if node is None:
node = ET.SubElement(root, tag)
node.text = memory_kib
node.set("unit", "KiB")
vcpu_node = root.find("vcpu")
if vcpu_node is None:
vcpu_node = ET.SubElement(root, "vcpu")
vcpu_node.text = str(int(vcpus))
vcpu_node.set("placement", "static")
arch_changed = False
if guest_arch != "keep":
os_type = root.find("os/type")
if os_type is None:
os_node = root.find("os")
if os_node is None:
os_node = ET.SubElement(root, "os")
os_type = ET.SubElement(os_node, "type")
os_type.text = "hvm"
old_arch = os_type.attrib.get("arch", "")
if old_arch != guest_arch:
os_type.set("arch", guest_arch)
if guest_arch == "aarch64":
os_type.set("machine", "virt")
arch_changed = True
xml_text = ET.tostring(root, encoding="unicode")
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".xml", delete=False) as handle:
handle.write(xml_text)
tmp_name = handle.name
try:
define = run_cmd(["virsh", "define", tmp_name], timeout=30)
finally:
Path(tmp_name).unlink(missing_ok=True)
if not define.get("ok"):
return False, define.get("stderr") or "virsh define завершился ошибкой."
message = f"Ресурсы VM применены: CPU {vcpus}, RAM {memory_mb} MB"
if arch_changed:
message += f", архитектура {guest_arch}. Важно: смена архитектуры может потребовать совместимый диск/загрузчик."
return True, message
'''
if 'def apply_vm_resources(name: str, memory_mb: int, vcpus: int, guest_arch: str)' not in text:
marker = '\n\ndef vm_details(name: str) -> dict[str, Any]:'
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('existing VM resources helpers added')
else:
warnings.append('vm_details marker not found, helpers skipped')
else:
changed.append('existing VM resources helpers already present')
if '"resource_settings": vm_resource_settings(name)' not in text:
replacements = [
('"iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})', '"iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", ""), "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})'),
('"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", "")})', '"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", ""), "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})'),
('"host_ip": system_summary()["ip"]})', '"host_ip": system_summary()["ip"], "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})'),
]
for old, new in replacements:
if old in text:
text = text.replace(old, new, 1)
changed.append('vm detail context gets resource settings')
break
else:
warnings.append('vm detail context marker not found, resource context skipped')
else:
changed.append('vm detail context already has resource settings')
route = r'''
@app.post("/vm/{name}/resources")
def vm_resources_apply(request: Request, name: str, memory_mb: int = Form(...), vcpus: int = Form(...), guest_arch: str = Form("keep")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
ok, message = apply_vm_resources(name, memory_mb, vcpus, guest_arch)
if ok:
return RedirectResponse(url=f"/vm/{name}?resource_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?resource_error={message}", status_code=303)
'''
if '@app.post("/vm/{name}/resources")' not in text:
markers = ['\n\n@app.post("/vm/{name}/boot-order")', '\n\n@app.post("/vm/{name}/iso/mount")', '\n\n@app.post("/vm/{name}/{action}")']
for marker in markers:
if marker in text:
text = text.replace(marker, route + marker, 1)
changed.append('existing VM resources route added')
break
else:
warnings.append('vm action marker not found, resources route skipped')
else:
changed.append('existing VM resources route already present')
app_path.write_text(text)
print('existing VM resources patch applied:')
for item in changed:
print(f'- {item}')
if warnings:
print('Warnings:')
for item in warnings:
print(f'- {item}')
+154
Просмотреть файл
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
app_dir = app_path.parent
static_dir = app_dir / 'static'
static_dir.mkdir(exist_ok=True)
panel_js_path = static_dir / 'panel.js'
app_css_path = static_dir / 'app.css'
changed = []
text = app_path.read_text()
live_routes = r'''
@app.get("/live/status")
def live_status(request: Request):
if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
vms = []
for vm in parse_virsh_list():
name = vm.get("name", "")
state = vm.get("state", "unknown")
try:
ip = vm_ip(name) if name else "—"
except Exception:
ip = "—"
css = "ok" if "running" in state else "err" if "shut" in state else "warn"
vms.append({"id": vm.get("id", "-"), "name": name, "state": state, "state_css": css, "ip": ip if ip and ip != "not available" else "—"})
return JSONResponse({"ok": True, "generated_at": utc_now(), "vms": vms, "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "web": service_state("virtuality-web.service")}, "operations": list_operations(5)})
@app.get("/live/operations")
def live_operations(request: Request):
if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
return JSONResponse({"ok": True, "generated_at": utc_now(), "operations": list_operations(25)})
'''
if '@app.get("/live/status"' not in text:
marker = '\n\n@app.get("/api/health")'
text = text.replace(marker, live_routes + marker, 1) if marker in text else text.rstrip() + live_routes + '\n'
changed.append('live routes added')
else:
changed.append('live routes already present')
app_path.write_text(text)
panel_js = panel_js_path.read_text() if panel_js_path.exists() else ''
live_js = r'''
/* Virtuality live status + toast layer */
(() => {
if (window.VirtualityLiveStatus) return;
window.VirtualityLiveStatus = true;
function qs(s, r = document) { return r.querySelector(s); }
function qsa(s, r = document) { return Array.from(r.querySelectorAll(s)); }
function toast(message, type = 'ok') {
if (!message) return;
let wrap = qs('#v-toast-wrap');
if (!wrap) { wrap = document.createElement('div'); wrap.id = 'v-toast-wrap'; document.body.appendChild(wrap); }
const item = document.createElement('div');
item.className = 'v-toast ' + type;
item.textContent = message;
wrap.appendChild(item);
setTimeout(() => item.classList.add('show'), 20);
setTimeout(() => { item.classList.remove('show'); setTimeout(() => item.remove(), 260); }, 3600);
}
function vmNameFromRow(row) {
const link = qs('a[href^="/vm/"]', row);
if (!link) return '';
try { const p = new URL(link.href, location.href).pathname.split('/').filter(Boolean); return p[0] === 'vm' ? decodeURIComponent(p[1] || '') : ''; } catch (_) { return ''; }
}
function stateClass(state) { const v = String(state || '').toLowerCase(); return v.includes('running') ? 'ok' : v.includes('shut') ? 'err' : 'warn'; }
function updateDashboardRows(vms) {
if (!Array.isArray(vms)) return;
const map = new Map(vms.map((vm) => [vm.name, vm]));
qsa('table tbody tr').forEach((row) => {
const name = vmNameFromRow(row);
if (!name || !map.has(name)) return;
const vm = map.get(name);
const cells = qsa('td', row);
if (cells[0]) cells[0].textContent = vm.id || '-';
if (cells[2]) cells[2].textContent = vm.ip || '—';
if (cells[3]) {
let badge = qs('.status', cells[3]);
if (!badge) { badge = document.createElement('span'); cells[3].textContent = ''; cells[3].appendChild(badge); }
badge.className = 'status ' + (vm.state_css || stateClass(vm.state));
badge.textContent = vm.state || 'unknown';
}
});
}
function updateDetailHeader(vms) {
const title = qs('.brand');
if (!title || !Array.isArray(vms)) return;
const vm = vms.find((item) => item.name === title.textContent.trim());
if (!vm) return;
let badge = qs('#vm-live-badge');
if (!badge) { badge = document.createElement('span'); badge.id = 'vm-live-badge'; title.insertAdjacentElement('afterend', badge); }
badge.className = 'status live-badge ' + (vm.state_css || stateClass(vm.state));
badge.textContent = vm.state || 'unknown';
}
async function refreshLiveStatus() {
if (document.hidden || (!qs('.v-main') && !qs('.shell'))) return;
try {
const r = await fetch('/live/status', { cache: 'no-store', headers: { 'Accept': 'application/json' }});
if (!r.ok) return;
const p = await r.json();
if (!p.ok) return;
updateDashboardRows(p.vms);
updateDetailHeader(p.vms);
} catch (_) {}
}
document.addEventListener('submit', (event) => {
const form = event.target;
if (!form || !form.action) return;
const parts = new URL(form.action, location.href).pathname.split('/').filter(Boolean);
if (parts[0] !== 'vm' || parts.length < 3) return;
const labels = { start: 'Команда запуска VM отправлена', shutdown: 'Команда мягкого выключения VM отправлена', reboot: 'Команда перезагрузки VM отправлена', destroy: 'Команда принудительного выключения VM отправлена', autostart: 'Автозапуск VM включается', 'autostart-disable': 'Автозапуск VM отключается', delete: 'Удаление VM запущено' };
const action = parts[2];
if (labels[action]) { const type = action === 'destroy' || action === 'delete' ? 'warn' : 'ok'; sessionStorage.setItem('virtualityToast', labels[action]); toast(labels[action], type); setTimeout(refreshLiveStatus, 900); setTimeout(refreshLiveStatus, 2500); }
}, true);
function boot() { const m = sessionStorage.getItem('virtualityToast'); if (m) { sessionStorage.removeItem('virtualityToast'); toast(m, 'ok'); } refreshLiveStatus(); setInterval(refreshLiveStatus, 5000); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
})();
'''
if 'Virtuality live status + toast layer' not in panel_js:
panel_js_path.write_text(panel_js.rstrip() + live_js + '\n')
changed.append('live javascript added')
else:
changed.append('live javascript already present')
app_css = app_css_path.read_text() if app_css_path.exists() else ''
live_css = r'''
/* Virtuality live status + toast layer */
#v-toast-wrap { position: fixed; top: 14px; right: 14px; z-index: 10000; display: grid; gap: 8px; width: min(360px, calc(100vw - 28px)); pointer-events: none; }
.v-toast { transform: translateY(-8px); opacity: 0; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--line); background: #fff; color: var(--text); box-shadow: 0 12px 32px rgba(15,23,42,.18); font-size: 13px; font-weight: 800; transition: .22s ease; }
.v-toast.show { transform: translateY(0); opacity: 1; }
.v-toast.ok { border-color: rgba(22,163,74,.28); box-shadow: 0 12px 32px rgba(22,163,74,.16); }
.v-toast.warn { border-color: rgba(217,119,6,.32); box-shadow: 0 12px 32px rgba(217,119,6,.16); }
.v-toast.err { border-color: rgba(220,38,38,.32); box-shadow: 0 12px 32px rgba(220,38,38,.16); }
.live-badge { margin-left: 10px; vertical-align: middle; }
'''
if 'Virtuality live status + toast layer' not in app_css:
app_css_path.write_text(app_css.rstrip() + live_css + '\n')
changed.append('live css added')
else:
changed.append('live css already present')
print('live status patch applied:')
for item in changed:
print(f'- {item}')
+166
Просмотреть файл
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
templates_dir = app_path.parent / 'templates'
static_dir = app_path.parent / 'static'
text = app_path.read_text()
changed = []
helpers = r'''
LOG_SOURCES = {
"web": {"title": "Virtuality Web", "kind": "journal", "unit": "virtuality-web.service"},
"update": {"title": "Update Center", "kind": "file", "path": "/var/log/virtuality/update.log"},
"install": {"title": "Последняя установка", "kind": "glob", "pattern": "/var/log/virtuality/install_web_panel_*.log"},
"operations": {"title": "Операции", "kind": "operations"},
"auto-update": {"title": "Auto Update", "kind": "journal", "unit": "virtuality-auto-update.service"},
"libvirtd": {"title": "libvirtd", "kind": "journal", "unit": "libvirtd.service"},
"virtlogd": {"title": "virtlogd", "kind": "journal", "unit": "virtlogd.service"},
}
def read_log_source(source: str, lines: int = 220) -> dict[str, Any]:
key = source if source in LOG_SOURCES else "web"
cfg = LOG_SOURCES[key]
lines = max(20, min(int(lines or 220), 2000))
content = ""
path = ""
cmd = ""
if cfg["kind"] == "journal":
unit = cfg["unit"]
cmd = f"journalctl -u {unit} -n {lines} --no-pager"
result = run_cmd(["journalctl", "-u", unit, "-n", str(lines), "--no-pager"], timeout=15)
content = result["stdout"] or result["stderr"] or "Лог пуст или journalctl недоступен"
elif cfg["kind"] == "file":
path = cfg["path"]
cmd = f"tail -n {lines} {path}"
content = tail_text(Path(path), max_lines=lines) or "Файл лога пока пуст или не найден"
elif cfg["kind"] == "glob":
pattern = cfg["pattern"]
files = sorted(Path('/').glob(pattern.lstrip('/')), key=lambda item: item.stat().st_mtime if item.exists() else 0, reverse=True)
if files:
path = str(files[0])
cmd = f"tail -n {lines} {path}"
content = tail_text(files[0], max_lines=lines) or "Файл лога пуст"
else:
cmd = f"ls {pattern}"
content = "Логи установки ещё не найдены"
elif cfg["kind"] == "operations":
cmd = f"tail -n {lines} /var/log/virtuality/operations/*.log"
parts = []
ensure_operations_dir()
for item in sorted(OPERATIONS_DIR.glob('*.log'), key=lambda p: p.stat().st_mtime, reverse=True)[:10]:
parts.append(f"===== {item.name} =====\n" + tail_text(item, max_lines=max(20, lines // 5)))
content = "\n\n".join(parts) or "Журналы операций пока пусты"
return {"key": key, "title": cfg["title"], "content": content, "path": path, "cmd": cmd, "lines": lines}
'''
if 'LOG_SOURCES = {' not in text:
markers = [
'\n\ndef parse_virsh_list() -> list[dict[str, str]]:',
'\n\ndef list_recent_operations(limit: int = 20) -> list[dict[str, Any]]:',
'\n\ndef safe_iso_filename(filename: str) -> str | None:',
]
for marker in markers:
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('log center helpers added')
break
else:
print('WARN: log helper marker not found, skip helper injection')
changed.append('log center helpers skipped')
else:
start = text.find('LOG_SOURCES = {')
end = text.find('\n}\n\n\ndef read_log_source', start)
if start != -1 and end != -1:
replacement = helpers.strip().split('\n\n\ndef read_log_source', 1)[0]
text = text[:start] + replacement + text[end + 3:]
changed.append('log sources refreshed without telegram')
else:
text = text.replace(' "telegram": {"title": "Telegram notifier", "kind": "file", "path": "/var/log/virtuality/telegram_version_bot.log"},\n', '')
changed.append('telegram log source removed')
route = r'''
@app.get("/logs", response_class=HTMLResponse)
def logs_page(request: Request, source: str = "web", lines: int = 220):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
selected = read_log_source(source, lines)
return templates.TemplateResponse("logs.html", {
"request": request,
"app_name": APP_NAME,
"user": AUTH_USER,
"sources": LOG_SOURCES,
"selected": selected,
})
@app.get("/api/logs")
def api_logs(request: Request, source: str = "web", lines: int = 220):
if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
return {"ok": True, "log": read_log_source(source, lines)}
'''
if '@app.get("/logs"' not in text:
markers = ['\n\n@app.get("/vm/create", response_class=HTMLResponse)', '\n\n@app.get("/api/operations")', '\n\n@app.get("/iso", response_class=HTMLResponse)']
for marker in markers:
if marker in text:
text = text.replace(marker, route + marker, 1)
changed.append('logs routes added')
break
else:
print('WARN: logs route insert marker not found, skip route injection')
changed.append('logs routes skipped')
else:
changed.append('logs routes already present')
app_path.write_text(text)
sidebar_html = '''{% set path = request.url.path %}
<aside class="v-sidebar">
<div class="v-logo">
<strong>Virtuality</strong>
<span>control panel</span>
</div>
<nav class="v-nav">
<a class="{{ 'active' if path == '/' else '' }}" href="/">Обзор</a>
<a class="{{ 'active' if path.startswith('/vm/create') else '' }}" href="/vm/create">Создать VM</a>
<div class="v-nav-group {{ 'open' if path.startswith('/iso') or path.startswith('/disk-images') else '' }}">
<button class="v-nav-group-title" type="button">Образы</button>
<a class="{{ 'active' if path.startswith('/iso') else '' }}" href="/iso">ISO</a>
<a class="{{ 'active' if path.startswith('/disk-images') else '' }}" href="/disk-images">Диски</a>
</div>
<a class="{{ 'active' if path.startswith('/network') else '' }}" href="/network">Сеть</a>
<a class="{{ 'active' if path.startswith('/operations') else '' }}" href="/operations">Операции</a>
<a class="{{ 'active' if path.startswith('/logs') else '' }}" href="/logs">Журналы</a>
<a class="{{ 'active' if path.startswith('/update') else '' }}" href="/update">Обновления</a>
<a class="{{ 'active' if path.startswith('/host') else '' }}" href="/host">Хост</a>
</nav>
</aside>
'''
if templates_dir.exists():
sidebar_path = templates_dir / '_sidebar.html'
sidebar_path.write_text(sidebar_html)
changed.append('_sidebar.html ensured')
for path in sorted(templates_dir.glob('*.html')):
if path.name in {'login.html', 'console.html', '_sidebar.html'}:
continue
html = path.read_text()
if '<script src="/static/panel.js" defer></script>' not in html:
html = html.replace('</body>', ' <script src="/static/panel.js" defer></script>\n</body>', 1)
changed.append(f'{path.name} panel.js attached')
path.write_text(html)
print('logs center and UI dynamics patch applied:')
for item in changed:
print(f'- {item}')
+148
Просмотреть файл
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
core_path = app_path.with_name('network_core.py')
if not core_path.exists():
raise SystemExit(f'network_core.py not found: {core_path}')
text = core_path.read_text()
changed = []
if 'def route_interface_for_ip(' not in text:
marker = '''def external_interface() -> str:
result = run_cmd(['ip', 'route', 'show', 'default'], timeout=5)
if not result['ok']:
return 'eth0'
match = re.search(r'\\bdev\\s+([^\\s]+)', result['stdout'])
return match.group(1) if match else 'eth0'
'''
helper = marker + '''
def route_interface_for_ip(ip: str) -> str:
if not valid_ip(ip):
return NAT_BRIDGE
result = run_cmd(['ip', 'route', 'get', ip], timeout=5)
if not result['ok']:
return NAT_BRIDGE
match = re.search(r'\\bdev\\s+([^\\s]+)', result['stdout'])
return match.group(1) if match else NAT_BRIDGE
def forward_guest_interface(item: dict[str, Any]) -> str:
return route_interface_for_ip(str(item.get('guest_ip', '')))
'''
if marker not in text:
raise SystemExit('external_interface marker not found')
text = text.replace(marker, helper, 1)
changed.append('route_interface_for_ip helper added')
else:
changed.append('route_interface_for_ip helper already present')
old_render = '''def render_nft_rules() -> str:
ext = external_interface()
lines = [
'table ip virtuality {',
' chain prerouting {',
' type nat hook prerouting priority dstnat; policy accept;',
]
for item in load_port_forwards():
external_ports = nft_port_value(item['external_port_start'], item['external_port_end'])
guest_ports = nft_port_value(item['guest_port_start'], item['guest_port_end'])
lines.append(f" iifname \\"{ext}\\" {item['protocol']} dport {external_ports} dnat to {item['guest_ip']}:{guest_ports}")
lines += [
' }',
' chain postrouting {',
' type nat hook postrouting priority srcnat; policy accept;',
f' ip saddr {NAT_SUBNET} oifname "{ext}" masquerade',
' }',
' chain forward {',
' type filter hook forward priority filter; policy accept;',
f' ip saddr {NAT_SUBNET} accept',
f' ip daddr {NAT_SUBNET} accept',
' }',
'}',
]
return '\\n'.join(lines) + '\\n'
'''
new_render = '''def render_nft_rules() -> str:
ext = external_interface()
forwards = load_port_forwards()
lines = [
'table ip virtuality {',
' chain prerouting {',
' type nat hook prerouting priority dstnat; policy accept;',
]
for item in forwards:
external_ports = nft_port_value(item['external_port_start'], item['external_port_end'])
guest_ports = nft_port_value(item['guest_port_start'], item['guest_port_end'])
lines.append(f" iifname \\"{ext}\\" {item['protocol']} dport {external_ports} dnat to {item['guest_ip']}:{guest_ports}")
lines += [
' }',
' chain postrouting {',
' type nat hook postrouting priority srcnat; policy accept;',
f' ip saddr {NAT_SUBNET} oifname "{ext}" masquerade',
]
for item in forwards:
guest_iface = forward_guest_interface(item)
lines.append(f' ip daddr {item["guest_ip"]} oifname "{guest_iface}" masquerade')
lines += [
' }',
' chain forward {',
' type filter hook forward priority filter; policy accept;',
f' ip saddr {NAT_SUBNET} accept',
f' ip daddr {NAT_SUBNET} accept',
]
for item in forwards:
guest_iface = forward_guest_interface(item)
lines.append(f' iifname "{ext}" oifname "{guest_iface}" ip daddr {item["guest_ip"]} accept')
lines.append(f' iifname "{guest_iface}" oifname "{ext}" ip saddr {item["guest_ip"]} ct state established,related accept')
lines += [
' }',
'}',
]
return '\\n'.join(lines) + '\\n'
'''
if old_render in text:
text = text.replace(old_render, new_render, 1)
changed.append('nft rules now support bridge/static VM interfaces')
elif 'forward_guest_interface(item)' in text and 'ip daddr {item["guest_ip"]}' in text:
changed.append('nft bridge/static rules already present')
else:
raise SystemExit('render_nft_rules marker not found')
text = text.replace(
"'out', 'on', NAT_BRIDGE,\n 'to', item['guest_ip'],",
"'out', 'on', forward_guest_interface(item),\n 'to', item['guest_ip'],",
)
if "'out', 'on', forward_guest_interface(item)" in text:
changed.append('UFW route rules now use guest route interface')
old_iptables_line = """ results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', NAT_BRIDGE, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', NAT_BRIDGE, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, '-j', 'DNAT', '--to-destination', guest_to]))
"""
new_iptables_line = """ guest_iface = forward_guest_interface(item)
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', guest_iface, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', guest_iface, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, '-j', 'DNAT', '--to-destination', guest_to]))
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-d', guest_ip, '-o', guest_iface, '-j', 'MASQUERADE']))
"""
if old_iptables_line in text:
text = text.replace(old_iptables_line, new_iptables_line, 1)
changed.append('iptables fallback now supports bridge/static VM interfaces')
elif "guest_iface = forward_guest_interface(item)" in text and "'-o', guest_iface" in text:
changed.append('iptables bridge/static fallback already present')
else:
raise SystemExit('iptables fallback marker not found')
text = text.replace(
"'watch_internal': f\"sudo tcpdump -ni {NAT_BRIDGE} 'host {vm_ip or '<VM_IP>'} and {protocol} port {int(guest_port)}'\",",
"'watch_internal': f\"sudo tcpdump -ni {route_interface_for_ip(vm_ip) if vm_ip else NAT_BRIDGE} 'host {vm_ip or '<VM_IP>'} and {protocol} port {int(guest_port)}'\",",
)
core_path.write_text(text)
print('network bridge forward patch applied:')
for item in changed:
print(f'- {item}')
+43
Просмотреть файл
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
if 'def network_diagnose(' in text:
print('network diagnostics handler already applied')
raise SystemExit(0)
handler = '''
@app.post("/network/diagnose", response_class=HTMLResponse)
def network_diagnose(request: Request, vm_name: str = Form(...), external_port: int = Form(...), guest_port: int = Form(...), protocol: str = Form("tcp")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
diagnostics = None
error = None
try:
diagnostics = network_core.diagnose_public_access(vm_name, external_port, guest_port, protocol)
except NetworkError as exc:
error = str(exc)
return templates.TemplateResponse("network.html", {
"request": request,
"app_name": APP_NAME,
"user": AUTH_USER,
"vms": parse_virsh_list(),
"ctx": network_core.network_context(),
"error": error,
"diagnostics": diagnostics,
}, status_code=400 if error else 200)
'''
marker = '\n\n@app.get("/operations", response_class=HTMLResponse)'
if marker not in text:
raise SystemExit('operations marker not found in app.py')
text = text.replace(marker, handler + marker, 1)
app_path.write_text(text)
print(f'network diagnostics handler applied: {app_path}')
+25
Просмотреть файл
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
template_path = app_path.with_name('templates') / 'network.html'
if not template_path.exists():
raise SystemExit(f'network.html not found: {template_path}')
text = template_path.read_text()
old = '<input type="hidden" name="guest_ip" value="auto">'
new = '''<label>
<span>IP VM</span>
<input type="text" name="guest_ip" value="auto" placeholder="auto или 192.168.100.55" required>
<small>Если IP не определяется автоматически, впиши внутренний IP VM вручную.</small>
</label>'''
if old in text:
text = text.replace(old, new, 1)
template_path.write_text(text)
print('network manual IP field added')
elif 'name="guest_ip" value="auto" placeholder="auto' in text:
print('network manual IP field already present')
else:
raise SystemExit('guest_ip marker not found in network.html')
+126
Просмотреть файл
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helper = r'''
def safe_network_template(request: Request, error: str | None = None, status_code: int = 200, diagnostics: dict[str, Any] | None = None):
try:
ctx = network_core.network_context()
except Exception as exc:
ctx = {
"nat": {
"name": network_core.NETWORK_NAME,
"bridge": network_core.NAT_BRIDGE,
"subnet": network_core.NAT_SUBNET,
"gateway": network_core.NAT_GATEWAY,
"dhcp": f"{network_core.DHCP_START} - {network_core.DHCP_END}",
"exists": False,
"info": str(exc),
"leases": "",
},
"networks": [],
"forwards": [],
"external_interface": "unknown",
"ip_forward": "unknown",
"nft_rules": "",
}
error = error or f"Ошибка чтения сетевого состояния: {exc}"
return templates.TemplateResponse("network.html", {
"request": request,
"app_name": APP_NAME,
"user": AUTH_USER,
"vms": parse_virsh_list(),
"ctx": ctx,
"error": error,
"diagnostics": diagnostics,
}, status_code=status_code)
'''
if 'def safe_network_template(' not in text:
marker = '\n\n@app.get("/network", response_class=HTMLResponse)'
if marker not in text:
raise SystemExit('network route marker not found')
text = text.replace(marker, helper + marker, 1)
changed.append('safe network template helper added')
else:
changed.append('safe network template helper already present')
old_get = '''@app.get("/network", response_class=HTMLResponse)
def network_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": error})
'''
new_get = '''@app.get("/network", response_class=HTMLResponse)
def network_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return safe_network_template(request, error=error)
'''
if old_get in text:
text = text.replace(old_get, new_get, 1)
changed.append('network page now uses safe template')
elif 'return safe_network_template(request, error=error)' in text:
changed.append('network page already safe')
else:
changed.append('network page replacement skipped')
old_nat = '''@app.post("/network/nat/setup")
def network_nat_setup(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
try:
network_core.create_nat_network()
network_core.apply_port_forwards()
except NetworkError as exc:
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc)}, status_code=500)
return RedirectResponse(url="/network", status_code=303)
'''
new_nat = '''@app.post("/network/nat/setup")
def network_nat_setup(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
try:
network_core.create_nat_network()
network_core.apply_port_forwards()
except NetworkError as exc:
return safe_network_template(request, error=str(exc), status_code=500)
except Exception as exc:
return safe_network_template(request, error=f"Внутренняя ошибка настройки NAT: {exc}", status_code=500)
return RedirectResponse(url="/network", status_code=303)
'''
if old_nat in text:
text = text.replace(old_nat, new_nat, 1)
changed.append('NAT setup catches all exceptions')
elif 'Внутренняя ошибка настройки NAT' in text:
changed.append('NAT setup already catches all exceptions')
else:
raise SystemExit('network nat setup route marker not found')
replacements = {
'return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc)}, status_code=400)': 'return safe_network_template(request, error=str(exc), status_code=400)',
'return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc)}, status_code=500)': 'return safe_network_template(request, error=str(exc), status_code=500)',
'return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc), "diagnostics": None}, status_code=400)': 'return safe_network_template(request, error=str(exc), status_code=400)',
'return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": None, "diagnostics": diagnostics})': 'return safe_network_template(request, diagnostics=diagnostics)',
}
for old, new in replacements.items():
if old in text:
text = text.replace(old, new)
changed.append('network error response hardened')
app_path.write_text(text)
print('network NAT error patch applied:')
for item in changed:
print(f'- {item}')
+143
Просмотреть файл
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
changed = []
text = app_path.read_text()
old_signature = 'def network_forward_add(request: Request, vm_name: str = Form(...), guest_ip: str = Form(...), external_port: int = Form(...), guest_port: int = Form(...), protocol: str = Form("tcp"), note: str = Form("")):'
new_signature = 'def network_forward_add(request: Request, vm_name: str = Form(...), guest_ip: str = Form(...), external_port: str = Form(...), guest_port: str = Form(...), protocol: str = Form("tcp"), note: str = Form("")):'
if old_signature in text:
text = text.replace(old_signature, new_signature, 1)
app_path.write_text(text)
changed.append('app.py forward/add accepts port ranges as text')
elif new_signature in text:
changed.append('app.py forward/add already accepts port ranges as text')
else:
raise SystemExit('network_forward_add signature marker not found')
core_path = app_path.with_name('network_core.py')
if core_path.exists():
core_text = core_path.read_text()
before = core_text
if 'def iptables_dnat_port_value(' not in core_text:
marker = """def iptables_port_value(start: int, end: int) -> str:
return str(int(start)) if int(start) == int(end) else f'{int(start)}:{int(end)}'
def port_label"""
replacement = """def iptables_port_value(start: int, end: int) -> str:
return str(int(start)) if int(start) == int(end) else f'{int(start)}:{int(end)}'
def iptables_dnat_port_value(start: int, end: int) -> str:
return str(int(start)) if int(start) == int(end) else f'{int(start)}-{int(end)}'
def successful_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [item for item in results if item.get('ok')]
def failed_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [item for item in results if not item.get('ok')]
def port_label"""
if marker not in core_text:
raise SystemExit('network_core.py port helper marker not found')
core_text = core_text.replace(marker, replacement, 1)
changed.append('network_core.py iptables DNAT range helper added')
else:
changed.append('network_core.py iptables DNAT range helper already present')
core_text = core_text.replace(
'guest_to = f"{guest_ip}:{iptables_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])}"',
'guest_to_port = iptables_dnat_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])\n guest_to = f"{guest_ip}:{guest_to_port}"',
)
core_text = core_text.replace(
'guest_to_port = nft_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])\n guest_to = f"{guest_ip}:{guest_to_port}"',
'guest_to_port = iptables_dnat_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])\n guest_to = f"{guest_ip}:{guest_to_port}"',
)
old_apply = """def apply_port_forwards() -> dict[str, Any]:
ensure_dirs()
enable_ip_forward()
disable_rp_filter()
items = load_port_forwards()
NFT_FILE.write_text(render_nft_rules())
run_cmd(['nft', 'delete', 'table', 'ip', 'virtuality'], timeout=8)
result = run_cmd(['nft', '-f', str(NFT_FILE)], timeout=15)
if not result['ok']:
raise NetworkError(result['stderr'] or result['stdout'] or 'Не удалось применить nftables-правила')
ufw_results = apply_ufw_route_rules(items)
iptables_results = apply_iptables_fallback(items)
return {
'ok': True,
'file': str(NFT_FILE),
'rules': render_nft_rules(),
'ufw': ufw_results,
'iptables': iptables_results,
}
"""
new_apply = """def apply_port_forwards() -> dict[str, Any]:
ensure_dirs()
enable_ip_forward()
disable_rp_filter()
items = load_port_forwards()
NFT_FILE.write_text(render_nft_rules())
nft_delete = run_cmd(['nft', 'delete', 'table', 'ip', 'virtuality'], timeout=8)
nft_apply = run_cmd(['nft', '-f', str(NFT_FILE)], timeout=15)
ufw_results = apply_ufw_route_rules(items)
iptables_results = apply_iptables_fallback(items)
iptables_ok = bool(successful_results(iptables_results)) or not items
if not nft_apply['ok'] and not iptables_ok:
details = [
nft_apply.get('stderr') or nft_apply.get('stdout') or 'nftables не применился',
*[
item.get('stderr') or item.get('stdout') or item.get('cmd', 'iptables rule failed')
for item in failed_results(iptables_results)
],
]
raise NetworkError('Не удалось применить правила проброса: ' + ' | '.join([d for d in details if d]))
return {
'ok': nft_apply['ok'] or iptables_ok,
'file': str(NFT_FILE),
'rules': render_nft_rules(),
'nft_delete': nft_delete,
'nft_apply': nft_apply,
'ufw': ufw_results,
'iptables': iptables_results,
}
"""
if old_apply in core_text:
core_text = core_text.replace(old_apply, new_apply, 1)
changed.append('network_core.py apply_port_forwards now falls back to iptables when nft fails')
elif 'nft_apply = run_cmd([\'nft\', \'-f\', str(NFT_FILE)]' in core_text or "nft_apply = run_cmd(['nft', '-f', str(NFT_FILE)]" in core_text:
changed.append('network_core.py apply_port_forwards fallback already present')
else:
raise SystemExit('network_core.py apply_port_forwards marker not found')
core_text = core_text.replace(
'f"{vm_ip}:{matching_forward[\'guest_port_label\'].replace(\'-\', \':\')}" in ipt_nat_text',
'(f"{vm_ip}:{matching_forward[\'guest_port_label\']}" in ipt_nat_text or f"{vm_ip}:{matching_forward[\'guest_port_label\'].replace(\'-\', \':\')}" in ipt_nat_text)',
)
if core_text != before:
core_path.write_text(core_text)
changed.append('network_core.py port forwarding rules patched')
else:
changed.append('network_core.py port forwarding rules already ok')
else:
changed.append(f'network_core.py not found near {app_path}')
print('network ranges patch applied:')
for item in changed:
print(f'- {item}')
+53
Просмотреть файл
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
app_dir = app_path.resolve().parent if app_path.exists() else Path('/opt/virtuality/web')
template_path = app_dir / 'templates' / 'vm_detail.html'
changed = []
if not template_path.exists():
print(f'legacy boot order cleanup skipped: vm_detail.html not found: {template_path}')
raise SystemExit(0)
tpl = template_path.read_text()
original = tpl
legacy_pattern = re.compile(
r'\n\s*<section class="card">\s*\n'
r'\s*<div class="card-head">\s*\n'
r'\s*<h2>Порядок загрузки</h2>\s*\n'
r'\s*<span class="pill">boot order</span>\s*\n'
r'.*?'
r'\s*</section>\s*\n',
re.DOTALL,
)
tpl, count = legacy_pattern.subn('\n', tpl)
if count:
changed.append(f'legacy select boot-order card removed: {count}')
# Safety cleanup for any accidental duplicated plain select card without the old pill text.
plain_select_pattern = re.compile(
r'\n\s*<section class="card">\s*\n'
r'\s*<div class="card-head">\s*\n'
r'\s*<h2>Порядок загрузки</h2>.*?'
r'<select name="boot_order" required>.*?'
r'\s*</section>\s*\n',
re.DOTALL,
)
tpl, count2 = plain_select_pattern.subn('\n', tpl)
if count2:
changed.append(f'legacy plain select boot-order card removed: {count2}')
if tpl != original:
template_path.write_text(tpl)
else:
changed.append('legacy boot-order select card not found')
print('legacy boot order cleanup patch applied:')
for item in changed:
print(f'- {item}')
+60
Просмотреть файл
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
templates_dir = app_path.parent / 'templates'
if not templates_dir.exists():
raise SystemExit(f'templates dir not found: {templates_dir}')
skip = {'login.html', 'console.html', '_sidebar.html'}
changed = []
skipped = []
for path in sorted(templates_dir.glob('*.html')):
if path.name in skip:
skipped.append(f'{path.name}: skipped system/template')
continue
text = path.read_text()
if '{% include "_sidebar.html" %}' in text or "{% include '_sidebar.html' %}" in text:
skipped.append(f'{path.name}: already has sidebar')
continue
if '<div class="v-layout">' in text:
skipped.append(f'{path.name}: already v-layout')
continue
if '<div class="shell">' not in text:
skipped.append(f'{path.name}: no shell')
continue
text = text.replace(
'<body>\n <div class="shell">',
'<body>\n <div class="v-layout">\n {% include "_sidebar.html" %}\n <main class="v-main">\n <div class="shell v-shell-embedded">',
1,
)
# Close shell/main/layout before scripts when page has JS block.
marker = '\n\n <script>'
if marker in text:
idx = text.rfind('\n </div>', 0, text.find(marker))
if idx != -1:
text = text[:idx] + '\n </div>\n </main>\n </div>' + text[idx + len('\n </div>'):]
else:
skipped.append(f'{path.name}: close marker before script not found')
continue
else:
marker2 = '\n</body>'
idx = text.rfind('\n </div>', 0, text.find(marker2) if marker2 in text else len(text))
if idx != -1:
text = text[:idx] + '\n </div>\n </main>\n </div>' + text[idx + len('\n </div>'):]
else:
skipped.append(f'{path.name}: close marker before body not found')
continue
path.write_text(text)
changed.append(path.name)
print('sidebar layout patch applied:')
for name in changed:
print(f'- {name}: sidebar wrapped')
for item in skipped:
print(f'- {item}')
+56
Просмотреть файл
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
if 'import update_core' not in text:
text = text.replace('import network_core\n', 'import network_core\nimport update_core\n', 1)
changed.append('import update_core added')
helper = r'''
def dashboard_update_notice() -> dict[str, Any]:
try:
info = update_core.check_updates(fetch=False)
if info.get("has_update"):
return {
"has_update": True,
"current_version": info.get("current_version", "unknown"),
"latest_version": info.get("latest_version", "unknown"),
"missing_count": len(info.get("missing_versions", [])),
"commit_count": len(info.get("commits", [])),
}
except Exception:
pass
return {"has_update": False}
'''
if 'def dashboard_update_notice() -> dict[str, Any]:' not in text:
marker = '\n\ndef update_operation(operation: dict[str, Any], **changes: Any) -> None:'
if marker not in text:
raise SystemExit('update_operation marker not found')
text = text.replace(marker, helper + marker, 1)
changed.append('dashboard update notice helper added')
else:
changed.append('dashboard update notice helper already present')
old_fragment = '"operations": list_operations(5), "operation_css": operation_css})'
new_fragment = '"operations": list_operations(5), "operation_css": operation_css, "update_notice": dashboard_update_notice()})'
if old_fragment in text:
text = text.replace(old_fragment, new_fragment, 1)
changed.append('dashboard context gets update_notice')
elif '"update_notice": dashboard_update_notice()' in text:
changed.append('dashboard context already has update_notice')
else:
raise SystemExit('dashboard context marker not found')
app_path.write_text(text)
print('update badge patch applied:')
for item in changed:
print(f'- {item}')
+127
Просмотреть файл
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
if 'import update_core' not in text:
if 'import network_core\n' in text:
text = text.replace('import network_core\n', 'import network_core\nimport update_core\n', 1)
elif 'from network_core import NetworkError\n' in text:
text = text.replace('from network_core import NetworkError\n', 'from network_core import NetworkError\nimport update_core\n', 1)
else:
raise SystemExit('network_core import marker not found')
changed.append('import update_core added')
else:
changed.append('import update_core already present')
routes = r'''
@app.get("/update", response_class=HTMLResponse)
def update_page(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
error = None
try:
info = update_core.check_updates(fetch=False)
except Exception as exc:
error = str(exc)
info = {
"ok": False,
"source_dir": str(update_core.SOURCE_DIR),
"remote": update_core.REMOTE,
"branch": update_core.DEFAULT_BRANCH,
"fetch_ok": False,
"fetch_error": str(exc),
"current_commit": "",
"latest_commit": "",
"current_version": "unknown",
"latest_version": "unknown",
"has_update": False,
"missing_versions": [],
"commits": [],
"checked_at": utc_now(),
"state": update_core.state(),
"log_tail": update_core.update_log_tail(),
}
return templates.TemplateResponse("update.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "info": info, "error": error})
@app.get("/update/status")
def update_status(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return JSONResponse({"ok": False, "error": "auth required"}, status_code=401)
return JSONResponse({
"ok": True,
"state": update_core.state(),
"log_tail": update_core.update_log_tail(260),
"checked_at": utc_now(),
})
@app.post("/update/check")
def update_check(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
try:
update_core.check_updates(fetch=True)
except Exception:
pass
return RedirectResponse(url="/update", status_code=303)
@app.post("/update/apply")
def update_apply(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
try:
update_core.start_update()
except Exception:
pass
return RedirectResponse(url="/update", status_code=303)
'''
if '@app.get("/update"' not in text:
marker = '\n\n@app.get("/operations", response_class=HTMLResponse)'
if marker not in text:
raise SystemExit('operations route marker not found')
text = text.replace(marker, routes + marker, 1)
changed.append('update routes added')
else:
changed.append('update route already present')
if '@app.get("/update/status"' not in text:
marker = '\n\n@app.post("/update/check")'
if marker not in text:
raise SystemExit('update/check route marker not found')
status_route = r'''
@app.get("/update/status")
def update_status(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return JSONResponse({"ok": False, "error": "auth required"}, status_code=401)
return JSONResponse({
"ok": True,
"state": update_core.state(),
"log_tail": update_core.update_log_tail(260),
"checked_at": utc_now(),
})
'''
text = text.replace(marker, status_route + marker, 1)
changed.append('update status route added')
else:
changed.append('update status route already present')
app_path.write_text(text)
print('update center patch applied:')
for item in changed:
print(f'- {item}')
+96
Просмотреть файл
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
safe_name_helper = r'''
def safe_upload_filename(filename: str, allowed_suffixes: tuple[str, ...], fallback_prefix: str) -> str | None:
original = Path(filename or "").name.strip()
suffix = Path(original).suffix.lower()
if suffix not in allowed_suffixes:
return None
stem = Path(original).stem.strip()
stem = re.sub(r"\s+", "-", stem)
stem = re.sub(r"[^a-zA-Z0-9_.-]", "_", stem)
stem = stem.strip("._-")
if not stem:
stem = fallback_prefix
stem = stem[:120]
name = f"{stem}{suffix}"
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,180}\.[a-zA-Z0-9]{2,8}", name):
name = f"{fallback_prefix}{suffix}"
return name
'''
if 'def safe_upload_filename(' not in text:
marker = '\n\ndef safe_iso_filename(filename: str) -> str | None:'
if marker not in text:
raise SystemExit('safe_iso_filename marker not found')
text = text.replace(marker, safe_name_helper + marker, 1)
changed.append('safe upload filename helper added')
else:
changed.append('safe upload filename helper already present')
old_iso = r'''def safe_iso_filename(filename: str) -> str | None:
name = Path(filename or "").name.strip().replace(" ", "-")
name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name)
if not name.lower().endswith(".iso"):
return None
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.iso", name):
return None
return name
'''
new_iso = r'''def safe_iso_filename(filename: str) -> str | None:
return safe_upload_filename(filename, (".iso",), "virtuality-iso")
'''
if old_iso in text:
text = text.replace(old_iso, new_iso, 1)
changed.append('ISO filename sanitizer relaxed')
elif 'return safe_upload_filename(filename, (".iso",), "virtuality-iso")' in text:
changed.append('ISO filename sanitizer already relaxed')
else:
changed.append('ISO filename sanitizer replacement skipped')
old_disk = r'''def safe_disk_image_filename(filename: str) -> str | None:
name = Path(filename or "").name.strip().replace(" ", "-")
name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name)
if not name.lower().endswith((".img", ".raw", ".qcow2")):
return None
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.(img|raw|qcow2)", name, re.IGNORECASE):
return None
return name
'''
new_disk = r'''def safe_disk_image_filename(filename: str) -> str | None:
return safe_upload_filename(filename, (".img", ".raw", ".qcow2"), "virtuality-disk")
'''
if old_disk in text:
text = text.replace(old_disk, new_disk, 1)
changed.append('disk image filename sanitizer relaxed')
elif 'return safe_upload_filename(filename, (".img", ".raw", ".qcow2"), "virtuality-disk")' in text:
changed.append('disk image filename sanitizer already relaxed')
else:
changed.append('disk image filename sanitizer not found yet')
# Make upload errors more explicit when temp/filesystem fails after multipart parsing.
text = text.replace(
'"error": f"Ошибка загрузки ISO: {exc}"}, status_code=500)',
'"error": f"Ошибка загрузки ISO: {exc}. Проверь свободное место, права на /var/lib/virtuality/iso и временный каталог /var/lib/virtuality/tmp."}, status_code=500)'
)
text = text.replace(
'"error": f"Ошибка загрузки образа: {exc}"}, status_code=500)',
'"error": f"Ошибка загрузки образа: {exc}. Проверь свободное место, права на /var/lib/virtuality/disk-images и временный каталог /var/lib/virtuality/tmp."}, status_code=500)'
)
changed.append('upload error messages clarified')
app_path.write_text(text)
print('upload compatibility patch applied:')
for item in changed:
print(f'- {item}')
+73
Просмотреть файл
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_dir = Path(sys.argv[1]).resolve().parent if len(sys.argv) > 1 else Path('/opt/virtuality/web')
templates_dir = app_dir / 'templates'
changed = []
guard_script = r'''
function setUploadNavigationGuard(active) {
window.virtualityUploadActive = Boolean(active);
document.body.classList.toggle('upload-locked', window.virtualityUploadActive);
}
window.addEventListener('beforeunload', function (event) {
if (!window.virtualityUploadActive) return;
event.preventDefault();
event.returnValue = 'Идёт загрузка файла. Переход остановит передачу.';
return event.returnValue;
});
document.addEventListener('click', function (event) {
if (!window.virtualityUploadActive) return;
const target = event.target.closest('a, button, input[type="submit"]');
if (!target) return;
if (target.id === 'iso-upload-button' || target.id === 'disk-upload-button') return;
event.preventDefault();
event.stopPropagation();
alert('Идёт загрузка файла. Дождись завершения или нажми «Отменить загрузку». Переход по меню сейчас заблокирован, чтобы файл не оборвался.');
}, true);
document.addEventListener('submit', function (event) {
if (!window.virtualityUploadActive) return;
if (event.target && (event.target.id === 'iso-upload-form' || event.target.id === 'disk-upload-form')) return;
event.preventDefault();
event.stopPropagation();
alert('Идёт загрузка файла. Дождись завершения или нажми «Отменить загрузку».');
}, true);
'''
def patch_template(path: Path, active_token: str, form_id: str) -> None:
if not path.exists():
return
text = path.read_text()
original = text
if 'function setUploadNavigationGuard(active)' not in text:
marker = ' function bytesText(bytes) {'
if marker not in text:
raise SystemExit(f'bytesText marker not found in {path}')
text = text.replace(marker, guard_script + '\n' + marker, 1)
text = text.replace(' uploadActive = active;\n', ' uploadActive = active;\n setUploadNavigationGuard(active);\n')
# During server-side post-processing after upload, file transfer is done, so page navigation is safe again.
text = text.replace(" window.location.href = '/iso';", " setUploadNavigationGuard(false);\n window.location.href = '/iso';")
text = text.replace(" window.location.href = '/disk-images';", " setUploadNavigationGuard(false);\n window.location.href = '/disk-images';")
# Conversion polling is not an active browser upload anymore; allow navigation while backend operation continues.
text = text.replace(" setUploadMode(false);\n fileInput.disabled = true;", " setUploadMode(false);\n setUploadNavigationGuard(false);\n fileInput.disabled = true;")
if text != original:
path.write_text(text)
changed.append(str(path))
patch_template(templates_dir / 'iso.html', 'iso-upload-button', 'iso-upload-form')
patch_template(templates_dir / 'disk_images.html', 'disk-upload-button', 'disk-upload-form')
print('upload navigation guard patch applied:')
for item in changed or ['already applied']:
print(f'- {item}')
+147
Просмотреть файл
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helpers = r'''
def vm_arch_options(profile: dict[str, Any]) -> list[dict[str, str]]:
host_arch = str(profile.get("arch", ""))
options = [
{"value": "auto", "label": "Авто — рекомендовано для этого хоста"},
{"value": "x86_64", "label": "x86_64 / amd64 — обычные ПК и серверы"},
]
if host_arch in ("aarch64", "arm64"):
options.append({"value": "aarch64", "label": "ARM64 / aarch64 — KVM на ARM-хосте"})
else:
options.append({"value": "aarch64", "label": "ARM64 / aarch64 — QEMU-эмуляция, медленно"})
options.append({"value": "generic", "label": "Generic QEMU — экспериментально"})
return options
def resolve_vm_arch(arch_choice: str, profile: dict[str, Any]) -> str:
if arch_choice == "auto" or not arch_choice:
return str(profile.get("recommended_guest_arch") or "x86_64")
if arch_choice in ("x86_64", "aarch64", "generic"):
return arch_choice
return str(profile.get("recommended_guest_arch") or "x86_64")
def append_arch_args(cmd: list[str], guest_arch: str, profile: dict[str, Any]) -> list[str]:
host_arch = str(profile.get("arch", ""))
if guest_arch == "aarch64":
if host_arch in ("aarch64", "arm64"):
return cmd + ["--arch", "aarch64", "--machine", "virt", "--cpu", "host", "--virt-type", "kvm", "--boot", "uefi"]
return cmd + ["--arch", "aarch64", "--machine", "virt", "--cpu", "cortex-a57", "--virt-type", "qemu", "--boot", "uefi"]
if guest_arch == "x86_64":
if host_arch in ("x86_64", "amd64"):
return cmd + ["--arch", "x86_64"]
return cmd + ["--arch", "x86_64", "--virt-type", "qemu"]
return cmd
'''
start = text.find('def vm_arch_options(profile: dict[str, Any])')
if start != -1:
end = text.find('\n\ndef vm_form_context(', start)
if end == -1:
raise SystemExit('vm_form_context end marker not found')
text = text[:start] + helpers.strip() + text[end:]
changed.append('architecture helpers replaced')
else:
marker = '\n\ndef vm_form_context('
if marker not in text:
raise SystemExit('vm_form_context marker not found')
text = text.replace(marker, helpers + marker, 1)
changed.append('architecture helpers added')
old_context = '"disk_images": list_disk_image_files(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
new_context = '"disk_images": list_disk_image_files(), "arch_options": vm_arch_options(profile), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "guest_arch": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
if old_context in text:
text = text.replace(old_context, new_context, 1)
changed.append('vm form context gets arch options')
elif '"arch_options": vm_arch_options(profile)' in text:
changed.append('vm form context already has arch options')
else:
old_context2 = '"isos": list_iso_files(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
new_context2 = '"isos": list_iso_files(), "disk_images": list_disk_image_files() if "list_disk_image_files" in globals() else [], "arch_options": vm_arch_options(profile), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "guest_arch": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
if old_context2 in text:
text = text.replace(old_context2, new_context2, 1)
changed.append('vm form context gets arch options fallback')
else:
raise SystemExit('vm_form_context payload marker not found')
old_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
new_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
if old_sig in text:
text = text.replace(old_sig, new_sig, 1)
changed.append('vm create signature gets guest_arch')
elif new_sig in text:
changed.append('vm create signature already has guest_arch')
else:
old_sig2 = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(...), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
new_sig2 = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(...), guest_arch: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
if old_sig2 in text:
text = text.replace(old_sig2, new_sig2, 1)
changed.append('vm create signature gets guest_arch fallback')
else:
raise SystemExit('vm_create_submit signature marker not found')
old_form = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "network_mode": network_mode, "bridge": bridge}'
new_form = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
if old_form in text:
text = text.replace(old_form, new_form, 1)
changed.append('form state stores guest_arch')
elif '"guest_arch": guest_arch' in text:
changed.append('form state already stores guest_arch')
else:
old_form2 = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "network_mode": network_mode, "bridge": bridge}'
new_form2 = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
if old_form2 in text:
text = text.replace(old_form2, new_form2, 1)
changed.append('form state stores guest_arch fallback')
old_validation = 'elif source_type not in ("iso", "disk_image"):\n error = "Некорректный источник VM."'
new_validation = 'elif source_type not in ("iso", "disk_image"):\n error = "Некорректный источник VM."\n elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):\n error = "Некорректная архитектура VM."'
if old_validation in text and 'Некорректная архитектура VM' not in text:
text = text.replace(old_validation, new_validation, 1)
changed.append('guest_arch validation added')
elif 'Некорректная архитектура VM' in text:
changed.append('guest_arch validation already present')
old_arch_block = ''' profile = host_profile.load_host_profile()
is_arm = profile.get("recommended_guest_arch") == "aarch64"
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus)]
if is_arm:
cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host", "--virt-type", "kvm", "--boot", "uefi"]
'''
new_arch_block = ''' profile = host_profile.load_host_profile()
resolved_guest_arch = resolve_vm_arch(guest_arch, profile)
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus)]
cmd = append_arch_args(cmd, resolved_guest_arch, profile)
'''
if old_arch_block in text:
text = text.replace(old_arch_block, new_arch_block, 1)
changed.append('virt-install arch args made selectable')
elif 'cmd = append_arch_args(cmd, resolved_guest_arch)' in text:
text = text.replace('cmd = append_arch_args(cmd, resolved_guest_arch)', 'cmd = append_arch_args(cmd, resolved_guest_arch, profile)')
changed.append('virt-install arch args now use host profile')
elif 'cmd = append_arch_args(cmd, resolved_guest_arch, profile)' in text:
changed.append('virt-install arch args already use host profile')
else:
raise SystemExit('arch command block marker not found')
text = text.replace('"guest_arch": profile.get("recommended_guest_arch"),', '"guest_arch": resolved_guest_arch, "guest_arch_choice": guest_arch,')
text = text.replace('"guest_arch": profile.get("recommended_guest_arch")', '"guest_arch": resolved_guest_arch, "guest_arch_choice": guest_arch')
app_path.write_text(text)
print('vm architecture patch applied:')
for item in changed:
print(f'- {item}')
+67
Просмотреть файл
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helper = r'''
def vm_autostart_status(name: str) -> dict[str, str | bool]:
result = run_cmd(["virsh", "dominfo", name], timeout=8)
output = result.get("stdout", "") or ""
match = re.search(r"^Autostart:\s*(.+)$", output, re.MULTILINE | re.IGNORECASE)
raw = match.group(1).strip() if match else "unknown"
enabled = raw.lower() in ("enable", "enabled", "yes", "on")
label = "enabled" if enabled else "disabled" if raw != "unknown" else "unknown"
css = "ok" if enabled else "warn"
return {"enabled": enabled, "label": label, "css": css, "raw": raw}
'''
if 'def vm_autostart_status(' not in text:
marker = '\n\ndef parse_virsh_list() -> list[dict[str, str]]:'
if marker not in text:
raise SystemExit('parse_virsh_list marker not found')
text = text.replace(marker, helper + marker, 1)
changed.append('vm_autostart_status helper added')
else:
changed.append('vm_autostart_status helper already present')
old_rows = ''' if len(parts) == 3:\n rows.append({"id": parts[0], "name": parts[1], "state": parts[2]})\n elif len(parts) == 2:\n rows.append({"id": "-", "name": parts[0], "state": parts[1]})'''
new_rows = ''' if len(parts) == 3:\n autostart = vm_autostart_status(parts[1])\n rows.append({"id": parts[0], "name": parts[1], "state": parts[2], "autostart_enabled": autostart["enabled"], "autostart_label": autostart["label"], "autostart_css": autostart["css"]})\n elif len(parts) == 2:\n autostart = vm_autostart_status(parts[0])\n rows.append({"id": "-", "name": parts[0], "state": parts[1], "autostart_enabled": autostart["enabled"], "autostart_label": autostart["label"], "autostart_css": autostart["css"]})'''
if old_rows in text:
text = text.replace(old_rows, new_rows, 1)
changed.append('parse_virsh_list enriched with autostart')
elif 'autostart_enabled' in text and 'autostart_label' in text:
changed.append('parse_virsh_list already has autostart')
else:
raise SystemExit('parse_virsh_list row marker not found')
old_details = '''def vm_details(name: str) -> dict[str, Any]:\n return {\n "name": name,\n "dominfo": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"],\n "vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available",\n "ip": vm_ip(name),\n "disks": run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)["stdout"],\n "interfaces": run_cmd(["virsh", "domiflist", name], timeout=10)["stdout"],\n "autostart": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"],\n }'''
new_details = '''def vm_details(name: str) -> dict[str, Any]:\n dominfo = run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"]\n autostart = vm_autostart_status(name)\n return {\n "name": name,\n "dominfo": dominfo,\n "vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available",\n "ip": vm_ip(name),\n "disks": run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)["stdout"],\n "interfaces": run_cmd(["virsh", "domiflist", name], timeout=10)["stdout"],\n "autostart": dominfo,\n "autostart_enabled": autostart["enabled"],\n "autostart_label": autostart["label"],\n "autostart_css": autostart["css"],\n }'''
if old_details in text:
text = text.replace(old_details, new_details, 1)
changed.append('vm_details enriched with autostart')
elif '"autostart_enabled": autostart["enabled"]' in text:
changed.append('vm_details already has autostart')
else:
raise SystemExit('vm_details marker not found')
old_live = ''' vms.append({"id": vm.get("id", "-"), "name": name, "state": state, "state_css": css, "ip": ip if ip and ip != "not available" else "—"})'''
new_live = ''' vms.append({"id": vm.get("id", "-"), "name": name, "state": state, "state_css": css, "ip": ip if ip and ip != "not available" else "—", "autostart_enabled": vm.get("autostart_enabled", False), "autostart_label": vm.get("autostart_label", "unknown"), "autostart_css": vm.get("autostart_css", "warn")})'''
if old_live in text:
text = text.replace(old_live, new_live, 1)
changed.append('live status enriched with autostart')
elif '"autostart_label": vm.get("autostart_label", "unknown")' in text:
changed.append('live status already has autostart')
else:
changed.append('live status marker not found, skipped')
app_path.write_text(text)
print('vm autostart patch applied:')
for item in changed:
print(f'- {item}')
+214
Просмотреть файл
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
app_dir = app_path.resolve().parent
template_path = app_dir / 'templates' / 'vm_create.html'
changed = []
warnings = []
text = app_path.read_text()
helpers = r'''
def vm_boot_order_options() -> list[dict[str, str]]:
return [
{"value": "auto", "label": "Auto — по источнику VM"},
{"value": "disk", "label": "Сначала диск"},
{"value": "cdrom_disk", "label": "Сначала ISO/CD-ROM, потом диск"},
{"value": "disk_cdrom", "label": "Сначала диск, потом ISO/CD-ROM"},
{"value": "network_disk", "label": "Сначала сеть/PXE, потом диск"},
]
def normalize_boot_order(value: str, source_type: str) -> str:
value = (value or "auto").strip()
if value == "auto":
return "cdrom_disk" if source_type == "iso" else "disk"
if value in ("disk", "cdrom_disk", "disk_cdrom", "network_disk"):
return value
return "cdrom_disk" if source_type == "iso" else "disk"
def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
mapping = {
"disk": "hd",
"cdrom_disk": "cdrom,hd",
"disk_cdrom": "hd,cdrom",
"network_disk": "network,hd",
}
value = mapping.get(boot_order, "hd")
if is_arm:
return "uefi," + value
return value
'''
def replace_once(source: str, old: str, new: str, label: str) -> str:
if old in source:
changed.append(label)
return source.replace(old, new, 1)
warnings.append(f'{label}: marker not found, skipped')
return source
if 'def vm_boot_order_options()' not in text:
marker = '\n\ndef vm_form_context('
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('boot order helpers added')
else:
warnings.append('vm_form_context marker not found, helpers skipped')
else:
changed.append('boot order helpers already present')
if '"boot_options": vm_boot_order_options()' not in text:
if '"arch_options": vm_arch_options(),' in text:
text = text.replace('"arch_options": vm_arch_options(),', '"arch_options": vm_arch_options(), "boot_options": vm_boot_order_options(),', 1)
changed.append('vm form context gets boot options')
else:
warnings.append('arch_options marker not found, boot_options skipped')
else:
changed.append('vm form context already has boot options')
if '"boot_order": "auto"' not in text:
if '"guest_arch": "auto", "network_mode"' in text:
text = text.replace('"guest_arch": "auto", "network_mode"', '"guest_arch": "auto", "boot_order": "auto", "network_mode"', 1)
changed.append('default form boot_order added')
elif '"guest_arch": "auto"' in text:
text = text.replace('"guest_arch": "auto"', '"guest_arch": "auto", "boot_order": "auto"', 1)
changed.append('default form boot_order added near guest_arch')
else:
warnings.append('default form marker not found, boot_order default skipped')
else:
changed.append('default form boot_order already present')
if 'boot_order: str = Form("auto")' not in text:
signature_patterns = [
('guest_arch: str = Form("auto"), network_mode:', 'guest_arch: str = Form("auto"), boot_order: str = Form("auto"), network_mode:'),
('guest_arch: str = Form("auto"),\n network_mode:', 'guest_arch: str = Form("auto"),\n boot_order: str = Form("auto"),\n network_mode:'),
]
for old, new in signature_patterns:
if old in text:
text = text.replace(old, new, 1)
changed.append('vm create signature supports boot order')
break
else:
warnings.append('vm create signature marker not found, skipped')
else:
changed.append('vm create signature already supports boot order')
if '"boot_order": boot_order' not in text:
if '"guest_arch": guest_arch, "network_mode"' in text:
text = text.replace('"guest_arch": guest_arch, "network_mode"', '"guest_arch": guest_arch, "boot_order": boot_order, "network_mode"', 1)
changed.append('vm create form state supports boot order')
elif '"guest_arch": guest_arch' in text:
text = text.replace('"guest_arch": guest_arch', '"guest_arch": guest_arch, "boot_order": boot_order', 1)
changed.append('vm create form state supports boot order near guest_arch')
else:
warnings.append('vm create form state marker not found, skipped')
else:
changed.append('vm create form state already supports boot order')
if 'boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk")' not in text:
validation_old = 'elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):\n error = "Некорректная архитектура VM."'
validation_new = validation_old + '\n elif boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk"):\n error = "Некорректный порядок загрузки VM."'
if validation_old in text:
text = text.replace(validation_old, validation_new, 1)
changed.append('vm create validation supports boot order')
else:
warnings.append('vm create validation marker not found, skipped')
else:
changed.append('vm create validation already supports boot order')
if 'selected_boot_order = normalize_boot_order(boot_order, source_type)' not in text:
if 'selected_arch = normalize_guest_arch(guest_arch, profile)\n' in text:
text = text.replace('selected_arch = normalize_guest_arch(guest_arch, profile)\n', 'selected_arch = normalize_guest_arch(guest_arch, profile)\n selected_boot_order = normalize_boot_order(boot_order, source_type)\n', 1)
changed.append('selected boot order added')
else:
warnings.append('selected_arch marker not found, selected boot order skipped')
else:
changed.append('selected boot order already present')
# Replace legacy ARM-specific --boot uefi in architecture block with unified --boot argument if present.
if ', "--boot", "uefi"]' in text:
text = text.replace(', "--boot", "uefi"]', ']')
changed.append('legacy ARM --boot uefi removed')
if 'cmd += ["--boot", virt_boot_arg(selected_boot_order, is_arm)]' not in text:
marker = ' if source_type == "disk_image":\n'
if marker in text:
text = text.replace(marker, ' cmd += ["--boot", virt_boot_arg(selected_boot_order, is_arm)]\n\n' + marker, 1)
changed.append('virt-install boot argument added')
else:
warnings.append('source_type command marker not found, boot argument skipped')
else:
changed.append('virt-install boot argument already present')
if '"boot_order": selected_boot_order' not in text:
if '"guest_arch": selected_arch, "host_profile"' in text:
text = text.replace('"guest_arch": selected_arch, "host_profile"', '"guest_arch": selected_arch, "boot_order": selected_boot_order, "host_profile"', 1)
changed.append('operation metadata gets boot order')
elif '"guest_arch": selected_arch' in text:
text = text.replace('"guest_arch": selected_arch', '"guest_arch": selected_arch, "boot_order": selected_boot_order', 1)
changed.append('operation metadata gets boot order near guest_arch')
else:
warnings.append('operation metadata marker not found, skipped')
else:
changed.append('operation metadata already has boot order')
app_path.write_text(text)
if template_path.exists():
tpl = template_path.read_text()
original = tpl
if 'name="boot_order"' not in tpl:
insert = r'''
<label>
<span>Порядок загрузки</span>
<select name="boot_order" required>
{% for boot in boot_options %}
<option value="{{ boot.value }}" {% if form.boot_order == boot.value %}selected{% endif %}>{{ boot.label }}</option>
{% endfor %}
</select>
</label>
'''
markers = [
' <label>\n <span>Режим сети</span>',
' <label>\n <span>Режим сети</span>',
]
for marker in markers:
if marker in tpl:
tpl = tpl.replace(marker, insert + '\n' + marker, 1)
changed.append('boot order field added to vm_create.html')
break
else:
warnings.append('network mode marker not found in vm_create.html, boot field skipped')
else:
changed.append('boot order field already present in vm_create.html')
tpl = tpl.replace(
'Virtuality импортирует .img/.raw/.qcow2 в новый qcow2-диск VM. Поле “Диск, GB” не используется.',
'Virtuality импортирует .img/.raw/.qcow2 в новый qcow2-диск VM. Поле “Диск, GB” не используется. Для готовых дисков обычно выбирай порядок загрузки “Сначала диск”.',
)
tpl = tpl.replace(
'Virtuality создаст новый диск указанного размера и запустит установку с ISO.',
'Virtuality создаст новый диск указанного размера и запустит установку с ISO. Для установщика обычно выбирай “Сначала ISO/CD-ROM, потом диск”.',
)
if tpl != original:
template_path.write_text(tpl)
else:
warnings.append(f'vm_create.html not found: {template_path}')
print('VM boot order patch applied:')
for item in changed:
print(f'- {item}')
if warnings:
print('Warnings:')
for item in warnings:
print(f'- {item}')
+161
Просмотреть файл
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
app_dir = app_path.resolve().parent if app_path.exists() else Path('/opt/virtuality/web')
template_path = app_dir / 'templates' / 'vm_detail.html'
css_path = app_dir / 'static' / 'app.css'
changed = []
if not template_path.exists():
print(f'VM detail resource layout patch skipped: vm_detail.html not found: {template_path}')
raise SystemExit(0)
tpl = template_path.read_text()
original = tpl
resource_messages = r'''
{% if resource_message %}
<div class="alert success">{{ resource_message }}</div>
{% endif %}
{% if resource_error %}
<div class="alert danger">{{ resource_error }}</div>
{% endif %}
'''
resource_card = r'''
<article class="card">
<div class="card-head">
<h2>CPU / RAM / Архитектура</h2>
<span class="pill">resources</span>
</div>
<div class="resource-mini-grid">
<div><span>CPU</span><b>{{ resource_settings.vcpus }}</b></div>
<div><span>RAM</span><b>{{ resource_settings.memory_mb }} MB</b></div>
<div><span>Архитектура</span><b>{{ resource_settings.arch }}</b></div>
</div>
<form method="post" action="/vm/{{ vm.name }}/resources" class="form-grid">
<label>
<span>CPU, vCPU</span>
<input type="number" name="vcpus" min="1" max="128" value="{{ resource_settings.vcpus }}" required {% if not resource_settings.is_shutoff %}disabled{% endif %}>
</label>
<label>
<span>RAM, MB</span>
<input type="number" name="memory_mb" min="512" max="262144" step="128" value="{{ resource_settings.memory_mb }}" required {% if not resource_settings.is_shutoff %}disabled{% endif %}>
</label>
<label>
<span>Архитектура</span>
<select name="guest_arch" {% if not resource_settings.is_shutoff %}disabled{% endif %}>
<option value="keep" selected>Не менять — {{ resource_settings.arch }}</option>
<option value="x86_64">x86_64 / amd64</option>
<option value="aarch64">ARM64 / aarch64</option>
</select>
</label>
<button class="primary wide" type="submit" {% if not resource_settings.is_shutoff %}disabled{% endif %}>Применить ресурсы</button>
</form>
{% if not resource_settings.is_shutoff %}
<div class="alert danger">VM сейчас не выключена. CPU, RAM и архитектуру можно менять только после Shutdown/Power off.</div>
{% endif %}
<p class="muted small-note">CPU/RAM меняются в XML VM. Смена архитектуры требует совместимый диск и загрузчик; x86-диск не станет ARM-диском по щелчку, увы, физика всё ещё душнит.</p>
</article>
'''
if 'resource_message' not in tpl:
marker = ' {% if iso_message %}'
if marker in tpl:
tpl = tpl.replace(marker, resource_messages + '\n' + marker, 1)
changed.append('resource messages added before ISO messages')
else:
# Safe fallback: place messages after header/topbar area if exact ISO marker is not present.
marker = ' <section'
index = tpl.find(marker)
if index != -1:
tpl = tpl[:index] + resource_messages + '\n' + tpl[index:]
changed.append('resource messages added by fallback')
else:
changed.append('resource messages skipped: no safe marker')
if 'CPU / RAM / Архитектура' not in tpl:
candidate_markers = [
' <section class="grid three vm-resource-boot-iso-grid">',
' <section class="grid two vm-boot-iso-grid">',
' <section class="grid two">',
' <section class="card">',
]
inserted = False
for marker in candidate_markers:
pos = tpl.find(marker)
if pos == -1:
continue
if 'vm-boot-iso-grid' in marker:
tpl = tpl.replace(marker, ' <section class="grid three vm-resource-boot-iso-grid">', 1)
grid_pos = tpl.find('vm-resource-boot-iso-grid')
insert_at = tpl.find('\n <article class="card">', grid_pos)
if insert_at != -1:
tpl = tpl[:insert_at] + '\n' + resource_card + tpl[insert_at:]
else:
close_at = tpl.find('</section>', grid_pos)
if close_at != -1:
tpl = tpl[:close_at] + resource_card + tpl[close_at:]
changed.append('resource card inserted into existing settings grid')
inserted = True
break
elif marker == ' <section class="grid two">':
settings = '\n\n <section class="grid three vm-resource-boot-iso-grid">\n' + resource_card + ' </section>\n'
tpl = tpl.replace('\n\n' + marker, settings + '\n\n' + marker, 1)
changed.append('resource grid inserted before first two-column grid')
inserted = True
break
else:
settings = '\n\n <section class="grid three vm-resource-boot-iso-grid">\n' + resource_card + ' </section>\n'
tpl = tpl[:pos] + settings + tpl[pos:]
changed.append('resource grid inserted before first card fallback')
inserted = True
break
if not inserted:
changed.append('resource card skipped: no safe layout marker')
else:
changed.append('resource card already present')
# Upgrade older two-column settings grid when present.
tpl = tpl.replace('class="grid two vm-boot-iso-grid"', 'class="grid three vm-resource-boot-iso-grid"')
if tpl != original:
template_path.write_text(tpl)
if css_path.exists():
css = css_path.read_text()
original_css = css
if '.three {' not in css:
if '.two {' in css:
css = css.replace('.two { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 12px; }', '.two { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 12px; }\n.three { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-bottom: 12px; }')
if '.three {' not in css:
css += '\n.three { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }\n'
else:
css += '\n.three { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }\n'
changed.append('three-column grid CSS added')
if '.resource-mini-grid' not in css:
css += '''
.resource-mini-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; margin-bottom: 10px; }
.resource-mini-grid div { display: grid; gap: 3px; padding: 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel-2); }
.resource-mini-grid span { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
.resource-mini-grid b { font-size: 14px; overflow-wrap: anywhere; }
.boot-order-list { display: grid; gap: 8px; }
.boot-order-item { display: flex; align-items: center; gap: 10px; padding: 9px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel-2); cursor: grab; }
.boot-order-item:active { cursor: grabbing; }
.boot-order-item.dragging { opacity: .55; border-color: var(--accent); }
.boot-order-item small { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }
.drag-handle { color: var(--muted); font-weight: 900; }
@media (max-width: 1180px) { .three { grid-template-columns: 1fr; } .resource-mini-grid { grid-template-columns: 1fr; } }
'''
changed.append('resource and boot-order CSS added')
if css != original_css:
css_path.write_text(css)
else:
changed.append('CSS skipped: app.css not found')
print('VM detail resource layout patch applied:')
for item in changed or ['already applied']:
print(f'- {item}')
+74
Просмотреть файл
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helper = r'''
def vm_exists(name: str) -> bool:
if not name or not valid_vm_name(name):
return False
return run_cmd(["virsh", "dominfo", name], timeout=8)["ok"]
'''
if 'def vm_exists(name: str) -> bool:' not in text:
marker = '\n\ndef list_vms() -> list[dict[str, str]]:'
if marker not in text:
raise SystemExit('list_vms marker not found')
text = text.replace(marker, helper + marker, 1)
changed.append('vm_exists helper added')
else:
changed.append('vm_exists helper already present')
old_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
new_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), replace_existing_disk: str = Form("0"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
if old_sig in text:
text = text.replace(old_sig, new_sig, 1)
changed.append('replace_existing_disk added to signature')
elif 'replace_existing_disk: str = Form("0")' in text:
changed.append('replace_existing_disk already in signature')
else:
raise SystemExit('vm_create_submit signature marker not found')
old_form = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
new_form = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "replace_existing_disk": replace_existing_disk, "network_mode": network_mode, "bridge": bridge}'
if old_form in text:
text = text.replace(old_form, new_form, 1)
changed.append('replace_existing_disk stored in form')
elif '"replace_existing_disk": replace_existing_disk' in text:
changed.append('replace_existing_disk already stored in form')
old_disk_check = ''' disk_path = IMAGES_DIR / f"{name}.qcow2"
if disk_path.exists():
return vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400)
profile = host_profile.load_host_profile()
'''
new_disk_check = ''' disk_path = IMAGES_DIR / f"{name}.qcow2"
if vm_exists(name):
return vm_form_context(request, error=f"VM с именем {name} уже существует. Выбери другое имя или удали существующую VM.", form=form, status_code=400)
if disk_path.exists():
if replace_existing_disk == "1":
disk_path.unlink()
else:
return vm_form_context(request, error=f"Диск уже существует: {disk_path}. Это может быть остаток неудачной установки. Включи опцию «Заменить существующий диск», если VM с таким именем не нужна.", form=form, status_code=400)
profile = host_profile.load_host_profile()
'''
if old_disk_check in text:
text = text.replace(old_disk_check, new_disk_check, 1)
changed.append('disk replace logic added')
elif 'Заменить существующий диск' in text and 'replace_existing_disk == "1"' in text:
changed.append('disk replace logic already present')
else:
raise SystemExit('disk_path exists marker not found')
app_path.write_text(text)
print('vm disk replace patch applied:')
for item in changed:
print(f'- {item}')
+64
Просмотреть файл
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helper = '''
def bridge_exists(name: str) -> bool:
if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):
return False
return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]
'''
if 'def bridge_exists(name: str) -> bool:' not in text:
markers = [
'\n\ndef default_network_mode() -> str:',
'\n\ndef valid_vm_name(name: str) -> bool:',
'\n\ndef parse_virsh_list() -> list[dict[str, str]]:',
]
inserted = False
for marker in markers:
if marker in text:
text = text.replace(marker, helper + marker, 1)
changed.append('bridge_exists helper added')
inserted = True
break
if not inserted:
print('WARN: bridge helper marker not found, skip helper injection')
changed.append('bridge_exists helper skipped')
else:
changed.append('bridge_exists helper already present')
old_validation = ''' elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
error = "Некорректное имя bridge."
else:
iso = Path(iso_path).resolve()
'''
new_validation = ''' elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
error = "Некорректное имя bridge."
elif network_mode == "bridge" and 'bridge_exists' in globals() and not bridge_exists(bridge):
error = f"Bridge {bridge} не найден на сервере. Для VPS выбери режим NAT Router — virtuality-nat, либо сначала создай bridge {bridge}."
else:
iso = Path(iso_path).resolve()
'''
if old_validation in text:
text = text.replace(old_validation, new_validation, 1)
changed.append('bridge existence validation added')
elif 'Bridge {bridge} не найден на сервере' in text:
changed.append('bridge existence validation already present')
else:
print('WARN: bridge validation marker not found, skip validation injection')
changed.append('bridge validation skipped')
app_path.write_text(text)
print('vm network guard patch applied:')
for item in changed:
print(f'- {item}')
+101
Просмотреть файл
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
safe_proxy = '''# Virtuality noVNC console patch
async def proxy_vnc_to_websocket(reader: asyncio.StreamReader, websocket: WebSocket) -> None:
while True:
data = await reader.read(65536)
if not data:
break
try:
await websocket.send_bytes(data)
except (WebSocketDisconnect, RuntimeError, ConnectionError):
break
async def proxy_websocket_to_vnc(websocket: WebSocket, writer: asyncio.StreamWriter) -> None:
while True:
try:
message = await websocket.receive()
except (WebSocketDisconnect, RuntimeError, ConnectionError):
break
if message.get("type") == "websocket.disconnect":
break
if message.get("bytes") is not None:
writer.write(message["bytes"])
elif message.get("text") is not None:
writer.write(message["text"].encode())
try:
await writer.drain()
except (RuntimeError, ConnectionError, BrokenPipeError):
break
'''
safe_gather = ''' tasks = [
asyncio.create_task(proxy_vnc_to_websocket(reader, websocket)),
asyncio.create_task(proxy_websocket_to_vnc(websocket, writer)),
]
try:
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
await asyncio.gather(*done, return_exceptions=True)
finally:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
'''
if 'Virtuality noVNC console patch' in text:
start = text.find('# Virtuality noVNC console patch')
marker = '\n\n@app.get("/login", response_class=HTMLResponse)'
end = text.find(marker, start)
if start != -1 and end != -1:
text = text[:start] + safe_proxy + text[end:]
old_gather = ''' try:
await asyncio.gather(proxy_vnc_to_websocket(reader, websocket), proxy_websocket_to_vnc(websocket, writer))
except (WebSocketDisconnect, asyncio.CancelledError, ConnectionError):
pass
finally:
writer.close()
await writer.wait_closed()
'''
if old_gather in text:
text = text.replace(old_gather, safe_gather, 1)
app_path.write_text(text)
print(f'noVNC console patch upgraded: {app_path}')
raise SystemExit(0)
text = text.replace('import crypt\n', 'import asyncio\nimport crypt\n', 1)
text = text.replace('from fastapi import FastAPI, Request, Form, UploadFile, File\n', 'from fastapi import FastAPI, Request, Form, UploadFile, File, WebSocket, WebSocketDisconnect\n', 1)
text = text.replace('serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-session")\n', 'serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-session")\nconsole_serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-console")\n', 1)
text = text.replace('DEFAULT_BRIDGE = "br0"\n', 'DEFAULT_BRIDGE = "br0"\nNOVNC_DIR = next((p for p in [Path("/usr/share/novnc"), Path("/usr/share/novnc/app")] if p.exists()), None)\n', 1)
text = text.replace('app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")\n', 'app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")\nif NOVNC_DIR:\n app.mount("/novnc", StaticFiles(directory=str(NOVNC_DIR)), name="novnc")\n', 1)
old_get_user = '''def get_current_user(request: Request) -> str | None:\n token = request.cookies.get("virtuality_session")\n if not token:\n return None\n try:\n data = serializer.loads(token)\n except BadSignature:\n return None\n return AUTH_USER if data.get("user") == AUTH_USER else None\n'''
new_get_user = '''def user_from_session_token(token: str | None) -> str | None:\n if not token:\n return None\n try:\n data = serializer.loads(token)\n except BadSignature:\n return None\n return AUTH_USER if data.get("user") == AUTH_USER else None\n\n\ndef get_current_user(request: Request) -> str | None:\n return user_from_session_token(request.cookies.get("virtuality_session"))\n'''
text = text.replace(old_get_user, new_get_user, 1)
insert_after_vm_ip = '''def vm_vnc_display(name: str) -> str:\n return run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available"\n\n\ndef vnc_display_to_port(display: str) -> int | None:\n value = (display or "").strip()\n if not value or value == "not available":\n return None\n match = re.search(r":(\\d+)$", value)\n if not match:\n return None\n display_number = int(match.group(1))\n if display_number >= 5900:\n return display_number\n return 5900 + display_number\n\n\ndef console_info(name: str) -> dict[str, Any]:\n display = vm_vnc_display(name)\n port = vnc_display_to_port(display)\n has_novnc = bool(NOVNC_DIR and (NOVNC_DIR / "vnc.html").exists())\n token = None\n url = None\n if port and has_novnc:\n token = console_serializer.dumps({"vm": name, "port": port})\n url = f"/novnc/vnc.html?autoconnect=1&resize=scale&path=console/ws/{token}"\n return {"vm": name, "display": display, "port": port, "has_novnc": has_novnc, "novnc_dir": str(NOVNC_DIR) if NOVNC_DIR else "not installed", "url": url}\n\n\n'''
text = text.replace('def vm_details(name: str) -> dict[str, Any]:\n', insert_after_vm_ip + 'def vm_details(name: str) -> dict[str, Any]:\n', 1)
text = text.replace('"vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available",', '"vnc": vm_vnc_display(name),', 1)
text = text.replace('@app.get("/login", response_class=HTMLResponse)\n', safe_proxy + '\n@app.get("/login", response_class=HTMLResponse)\n', 1)
console_routes = '''\n\n@app.get("/vm/{name}/console", response_class=HTMLResponse)\ndef vm_console_page(request: Request, name: str):\n auth_redirect = require_auth(request)\n if auth_redirect:\n return auth_redirect\n if not valid_vm_name(name) or not vm_exists(name):\n return RedirectResponse(url="/", status_code=303)\n return templates.TemplateResponse("console.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "console": console_info(name)})\n\n\n@app.websocket("/console/ws/{token}")\nasync def console_websocket(websocket: WebSocket, token: str):\n if user_from_session_token(websocket.cookies.get("virtuality_session")) != AUTH_USER:\n await websocket.close(code=1008)\n return\n try:\n payload = console_serializer.loads(token)\n vm_name = payload.get("vm")\n target_port = int(payload.get("port"))\n except Exception:\n await websocket.close(code=1008)\n return\n if not valid_vm_name(vm_name) or not vm_exists(vm_name) or target_port < 5900 or target_port > 5999:\n await websocket.close(code=1008)\n return\n await websocket.accept()\n try:\n reader, writer = await asyncio.open_connection("127.0.0.1", target_port)\n except Exception:\n await websocket.close(code=1011)\n return\n''' + safe_gather.replace('\n', '\n')
text = text.replace('\n\n@app.get("/vm/{name}", response_class=HTMLResponse)\n', console_routes + '\n\n@app.get("/vm/{name}", response_class=HTMLResponse)\n', 1)
app_path.write_text(text)
print(f'noVNC console patch applied: {app_path}')
+169
Просмотреть файл
@@ -0,0 +1,169 @@
#!/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 "============================================================"
-29
Просмотреть файл
@@ -1,29 +0,0 @@
#!/usr/bin/env bash
# Sourced by auto_update_check.sh and apply_github_update.sh: which git ref a node updates to.
# stable (default) — the latest release tag vX.Y.Z; main until the first release is tagged
# main — every change on the main branch
# Needs SOURCE_DIR, REMOTE and BRANCH; sets UPDATE_CHANNEL, TARGET_REF, TARGET_LABEL, TARGET_ZIP_URL.
update_channel() {
local value="${VIRTUALITY_UPDATE_CHANNEL:-}"
if [[ -z "$value" && -f /var/lib/virtuality/config/web.env ]]; then
value="$(sed -n 's/^VIRTUALITY_UPDATE_CHANNEL=//p' /var/lib/virtuality/config/web.env | tail -n1 | tr -d "\"'")"
fi
[[ "$value" == "main" ]] && echo main || echo stable
}
resolve_update_target() {
local tag=""
UPDATE_CHANNEL="$(update_channel)"
TARGET_REF="${REMOTE}/${BRANCH}"
TARGET_LABEL="${BRANCH}"
TARGET_ZIP_URL="https://github.com/viktor138irk/virtuality/archive/refs/heads/${BRANCH}.zip"
if [[ "$UPDATE_CHANNEL" == "stable" && -d "${SOURCE_DIR}/.git" ]]; then
tag="$(git -C "$SOURCE_DIR" tag --list 'v[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+(\.[0-9]+)*$' | head -n1 || true)"
if [[ -n "$tag" ]]; then
TARGET_REF="refs/tags/${tag}"
TARGET_LABEL="$tag"
TARGET_ZIP_URL="https://github.com/viktor138irk/virtuality/archive/refs/tags/${tag}.zip"
fi
fi
}
-744
Просмотреть файл
@@ -1,744 +0,0 @@
#!/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
Просмотреть файл
@@ -0,0 +1,224 @@
#!/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
Просмотреть файл
@@ -1,239 +0,0 @@
#!/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
Просмотреть файл
@@ -0,0 +1,72 @@
#!/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 \"Описание изменений\""
-196
Просмотреть файл
@@ -1,196 +0,0 @@
import os
import sys
from pathlib import Path
import pytest
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)
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
"""
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 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
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
Просмотреть файл
@@ -1,221 +0,0 @@
#!/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
Просмотреть файл
@@ -1,3 +0,0 @@
5861314d7fccb39c2192173240eab44fa35ca66426201ca2acd0630a6258dd51 *resolute-server-cloudimg-amd64.img
f69162950f235e3cdbbad33f1f912d1a504be90d8a37d002c735d6f3e3882265 *resolute-server-cloudimg-arm64.img
e0361263a9f568c21a59e75c3b7810bc5867866e5bd48f499f8b2e6ab723f059 *resolute-server-cloudimg-amd64.vmdk
-3
Просмотреть файл
@@ -1,3 +0,0 @@
# generated by debian cloud team
1412d62c2d55ae2432df6d22c36b9edf4512a59a43bb4eadb1bd3a5ef201154a3052d83ffae704d7079b50388e61992013348e4bb4dee45b3c2a266e2b296135 debian-13-genericcloud-amd64.qcow2
27c03aab0b5ee1f5918bfaa94821d2a1500fc171ffeb0ae159e7d18662f49171551f397683004434096e4006380fa607577b3c90fafa1eceb3a92702ebd49a15 debian-13-genericcloud-arm64.qcow2
-3
Просмотреть файл
@@ -1,3 +0,0 @@
Capacity: 42949672960
Allocation: 6442450944
Physical: 6443499520
-4
Просмотреть файл
@@ -1,4 +0,0 @@
Type Device Target Source
------------------------------------------------------------
file disk vda /var/lib/virtuality/images/web01.qcow2
file cdrom sda -
-16
Просмотреть файл
@@ -1,16 +0,0 @@
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
Просмотреть файл
@@ -1,37 +0,0 @@
<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
Просмотреть файл
@@ -1,6 +0,0 @@
Id Name State Title
--------------------------------------------
1 web01 running Сайт компании
- db01 shut off
- win11 shut off Бухгалтерия 1С

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