Сравнить коммиты
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
ca5d49266c | ||
|
|
ec9428c2eb | ||
|
|
f31b4ade1d | ||
|
|
2474ad3dc2 | ||
|
|
daeec06ec6 | ||
|
|
209e83044a | ||
|
|
5d4525f78d | ||
|
|
984eb85a5a | ||
|
|
48ac535c3a | ||
|
|
cc3abe3f4a | ||
|
|
2a4bffc7df | ||
|
|
af9cae85de | ||
|
|
b7c87867fb | ||
|
|
fc76f3f1fa | ||
|
|
1ad557d1ab | ||
|
|
d50a4314aa | ||
|
|
40978d1c2b | ||
|
|
440d8fc562 | ||
|
|
83511d6b09 | ||
|
|
83300b6c5a | ||
|
|
657d54fc75 | ||
|
|
526ef3effd | ||
|
|
3613c02b7c | ||
|
|
e13887ad76 | ||
|
|
fb6fd2bc2d | ||
|
|
03ca04073f | ||
|
|
3ad50faa46 | ||
|
|
d51b620d02 | ||
|
|
6b4d4f899c | ||
|
|
5465000f42 | ||
|
|
ee6a615df7 | ||
|
|
a74e620192 | ||
|
|
01970f3071 | ||
|
|
c2dc32947f | ||
|
|
cb485b1d23 | ||
|
|
1d39029472 | ||
|
|
024fe5a267 | ||
|
|
43b7ea386f | ||
|
|
554270fcb9 | ||
|
|
c3912ca4e9 | ||
|
|
0d34fe8c0b | ||
|
|
90993a4115 | ||
|
|
c5b576dbe7 | ||
|
|
7ec3dc6082 | ||
|
|
e5ce9c4d41 | ||
|
|
eb7b71f478 | ||
|
|
2b7223f311 | ||
|
|
a43f96883e | ||
|
|
0e4b1783ec | ||
|
|
34684634af | ||
|
|
fd7e53f8f4 | ||
|
|
42ac092272 | ||
|
|
2745a1280f | ||
|
|
55f7d971f6 | ||
|
|
4fd04be588 | ||
|
|
9cf8c3e4b7 | ||
|
|
8a4474a305 | ||
|
|
2450278d93 | ||
|
|
4198c18ff4 | ||
|
|
7bf7a0f6b7 | ||
|
|
e52967fba1 | ||
|
|
1e29134fb3 | ||
|
|
873b32c517 | ||
|
|
f20782c087 | ||
|
|
4098c270a4 | ||
|
|
c4cebd916b | ||
|
|
967f4c1a7e | ||
|
|
cadc17a6df | ||
|
|
110a37f435 | ||
|
|
85648739dc | ||
|
|
d92b1b9bda | ||
|
|
975ab4d1a0 | ||
|
|
10a780d381 | ||
|
|
5f2bdd921d |
@@ -0,0 +1,40 @@
|
||||
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
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Build ISO image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths:
|
||||
- "image/**"
|
||||
- "scripts/virtuality_firstboot.sh"
|
||||
- "web/requirements.txt"
|
||||
- ".github/workflows/image.yml"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
arch:
|
||||
description: "Architecture"
|
||||
type: choice
|
||||
options: [amd64, arm64, both]
|
||||
default: amd64
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: image-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
iso:
|
||||
name: ISO ${{ matrix.arch }}
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: sudo apt-get update && sudo apt-get install -y xorriso python3-yaml
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .cache/iso
|
||||
key: ubuntu-iso-${{ matrix.arch }}-${{ hashFiles('image/build-iso.sh') }}
|
||||
restore-keys: ubuntu-iso-${{ matrix.arch }}-
|
||||
- name: Free disk space
|
||||
run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL && df -h /
|
||||
- run: make iso ARCH=${{ matrix.arch }}
|
||||
- name: Check ISO boot records
|
||||
run: |
|
||||
iso=$(ls dist/*.iso)
|
||||
xorriso -indev "$iso" -report_el_torito plain 2>/dev/null | tee /dev/stderr | grep -q "UEFI"
|
||||
xorriso -osirrox on -indev "$iso" -extract /autoinstall.yaml autoinstall.yaml -extract /boot/grub/grub.cfg grub.cfg
|
||||
python3 -c "import yaml; d = yaml.safe_load(open('autoinstall.yaml')); assert d['autoinstall']['version'] == 1"
|
||||
grep -q "Установить Virtuality" grub.cfg
|
||||
# Ubuntu server ISOs are larger than the 2 GB GitHub release asset limit,
|
||||
# so images are published as workflow artifacts.
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: virtuality-iso-${{ matrix.arch }}
|
||||
path: |
|
||||
dist/*.iso
|
||||
dist/*.sha256
|
||||
compression-level: 0
|
||||
retention-days: 30
|
||||
@@ -91,3 +91,9 @@ composer.lock
|
||||
# System files
|
||||
*.pid
|
||||
*.sock
|
||||
|
||||
# Offline Python wheels bundled into system images
|
||||
wheels/
|
||||
|
||||
# рабочие копии агентов и локальные настройки Claude Code
|
||||
.claude/
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Virtuality — заметки для Claude Code
|
||||
|
||||
Virtuality — простая замена Proxmox для дома и малого офиса: KVM/QEMU/libvirt + собственная
|
||||
веб-панель на FastAPI, установочный ISO на базе чистого Ubuntu Server 26.04 LTS и мастер
|
||||
первичной настройки в браузере. Пользователь — обычный человек, не админ.
|
||||
|
||||
**Общайся с владельцем проекта по-русски.** Весь текст интерфейса — на простом русском, без жаргона
|
||||
(термины вроде qcow2, libvirt, nvram — только в раскрывающихся блоках «Для специалистов»).
|
||||
|
||||
Репозиторий: https://git.stackworks.ru/viktor/virtuality (Gitea StackWorks). С GitHub проект переехал в сентябре 2026 года;
|
||||
ноды со старым адресом переключаются сами (`update_core.migrate_remote`, `migrate_update_remote` в `scripts/update_target.sh`).
|
||||
|
||||
## Устройство репозитория
|
||||
|
||||
- `web/app.py` — основное приложение FastAPI (машины, ISO, образы дисков, сеть, журналы, обновления, консоль).
|
||||
- `web/core.py` — общие примитивы: `run_cmd`, хранилище операций (`new_operation`, `run_operation`,
|
||||
`finish_operation`, `active_operations_for`, `interrupt_orphaned_operations`), `render`, `require_auth`,
|
||||
`valid_vm_name`, шаблоны Jinja. Импорт `core` выставляет `LC_ALL=C.UTF-8` (virsh иначе переводится).
|
||||
- `web/features/*.py` — модули с `router = APIRouter()`, подключаются в конце `app.py`:
|
||||
`setup` (мастер `/setup`), `settings` (`/settings`), `snapshots`, `backups`, `backup_schedule` (копии
|
||||
по расписанию; запускает их `virtuality-backup.timer`, а не панель), `downloads` (скачивание по ссылке и каталог), `vmops` (клон, увеличение диска, пауза/спящий режим, заметки, нагрузка).
|
||||
POST-маршруты модулей — минимум 4 сегмента (`/vm/{name}/<модуль>/<действие>`), иначе их перехватит
|
||||
`@app.post("/vm/{name}/{action}")`.
|
||||
- `web/nodectl.py` — факты об узле и действия через `scripts/virtuality-ctl` (единая точка для
|
||||
systemd-юнитов, TLS, firewall, моста br0 с автооткатом, диска под хранилище, пароля, часового пояса).
|
||||
- `web/network_core.py` — NAT-сеть `virtuality-nat` (192.168.100.0/24), резервации DHCP (фиксированный
|
||||
MAC по имени машины), проброс портов (nftables + ufw + iptables-fallback с меткой `virtuality-forward`).
|
||||
- `web/catalog.py` — каталог облачных образов и типы ОС; `web/cloudinit.py` — seed cloud-init.
|
||||
- `web/update_core.py` + `scripts/auto_update_check.sh`, `scripts/apply_github_update.sh`,
|
||||
`scripts/update_target.sh` — обновления; канал `stable` = последний тег `vX.Y.Z` (пока тегов нет — main),
|
||||
канал `main` = ветка main.
|
||||
- `scripts/virtuality_firstboot.sh` — первая загрузка установленной системы: panel → network →
|
||||
virtualization → tools → finish, прогресс в `/var/lib/virtuality/config/setup.json`.
|
||||
- `scripts/install_web_panel.sh` — установка/обновление панели (web.prev для отката, офлайн-wheels).
|
||||
- `install.sh` (установка одной командой), `install_virtuality_node.sh` (пакеты KVM/libvirt).
|
||||
- `image/build-iso.sh`, `image/autoinstall.yaml`, `image/grub/` — сборка установочного ISO.
|
||||
- `updates/versions.json` — журнал изменений для центра обновлений; `VERSION` — текущая версия.
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
make check # pyflakes + shellcheck + pytest
|
||||
python3 -m pytest -q # тесты (fake virsh в tests/conftest.py)
|
||||
python3 tests/dev_server.py 8765 # демо-панель, вход: tester / любой пароль
|
||||
VIRTUALITY_DEV_SETUP=installing python3 tests/dev_server.py # мастер на шаге установки
|
||||
VIRTUALITY_DEV_SETUP=done|finished ... # мастер после установки / настроенный сервер
|
||||
make iso # ISO в dist/ (нужны xorriso, git, curl, python3-pip; ~3 ГБ загрузки)
|
||||
./image/build-iso.sh --help # все параметры сборки (в т.ч. --unattended)
|
||||
```
|
||||
|
||||
Скриншоты: Playwright и Chromium доступны в облачной среде, локально — `npx playwright`.
|
||||
|
||||
## Правила
|
||||
|
||||
- Код как вокруг: плотность комментариев, имена, идиомы модуля. Не переформатировать чужой код.
|
||||
- Каждое изменение поведения — с тестом. Перед коммитом: `make check`.
|
||||
- Коммиты по-русски в стиле «<что> было сделано».
|
||||
- Работаем прямо в `main` (так решил владелец). Перед каждым пушем — `make check`; не пушить
|
||||
недоделанное: ноды на канале «Ранний доступ» (`main`) подтягивают main каждую ночь.
|
||||
- Узлы на канале `stable` (по умолчанию) обновляются только до тегов `vX.Y.Z`. Релиз: поднять `VERSION`,
|
||||
добавить запись в `updates/versions.json`, запушить, поставить тег `vX.Y.Z` на main. Раннера Actions нет:
|
||||
ISO собирается вручную `make iso`, CI заменяет `make check` перед пушем.
|
||||
- Секреты и пароли не логировать; пароль cloud-init хранится только как SHA-512 crypt.
|
||||
- Не возвращать удалённое: Cockpit, консольный dashboard, эмуляцию ARM64 на x86, PXE, смену
|
||||
архитектуры машины, скрипты bootstrap/clean_install/setup_bridge_br0/create_test_vm.
|
||||
@@ -0,0 +1,34 @@
|
||||
PYTHON ?= python3
|
||||
ARCH ?= amd64
|
||||
ISO_ARGS ?=
|
||||
SHELL_SCRIPTS := $(wildcard *.sh scripts/*.sh image/*.sh)
|
||||
|
||||
.PHONY: help test lint lint-python lint-shell check iso clean
|
||||
|
||||
help:
|
||||
@echo "make test - run the web panel test suite"
|
||||
@echo "make lint - pyflakes + shellcheck"
|
||||
@echo "make check - lint + test"
|
||||
@echo "make iso - build the installer ISO (ARCH=amd64|arm64, ISO_ARGS='--unattended ...')"
|
||||
@echo "make clean - remove build output"
|
||||
|
||||
test:
|
||||
$(PYTHON) -m pytest -q
|
||||
|
||||
lint-python:
|
||||
$(PYTHON) -m pyflakes web tests
|
||||
|
||||
lint-shell:
|
||||
@for f in $(SHELL_SCRIPTS); do bash -n "$$f" || exit 1; done
|
||||
shellcheck -S error $(SHELL_SCRIPTS)
|
||||
|
||||
lint: lint-python lint-shell
|
||||
|
||||
check: lint test
|
||||
|
||||
iso:
|
||||
./image/build-iso.sh --arch $(ARCH) $(ISO_ARGS)
|
||||
|
||||
clean:
|
||||
rm -rf dist .pytest_cache
|
||||
find . -name __pycache__ -type d -prune -exec rm -rf {} +
|
||||
@@ -1,432 +1,362 @@
|
||||
# Virtuality
|
||||
|
||||
**Virtuality** — лёгкая серверная платформа виртуализации на базе **KVM**, **QEMU**, **libvirt**, **Cockpit** и собственной web-панели.
|
||||
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
|
||||
|
||||
Цель проекта — собрать понятную, компактную и расширяемую систему для управления виртуальными машинами, ISO-образами, NAT/bridge-сетью, пробросом портов, диагностикой, web-console/noVNC, backup/snapshot-функциями и будущей кластеризацией.
|
||||
Текущая версия: **0.12.5** (файл `VERSION`, журнал изменений — `updates/versions.json`).
|
||||
|
||||
> Текущий статус: ранняя стадия разработки. Уже есть one-command установка, автоопределение профиля хоста, web-панель, авторизация через Linux-пользователя, ISO-менеджер, создание VM из интерфейса, журнал операций, NAT Router для VPS/ARM edge nodes и port forwarding.
|
||||
Что умеет:
|
||||
|
||||
- ставится с готового ISO-образа, как Proxmox: три экрана установщика — и через несколько минут панель открыта в браузере;
|
||||
- создаёт машины из установочных ISO (в том числе Windows 11) и за минуту — из готовых облачных образов Ubuntu, Debian и Alpine;
|
||||
- снимки, резервные копии, клонирование, увеличение диска, спящий режим;
|
||||
- сеть машин «из коробки»: отдельная сеть NAT с постоянными адресами и пробросом портов или ваша домашняя сеть через мост;
|
||||
- экран машины прямо в браузере, HTTPS для панели, автообновления со стабильным каналом.
|
||||
|
||||

|
||||
|
||||
| Мастер создания машины | Страница машины |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Сеть и доступ | Тёмная тема |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Мастер настройки: установка | Мастер настройки: сеть машин |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Каталог готовых систем | Настройки |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Снимки | Резервные копии |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Вход | На телефоне |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
## Поддерживаемые профили хоста
|
||||
## Содержание
|
||||
|
||||
Virtuality автоматически определяет профиль сборки во время установки:
|
||||
- [Установка](#установка)
|
||||
- [Способ 1: установочный ISO](#способ-1-установочный-iso)
|
||||
- [Способ 2: одна команда на готовый сервер](#способ-2-одна-команда-на-готовый-сервер)
|
||||
- [Способ 3: вручную](#способ-3-вручную)
|
||||
- [Первая загрузка и мастер настройки](#первая-загрузка-и-мастер-настройки)
|
||||
- [Возможности](#возможности)
|
||||
- [Настройки](#настройки)
|
||||
- [Команда virtuality-ctl](#команда-virtuality-ctl)
|
||||
- [Обновления и откат](#обновления-и-откат)
|
||||
- [Удаление](#удаление)
|
||||
- [Если что-то не работает](#если-что-то-не-работает)
|
||||
- [Где что лежит](#где-что-лежит)
|
||||
- [Разработка](#разработка)
|
||||
|
||||
```text
|
||||
x86_64 # обычный сервер, домашний сервер, VPS
|
||||
raspberry-arm64 # Raspberry Pi ARM64 edge node
|
||||
orangepi5-arm64 # Orange Pi 5 / RK3588 ARM64 edge node
|
||||
generic-arm64 # другая ARM64-плата
|
||||
```
|
||||
|
||||
Профиль сохраняется здесь:
|
||||
|
||||
```text
|
||||
/var/lib/virtuality/config/host_profile.json
|
||||
```
|
||||
|
||||
В web-панели профиль виден на странице:
|
||||
|
||||
```text
|
||||
/host
|
||||
```
|
||||
|
||||
Для ARM64-плат правильный сценарий — **ARM64-гости**, NAT-сеть и позже cloud-image/cloud-init шаблоны. x86_64 ISO на Raspberry/Orange Pi не являются целевым режимом.
|
||||
Подробные документы: [требования](docs/REQUIREMENTS.md) · [установочный образ](docs/IMAGE.md) · [первая машина](docs/FIRST_VM.md) · [архитектура](docs/ARCHITECTURE.md) · [планы](docs/ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
## Быстрая установка одной командой
|
||||
## Установка
|
||||
|
||||
Одна команда работает и под `root`, и под обычным пользователем с `sudo`:
|
||||
Перед установкой проверьте [требования](docs/REQUIREMENTS.md): процессор x86_64 с включённой виртуализацией (Intel VT-x / AMD-V), от 4 ГБ памяти, проводная сеть.
|
||||
|
||||
### Способ 1: установочный ISO
|
||||
|
||||
Самый простой путь для «голого» сервера или старого компьютера. Образ собирается из чистого Ubuntu Server 26.04 LTS; установщик спрашивает только сеть, диск и пользователя.
|
||||
|
||||
Где взять образ:
|
||||
|
||||
- **Собрать самому** на любой Linux-машине (root не нужен):
|
||||
|
||||
```bash
|
||||
sudo apt install -y xorriso git curl python3-pip
|
||||
git clone https://git.stackworks.ru/viktor/virtuality.git
|
||||
cd virtuality
|
||||
make iso # dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso
|
||||
```
|
||||
|
||||
Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md).
|
||||
|
||||
### Способ 2: одна команда на готовый сервер
|
||||
|
||||
Подходит для уже установленного Ubuntu Server 24.04 / 26.04 или Debian 13. Работает и под `root`, и под обычным пользователем с `sudo`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | bash
|
||||
curl -fsSL https://git.stackworks.ru/viktor/virtuality/raw/branch/main/install.sh | bash
|
||||
```
|
||||
|
||||
Как выбирается пользователь для входа в web-панель:
|
||||
Скрипт проверит требования, поставит пакеты KVM/QEMU/libvirt, команду `vhealth` и панель, а в конце покажет адрес панели. Логин — тот пользователь, от которого запущена установка (через `sudo` — ваш пользователь, под `root` — `root`); пароль — его пароль Linux. Если пароль ещё не задан: `sudo passwd <пользователь>`.
|
||||
|
||||
Переменные окружения, которые понимает `install.sh`:
|
||||
|
||||
```text
|
||||
Если запуск под root → login: root
|
||||
Если запуск через sudo → login: текущий sudo-пользователь
|
||||
Если задан VIRTUALITY_USER → login: указанный Linux-пользователь
|
||||
```
|
||||
|
||||
Принудительно выбрать пользователя:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=<linux_user> bash
|
||||
VIRTUALITY_USER=admin # какой Linux-пользователь входит в панель (будет создан, если его нет)
|
||||
VIRTUALITY_WEB_PORT=8089 # порт панели (по умолчанию 8088)
|
||||
VIRTUALITY_AUTO_UPDATE=0 # не включать ночное автообновление
|
||||
VIRTUALITY_BRANCH=main # ветка репозитория
|
||||
VIRTUALITY_INSTALL_VERBOSE=1 # показывать вывод команд вместо спиннера
|
||||
```
|
||||
|
||||
Пример:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=admin bash
|
||||
curl -fsSL https://git.stackworks.ru/viktor/virtuality/raw/branch/main/install.sh | VIRTUALITY_USER=admin bash
|
||||
```
|
||||
|
||||
Установка с авторизацией под `root`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=root bash
|
||||
```
|
||||
|
||||
Если пароль выбранного Linux-пользователя не задан:
|
||||
|
||||
```bash
|
||||
sudo passwd <linux_user>
|
||||
```
|
||||
|
||||
Для root:
|
||||
|
||||
```bash
|
||||
passwd root
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Что уже есть
|
||||
|
||||
- универсальная one-command установка через `install.sh`;
|
||||
- автоопределение профиля хоста: x86_64, Raspberry Pi ARM64, Orange Pi 5 ARM64, generic ARM64;
|
||||
- установка разных пакетных наборов под x86 и ARM64;
|
||||
- красивый пошаговый installer с live-status и логами;
|
||||
- preflight-проверка системных требований;
|
||||
- установка KVM/QEMU/libvirt;
|
||||
- установка Cockpit и Cockpit Machines;
|
||||
- рабочая директория `/opt/virtuality/source`;
|
||||
- libvirt storage pools `virtuality-images` и `virtuality-iso`;
|
||||
- firewall-правила для SSH, Cockpit, VNC и web-панели;
|
||||
- безопасная ручная настройка bridge `br0` через `netplan try`;
|
||||
- NAT Router `virtuality-nat` для VPS/ARM edge nodes;
|
||||
- port forwarding через nftables;
|
||||
- автоматическое определение IP VM для проброса портов;
|
||||
- healthcheck одной командой `vhealth`;
|
||||
- консольный dashboard для физического монитора;
|
||||
- web-панель Virtuality на FastAPI;
|
||||
- авторизация web-панели через системного Linux-пользователя;
|
||||
- `/host` — профиль хоста и проверки готовности;
|
||||
- `/network` — NAT, DHCP leases, port forwarding, nftables preview;
|
||||
- `/iso` — ISO-менеджер;
|
||||
- `/operations` — журнал фоновых операций;
|
||||
- список VM;
|
||||
- управление VM: Start, Shutdown, Reboot, Power off, Autostart on/off, Delete VM with disks;
|
||||
- создание VM из web-интерфейса;
|
||||
- выбор сетевого режима при создании VM: `virtuality-nat` или `br0`;
|
||||
- прогресс создания VM и live-лог `virt-install`;
|
||||
- страница деталей VM: `dominfo`, VNC display, IP, диски, сетевые интерфейсы.
|
||||
|
||||
---
|
||||
|
||||
## Системные требования
|
||||
|
||||
Минимально для 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-панели:
|
||||
При первом входе в панель откроется мастер настройки — тот же, что после установки с ISO.
|
||||
|
||||
### Способ 3: вручную
|
||||
|
||||
Если хочется видеть каждый шаг. Всё делается от `root` или через `sudo`:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/virtuality
|
||||
sudo git clone https://git.stackworks.ru/viktor/virtuality.git /opt/virtuality/source
|
||||
cd /opt/virtuality/source
|
||||
sudo bash scripts/install_web_panel.sh
|
||||
sudo bash install_virtuality_node.sh # пакеты KVM/QEMU/libvirt, пулы, firewall
|
||||
sudo bash scripts/install_healthcheck_command.sh # команда sudo vhealth
|
||||
sudo VIRTUALITY_AUTH_USER=admin bash scripts/install_web_panel.sh # панель и virtuality-ctl
|
||||
```
|
||||
|
||||
Статус:
|
||||
`install_web_panel.sh` копирует панель в `/opt/virtuality/web`, создаёт virtualenv, ставит `virtuality-ctl` и через него — systemd-юниты и правила firewall. Переменные: `VIRTUALITY_AUTH_USER` (пользователь для входа), `VIRTUALITY_WEB_PORT` (порт), `VIRTUALITY_AUTO_UPDATE` (0/1). Повторный запуск скрипта обновляет панель, сохраняя настройки.
|
||||
|
||||
```bash
|
||||
systemctl status virtuality-web --no-pager
|
||||
```
|
||||
---
|
||||
|
||||
Логи:
|
||||
## Первая загрузка и мастер настройки
|
||||
|
||||
```bash
|
||||
journalctl -u virtuality-web -f
|
||||
```
|
||||
### Что происходит после установки с ISO
|
||||
|
||||
Вход:
|
||||
После перезагрузки запускается `virtuality-firstboot.service` (`scripts/virtuality_firstboot.sh`). Порядок шагов выбран так, чтобы вы как можно раньше увидели, что происходит:
|
||||
|
||||
1. **Панель управления** — ставится офлайн из Python-колёс, упакованных в образ, и сразу запускается. Открывайте `http://адрес-сервера:8088` — мастер покажет ход остальных шагов прямо в браузере.
|
||||
2. **Подключение к интернету** — ожидание сети. Если кабель не подключён, мастер напишет об этом и продолжит сам, как только интернет появится.
|
||||
3. **KVM, QEMU и libvirt** — самый долгий шаг, обычно 5–15 минут: пакеты ставятся из репозиториев Ubuntu, мастер показывает, какой пакет скачивается или настраивается.
|
||||
4. **Инструменты диагностики** — команда `vhealth`.
|
||||
5. **Завершение** — `virtuality-ctl reconfigure`: юниты, firewall, перезапуск панели.
|
||||
|
||||
Ход установки пишется в `/var/lib/virtuality/config/setup.json` и в `/var/log/virtuality/firstboot.log`. Если какой-то шаг упал (например, отвалилась сеть), systemd перезапускает скрипт через минуту, и он продолжает с первого незавершённого шага. На экране сервера (tty) всё это время показана подсказка, а после завершения — адрес панели и логин.
|
||||
|
||||
Логин в панель — пользователь, созданный в установщике Ubuntu; пароль — его пароль.
|
||||
|
||||
### Мастер настройки
|
||||
|
||||
Мастер (`/setup`) открывается при первом входе в панель — и после ISO, и после `install.sh`. Каждый шаг можно пропустить, а весь мастер — запустить заново из раздела «Настройки».
|
||||
|
||||
| Шаг | Что делает |
|
||||
|---|---|
|
||||
| **Установка** | Показывает ход первой загрузки (только после ISO). |
|
||||
| **Сервер** | Проверяет процессор, память, диск и доступность KVM; задаёт часовой пояс. |
|
||||
| **Администратор** | Смена пароля пользователя панели (он же пароль SSH). |
|
||||
| **Доступ** | HTTPS с сертификатом сервера (панель на порту 8443, с 8088 — перенаправление) или обычный HTTP; порт. |
|
||||
| **Сеть машин** | **Отдельная сеть (NAT)** — машины получают адреса 192.168.100.x, работает везде. **Домашняя сеть (мост)** — машины становятся устройствами вашей сети; сервер сам перестроит сеть в мост `br0`, а если панель не ответит за 2 минуты — вернёт всё как было. |
|
||||
| **Хранилище** | Отдать пустой диск (от 16 ГБ) под машины: он форматируется и монтируется в `/var/lib/virtuality`. Или оставить системный диск. |
|
||||
| **Обновления** | Автоматически ночью (только выпущенные версии) или вручную; канал: стабильный или ранний доступ. |
|
||||
| **Готово** | Сводка; настройки применяются, при смене адреса панель перезапускается и открывается по новому. |
|
||||
|
||||
---
|
||||
|
||||
## Возможности
|
||||
|
||||
Разделы панели: **Обзор**, **Создать машину**, **Образы ОС**, **Сеть и доступ**, **Резервные копии**, **Задачи**, **Журналы**, **Обновления**, **Сервер**, **Настройки**, **Помощь**.
|
||||
|
||||
**Машины**
|
||||
|
||||
- Создание из установочного ISO: три шага — название и система, мощность (готовые профили «Мини», «Стандарт», «Мощная» или свои значения), сеть.
|
||||
- Тип системы для ISO: Linux, **Windows 11 / Server 2025** (UEFI и TPM 2.0 включаются сами), Windows 10 / Server 2019–2022, другая. Для Windows подбираются диск SATA и сетевая карта Intel — драйверы не нужны.
|
||||
- Создание из готового образа за минуту: образ конвертируется в qcow2, диск увеличивается до нужного размера, а cloud-init настраивает первый вход — пользователя, пароль и/или SSH-ключ. Пароль хранится только как хеш. При первой загрузке в машину ставится гостевой агент, поэтому панель видит её адрес в любой сети и выключает её корректно.
|
||||
- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками и пробросами портов (работающая машина сначала выключается).
|
||||
- Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места).
|
||||
- Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ).
|
||||
- **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти.
|
||||
- **Экран машины в браузере** (noVNC). VNC наружу не открыт: консоль доступна только через панель.
|
||||
|
||||
**Снимки и копии**
|
||||
|
||||
- **Снимки** — точки восстановления внутри диска машины, до 8 на машину. У работающей машины в снимок попадает и память. Вернуться или удалить — одной кнопкой.
|
||||
- **Резервные копии** — полная копия настроек и всех дисков в `/var/lib/virtuality/backups/<машина>/<дата>/`. Восстановление под тем же или новым названием.
|
||||
- **Копии по расписанию** — каждый день или раз в неделю в выбранное время, для всех машин или только отмеченных. Сервер хранит последние N автоматических копий каждой машины и удаляет старые только после удачной новой; ручные копии не трогает.
|
||||
|
||||
**Образы ОС**
|
||||
|
||||
- Загрузка ISO и образов дисков перетаскиванием (с прогрессом и скоростью), поддержка `.qcow2`, `.img`, `.raw`, сжатых `.img.xz` и архивов.
|
||||
- **Скачивание по ссылке** — сервер качает файл сам, в фоне.
|
||||
- **Каталог готовых систем** — официальные облачные образы Ubuntu Server 26.04 LTS и 24.04 LTS, Debian 13, Alpine Linux 3.23 с проверкой контрольных сумм.
|
||||
|
||||
**Сеть**
|
||||
|
||||
- Сеть **NAT** `virtuality-nat` (192.168.100.0/24): каждая машина получает **постоянный адрес** по имени, поэтому пробросы портов не ломаются после перезагрузок.
|
||||
- **Проброс портов** из списка готовых сервисов (SSH, сайт, RDP, Minecraft, VPN и другие) или любой порт и диапазон; проверка правила одной кнопкой; правила переживают перезагрузку сервера.
|
||||
- **Домашняя сеть через мост** `br0` с автооткатом, если сеть пропала.
|
||||
|
||||
**Сервер**
|
||||
|
||||
- Мастер настройки и страница «Настройки».
|
||||
- **HTTPS** для панели с сертификатом сервера и перенаправлением с HTTP.
|
||||
- **Обновления** из панели или автоматически ночью, **стабильный канал** (только выпущенные версии) и ранний доступ, откат на предыдущую версию.
|
||||
- Задачи с прогрессом и журналом, журналы служб, диагностика `vhealth`, отчёт для поддержки, архив настроек.
|
||||
- Светлая и тёмная темы, работает на телефоне, не требует доступа в интернет для самой панели.
|
||||
|
||||
Как сделать первую машину — в [docs/FIRST_VM.md](docs/FIRST_VM.md).
|
||||
|
||||
---
|
||||
|
||||
## Настройки
|
||||
|
||||
Страница **Настройки** (`/settings`) — всё то же, что в мастере, плюс управление сервером:
|
||||
|
||||
- **Доступ к панели** — HTTPS или HTTP, порт. После применения панель перезапускается и открывается по новому адресу.
|
||||
- **Пароль** — смена пароля пользователя панели (он же пароль Linux и SSH).
|
||||
- **Сеть машин** — переключение между NAT и домашней сетью (мост `br0`), возврат прежней сети, проверка сети NAT.
|
||||
- **Хранилище** — подключить пустой диск под машины.
|
||||
- **Время** — часовой пояс сервера (по нему показываются журналы и час ночного обновления).
|
||||
- **Обновления** — автообновление и канал (стабильный / ранний доступ).
|
||||
- **Сервер** — скачать архив настроек, собрать отчёт для поддержки, перезагрузить или выключить сервер (машины выключаются корректно).
|
||||
- **Мастер настройки** — пройти все шаги заново.
|
||||
|
||||
Настройки ноды хранятся в `/var/lib/virtuality/config/web.env` и не сбрасываются обновлениями:
|
||||
|
||||
```text
|
||||
Login: выбранный Linux-пользователь
|
||||
Pass: пароль этого Linux-пользователя
|
||||
VIRTUALITY_WEB_PORT=8088 # порт HTTP
|
||||
VIRTUALITY_TLS=1 # 1 — HTTPS включён
|
||||
VIRTUALITY_TLS_PORT=8443 # порт HTTPS
|
||||
VIRTUALITY_WEB_HOST=0.0.0.0 # 127.0.0.1, если панель стоит за reverse proxy
|
||||
VIRTUALITY_AUTH_USER=admin # Linux-пользователь для входа
|
||||
VIRTUALITY_AUTO_UPDATE=1 # 0 — без ночного автообновления
|
||||
VIRTUALITY_UPDATE_CHANNEL=stable # stable — выпущенные версии, main — все изменения
|
||||
VIRTUALITY_SOURCE_DIR=/opt/virtuality/source
|
||||
```
|
||||
|
||||
Менять их руками не нужно: `sudo virtuality-ctl set КЛЮЧ=ЗНАЧЕНИЕ` сохраняет значение и сразу применяет его.
|
||||
|
||||
---
|
||||
|
||||
## Команда virtuality-ctl
|
||||
|
||||
`virtuality-ctl` ставится в `/usr/local/bin` вместе с панелью; панель сама вызывает его для всех действий с сервером, так что из терминала можно сделать то же самое. Запускается от `root`:
|
||||
|
||||
```text
|
||||
sudo virtuality-ctl status # что установлено, порты, адрес панели
|
||||
sudo virtuality-ctl url # адрес панели
|
||||
sudo virtuality-ctl reconfigure # пересоздать systemd-юниты из web.env и перезапустить панель
|
||||
sudo virtuality-ctl set KEY=VALUE ... # изменить настройку в web.env и применить (например set VIRTUALITY_TLS=1)
|
||||
sudo virtuality-ctl passwd [USER] # сменить пароль пользователя панели
|
||||
sudo virtuality-ctl timezone Europe/Moscow # часовой пояс сервера
|
||||
sudo virtuality-ctl power reboot|poweroff # перезагрузить или выключить сервер (машины выключаются корректно)
|
||||
sudo virtuality-ctl backup-config [FILE] # архив настроек ноды и описаний машин
|
||||
sudo virtuality-ctl restore-config FILE # восстановить настройки из архива
|
||||
sudo virtuality-ctl rollback # вернуть предыдущую версию панели
|
||||
sudo virtuality-ctl maintenance # ежедневное обслуживание (запускается таймером)
|
||||
sudo virtuality-ctl bridge <iface> [dhcp|static] # мост br0 с автооткатом через 2 минуты
|
||||
sudo virtuality-ctl bridge-confirm # подтвердить новую сеть
|
||||
sudo virtuality-ctl bridge-revert # вернуть прежнюю сеть
|
||||
sudo virtuality-ctl storage-use /dev/sdX # отдать пустой диск под /var/lib/virtuality
|
||||
sudo virtuality-ctl support-bundle [FILE] # отчёт для поддержки
|
||||
sudo virtuality-ctl uninstall [--purge] [--yes] # удалить Virtuality
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Сеть: Bridge и NAT Router
|
||||
## Обновления и откат
|
||||
|
||||
### Bridge br0
|
||||
|
||||
Подходит для домашнего/офисного сервера, где VM должны получать IP из локальной сети.
|
||||
|
||||
По умолчанию one-command установщик **не включает `br0` автоматически**, чтобы не уронить SSH-сессию.
|
||||
|
||||
Сначала нужно посмотреть сетевой интерфейс:
|
||||
|
||||
```bash
|
||||
ip -br a
|
||||
ip route
|
||||
```
|
||||
|
||||
Затем запустить bridge setup, например для `enp2s0`:
|
||||
|
||||
```bash
|
||||
cd /opt/virtuality/source
|
||||
sudo bash scripts/setup_bridge_br0.sh enp2s0 static
|
||||
```
|
||||
|
||||
Ожидаемый результат:
|
||||
|
||||
```text
|
||||
enp2s0 UP
|
||||
br0 UP SERVER_IP/24
|
||||
default via GATEWAY dev br0
|
||||
```
|
||||
|
||||
### Virtuality NAT Router
|
||||
|
||||
Подходит для VPS, Raspberry Pi, Orange Pi 5 и других ARM64 edge nodes.
|
||||
|
||||
Схема:
|
||||
|
||||
```text
|
||||
Интернет / LAN
|
||||
↓
|
||||
Хост Virtuality
|
||||
↓
|
||||
virtuality-nat / virbr100
|
||||
↓
|
||||
VM 192.168.100.x
|
||||
```
|
||||
|
||||
Создать или починить NAT-сеть можно в web-панели:
|
||||
|
||||
```text
|
||||
/network → Создать / починить NAT-сеть
|
||||
```
|
||||
|
||||
Параметры по умолчанию:
|
||||
|
||||
```text
|
||||
Network: virtuality-nat
|
||||
Bridge: virbr100
|
||||
Subnet: 192.168.100.0/24
|
||||
Gateway: 192.168.100.1
|
||||
DHCP: 192.168.100.50–192.168.100.200
|
||||
```
|
||||
|
||||
Проброс порта добавляется в `/network`. IP VM определяется автоматически через `virsh domifaddr`, а если не получилось — через DHCP leases и MAC-адрес VM.
|
||||
|
||||
Пример SSH-проброса:
|
||||
|
||||
```text
|
||||
VM: ubuntu-test
|
||||
Внешний порт сервера: 2222
|
||||
Порт внутри VM: 22
|
||||
Протокол: tcp
|
||||
```
|
||||
|
||||
Подключение:
|
||||
|
||||
```bash
|
||||
ssh user@SERVER_IP -p 2222
|
||||
```
|
||||
- **Из панели**: раздел **Обновления** → «Проверить сейчас» → «Обновить». Машины во время обновления продолжают работать. Что изменилось — видно на той же странице.
|
||||
- **Автоматически**: таймер `virtuality-auto-update.timer` проверяет обновления каждую ночь около 4:00 по времени сервера. Включается и выключается в «Настройках» или командой `sudo virtuality-ctl set VIRTUALITY_AUTO_UPDATE=0`.
|
||||
- **Каналы**: `stable` — только выпущенные версии (последний тег `vX.Y.Z`; пока тегов нет, стабильный канал следует ветке `main`), `main` — все изменения ветки `main`. Нода никогда не откатывается на более старую версию сама.
|
||||
- **Вручную из терминала**: `sudo bash /opt/virtuality/source/scripts/apply_github_update.sh` — тот же сценарий, что и в панели (git fetch, при недоступности git — ZIP-архив с git.stackworks.ru, затем `scripts/install_web_panel.sh` и перезапуск панели). Журнал: `/var/log/virtuality/update.log`.
|
||||
- **Откат**: перед каждым обновлением предыдущая версия панели сохраняется в `/opt/virtuality/web.prev`. Если новая версия не запустилась: `sudo virtuality-ctl rollback`.
|
||||
|
||||
---
|
||||
|
||||
## ISO и создание VM
|
||||
|
||||
Загрузка ISO через web-панель:
|
||||
|
||||
```text
|
||||
/iso
|
||||
```
|
||||
|
||||
Создание VM:
|
||||
|
||||
```text
|
||||
/vm/create
|
||||
```
|
||||
|
||||
При создании VM можно выбрать сеть:
|
||||
|
||||
```text
|
||||
VPS NAT Router — virtuality-nat
|
||||
Bridge — br0 / локальная сеть
|
||||
```
|
||||
|
||||
Для x86_64 можно использовать обычные x86_64 ISO. Для Raspberry Pi / Orange Pi 5 нужны ARM64 ISO или, в будущем, ARM64 cloud images.
|
||||
|
||||
Пример загрузки Alpine x86_64 ISO:
|
||||
## Удаление
|
||||
|
||||
```bash
|
||||
sudo wget -O /var/lib/virtuality/iso/alpine-standard-x86_64.iso https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-standard-3.20.3-x86_64.iso
|
||||
sudo virsh pool-refresh virtuality-iso
|
||||
sudo virtuality-ctl uninstall # убирает панель, службы и настройки; машины, образы и копии остаются
|
||||
sudo virtuality-ctl uninstall --purge # удаляет ВСЁ: машины, диски, образы, резервные копии, сеть NAT
|
||||
```
|
||||
|
||||
Команда спрашивает подтверждение (`YES`); `--yes` пропускает вопрос. Пакеты KVM/QEMU/libvirt остаются в системе.
|
||||
|
||||
---
|
||||
|
||||
## Обновление
|
||||
## Если что-то не работает
|
||||
|
||||
```bash
|
||||
cd /opt/virtuality/source
|
||||
sudo git pull
|
||||
sudo bash scripts/install_web_panel.sh
|
||||
sudo systemctl restart virtuality-web
|
||||
```
|
||||
1. Раздел **Сервер** в панели показывает, чего не хватает (KVM, пакеты, мост). Раздел **Задачи** — журнал каждой операции. Раздел **Журналы** — панель, обновления, установка, виртуализация.
|
||||
2. Полная диагностика в терминале:
|
||||
|
||||
Полная повторная установка компонентов ноды:
|
||||
```bash
|
||||
sudo vhealth # проверка процессора, служб, сети, firewall, libvirt, дисков
|
||||
sudo virtuality-ctl status # версия, адрес панели, HTTPS, автообновление, хранилище
|
||||
```
|
||||
|
||||
```bash
|
||||
cd /opt/virtuality/source
|
||||
sudo bash install_virtuality_node.sh
|
||||
sudo bash scripts/install_web_panel.sh
|
||||
```
|
||||
3. Отчёт для поддержки — кнопка в «Настройках» или `sudo virtuality-ctl support-bundle`: архив с настройками (без секретов), журналами служб и последними задачами.
|
||||
4. Журналы:
|
||||
|
||||
```bash
|
||||
journalctl -u virtuality-web -f # панель
|
||||
sudo journalctl -fu virtuality-firstboot # первая загрузка после ISO
|
||||
cat /var/log/virtuality/firstboot.log # то же, файлом
|
||||
cat /var/log/virtuality/update.log # обновления
|
||||
ls /var/log/virtuality/operations/ # задачи: JSON + журнал каждой
|
||||
```
|
||||
|
||||
5. Для мониторинга: `GET /healthz` без авторизации отвечает `{"ok": true, "version": "..."}`.
|
||||
|
||||
Полезные команды libvirt: `virsh list --all`, `virsh net-list --all`, `virsh net-dhcp-leases virtuality-nat`, `virsh pool-list --all`.
|
||||
|
||||
---
|
||||
|
||||
## Полезные команды
|
||||
## Где что лежит
|
||||
|
||||
```bash
|
||||
sudo vhealth
|
||||
virsh list --all
|
||||
virsh pool-list --all
|
||||
virsh net-list --all
|
||||
virsh net-dhcp-leases virtuality-nat
|
||||
ip -br a
|
||||
ip route
|
||||
sudo nft list ruleset
|
||||
sudo ufw status
|
||||
systemctl status libvirtd --no-pager
|
||||
systemctl status cockpit.socket --no-pager
|
||||
systemctl status virtuality-web --no-pager
|
||||
journalctl -u virtuality-web -f
|
||||
```text
|
||||
/opt/virtuality/source # исходники (git), отсюда обновления
|
||||
/opt/virtuality/web # установленная панель (web.prev — предыдущая версия)
|
||||
/opt/virtuality/venv # Python virtualenv панели
|
||||
/var/lib/virtuality/iso # установочные ISO
|
||||
/var/lib/virtuality/disk-images # готовые образы систем (каталог, ссылки, загрузки)
|
||||
/var/lib/virtuality/images # диски машин (qcow2)
|
||||
/var/lib/virtuality/backups # резервные копии машин и архивы настроек
|
||||
/var/lib/virtuality/config # web.env, setup.json, wizard.json, host_profile.json, backup_schedule.json, tls/
|
||||
/var/lib/virtuality/network # port_forwards.json, virtuality-nat.xml
|
||||
/var/lib/virtuality/tmp # временные файлы загрузок и cloud-init
|
||||
/var/log/virtuality # firstboot.log, update.log, virtuality-ctl.log, install_*.log
|
||||
/var/log/virtuality/operations # задачи панели: JSON и журнал каждой
|
||||
/etc/virtuality/nftables/virtuality.nft # правила проброса портов
|
||||
/usr/local/bin/virtuality-ctl # управление нодой
|
||||
/usr/local/bin/vhealth # диагностика
|
||||
```
|
||||
|
||||
Порты: панель `8088` (HTTP) и `8443` (HTTPS), SSH `22`. VNC-порты машин наружу не открываются.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
## Разработка
|
||||
|
||||
- ARM64 cloud-image templates;
|
||||
- cloud-init для быстрых VM;
|
||||
- web-console/noVNC;
|
||||
- управление storage pools;
|
||||
- backup/snapshot manager;
|
||||
- сетевой менеджер bridge/VLAN;
|
||||
- роли и права пользователей;
|
||||
- журнал событий;
|
||||
- автообновление;
|
||||
- кластеризация.
|
||||
```bash
|
||||
pip install -r web/requirements.txt -r requirements-dev.txt
|
||||
make test # pytest (virsh и systemctl имитируются в tests/conftest.py)
|
||||
make lint # pyflakes + shellcheck
|
||||
make check # lint + test — запускать перед коммитом
|
||||
make iso # установочный ISO в dist/ (см. docs/IMAGE.md)
|
||||
```
|
||||
|
||||
Демо-панель без сервера виртуализации — с тремя машинами, задачами и загрузками:
|
||||
|
||||
```bash
|
||||
python3 tests/dev_server.py 8765 # http://127.0.0.1:8765, вход: tester / любой пароль
|
||||
VIRTUALITY_DEV_SETUP=installing python3 tests/dev_server.py # мастер на шаге «Установка»
|
||||
VIRTUALITY_DEV_SETUP=done python3 tests/dev_server.py # мастер после установки
|
||||
VIRTUALITY_DEV_SETUP=finished python3 tests/dev_server.py # настроенный сервер, страница «Настройки»
|
||||
```
|
||||
|
||||
Устройство кода описано в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Интерфейс: шаблоны `web/templates` (каркас `base.html`, компоненты `_ui.html`), стили `web/static/app.css`, поведение `web/static/panel.js`; шрифт и иконки лежат в `web/static`. Скриншоты страниц: `node tests/screenshots.mjs` (нужен Playwright).
|
||||
|
||||
Репозиторий живёт в Gitea StackWorks: https://git.stackworks.ru/viktor/virtuality (до сентября 2026 года был на GitHub; ноды со старым адресом переключаются на новый сами при первой проверке обновлений). Ветка `main` — рабочая ветка нод на канале `main`; выпуск для стабильного канала — тег `vX.Y.Z`. Раннер Actions не подключён: перед каждым пушем запускается `make check`, ISO собирается вручную `make iso`. Файлы `.github/workflows` (`ci.yml` — проверки на Python 3.11–3.14, `image.yml` — сборка ISO) Gitea тоже понимает, они заработают, если раннер появится.
|
||||
|
||||
---
|
||||
|
||||
## Лицензия
|
||||
|
||||
Рекомендуемая лицензия: **MIT License**.
|
||||
[MIT License](LICENSE).
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.9.6
|
||||
0.12.5
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ==========================================================
|
||||
# Virtuality One-command Bootstrap Installer
|
||||
# Public install entrypoint for clean Ubuntu/Debian servers.
|
||||
# ==========================================================
|
||||
|
||||
REPO_URL="${VIRTUALITY_REPO_URL:-https://github.com/viktor138irk/virtuality.git}"
|
||||
DEFAULT_INSTALL_USER="${SUDO_USER:-root}"
|
||||
INSTALL_USER="${VIRTUALITY_USER:-$DEFAULT_INSTALL_USER}"
|
||||
PROJECT_BASE_DIR="${VIRTUALITY_PROJECT_BASE_DIR:-/opt/virtuality}"
|
||||
PROJECT_DIR="${VIRTUALITY_PROJECT_DIR:-${PROJECT_BASE_DIR}/source}"
|
||||
RUN_BRIDGE="${VIRTUALITY_SETUP_BRIDGE:-0}"
|
||||
BRIDGE_IFACE="${VIRTUALITY_BRIDGE_IFACE:-}"
|
||||
RUN_TEST_VM="${VIRTUALITY_CREATE_TEST_VM:-0}"
|
||||
WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}"
|
||||
LOG_DIR="/var/log/virtuality"
|
||||
LOG_FILE="${LOG_DIR}/bootstrap_$(date +%Y%m%d_%H%M%S).log"
|
||||
MIN_ROOT_FREE_MB="${VIRTUALITY_MIN_ROOT_FREE_MB:-8192}"
|
||||
MIN_VAR_FREE_MB="${VIRTUALITY_MIN_VAR_FREE_MB:-20480}"
|
||||
MIN_RAM_MB="${VIRTUALITY_MIN_RAM_MB:-4096}"
|
||||
MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}"
|
||||
SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}"
|
||||
|
||||
ESC="\033"
|
||||
RESET="${ESC}[0m"
|
||||
BOLD="${ESC}[1m"
|
||||
GREEN="${ESC}[32m"
|
||||
YELLOW="${ESC}[33m"
|
||||
RED="${ESC}[31m"
|
||||
CYAN="${ESC}[36m"
|
||||
BLUE="${ESC}[34m"
|
||||
GRAY="${ESC}[90m"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
timestamp() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||
log() { echo "[$(timestamp)] $*" >> "$LOG_FILE"; }
|
||||
ok() { echo -e " ${GREEN}✓${RESET} $*"; log "OK: $*"; }
|
||||
warn() { echo -e " ${YELLOW}!${RESET} $*"; log "WARN: $*"; }
|
||||
fail() { echo -e " ${RED}✗${RESET} $*"; log "ERROR: $*"; echo; echo -e "${RED}${BOLD}Bootstrap остановлен.${RESET} Лог: ${LOG_FILE}"; exit 1; }
|
||||
step() { echo; echo -e "${BLUE}${BOLD}▶${RESET} ${BOLD}$*${RESET}"; log "STEP: $*"; }
|
||||
|
||||
run_logged() {
|
||||
local desc="$1"
|
||||
shift
|
||||
local start_ts pid code spinner elapsed symbol i
|
||||
log "RUN: $*"
|
||||
start_ts="$(date +%s)"
|
||||
"$@" >> "$LOG_FILE" 2>&1 &
|
||||
pid=$!
|
||||
spinner=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
|
||||
i=0
|
||||
while kill -0 "$pid" 2>/dev/null; do
|
||||
elapsed=$(( $(date +%s) - start_ts ))
|
||||
symbol="${spinner[$((i % ${#spinner[@]}))]}"
|
||||
printf "\r ${CYAN}%s${RESET} %s... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "$symbol" "$desc" "$elapsed" "$LOG_FILE"
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
done
|
||||
wait "$pid"
|
||||
code=$?
|
||||
printf "\r%*s\r" 120 ""
|
||||
if [[ "$code" -eq 0 ]]; then
|
||||
ok "$desc"
|
||||
else
|
||||
fail "$desc"
|
||||
fi
|
||||
}
|
||||
|
||||
header() {
|
||||
clear 2>/dev/null || true
|
||||
echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
|
||||
echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Bootstrap Installer${RESET} ${CYAN}${BOLD}│${RESET}"
|
||||
echo -e "${CYAN}${BOLD}│${RESET} One-command KVM / libvirt / Cockpit / Web Panel setup ${CYAN}${BOLD}│${RESET}"
|
||||
echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||
echo
|
||||
echo -e "${GRAY}Repository:${RESET} ${REPO_URL}"
|
||||
echo -e "${GRAY}Auth/install user:${RESET} ${INSTALL_USER}"
|
||||
echo -e "${GRAY}Project dir:${RESET} ${PROJECT_DIR}"
|
||||
echo -e "${GRAY}Log:${RESET} ${LOG_FILE}"
|
||||
echo
|
||||
}
|
||||
|
||||
require_root() {
|
||||
[[ "$EUID" -eq 0 ]] || fail "Запусти от root: curl ... | sudo bash"
|
||||
}
|
||||
|
||||
free_mb_for_path() {
|
||||
local path="$1"
|
||||
mkdir -p "$path" 2>/dev/null || true
|
||||
df -Pm "$path" | awk 'NR==2 {print $4}'
|
||||
}
|
||||
|
||||
check_password_state() {
|
||||
local user="$1"
|
||||
local hash
|
||||
hash="$(getent shadow "$user" | cut -d: -f2 || true)"
|
||||
if [[ -z "$hash" || "$hash" == "!" || "$hash" == "*" || "$hash" == "!!" ]]; then
|
||||
warn "У пользователя ${user} пароль не задан или заблокирован. Для входа в панель выполни: passwd ${user}"
|
||||
else
|
||||
ok "У пользователя ${user} есть пароль для входа в панель"
|
||||
fi
|
||||
}
|
||||
|
||||
check_requirements() {
|
||||
step "Проверяем минимальные системные требования"
|
||||
if [[ "$SKIP_REQUIREMENTS" == "1" ]]; then
|
||||
warn "Проверка требований отключена через VIRTUALITY_SKIP_REQUIREMENTS=1"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local root_free var_free ram_mb cpu_cores
|
||||
root_free="$(free_mb_for_path /)"
|
||||
var_free="$(free_mb_for_path /var/lib 2>/dev/null || free_mb_for_path /)"
|
||||
ram_mb="$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo)"
|
||||
cpu_cores="$(nproc 2>/dev/null || echo 1)"
|
||||
|
||||
if (( root_free >= MIN_ROOT_FREE_MB )); then
|
||||
ok "Свободно на /: ${root_free} MB минимум ${MIN_ROOT_FREE_MB} MB"
|
||||
else
|
||||
fail "Недостаточно места на /: ${root_free} MB. Нужно минимум ${MIN_ROOT_FREE_MB} MB. Очисти диск: apt clean; journalctl --vacuum-time=3d"
|
||||
fi
|
||||
|
||||
if (( var_free >= MIN_VAR_FREE_MB )); then
|
||||
ok "Свободно для /var/lib: ${var_free} MB минимум ${MIN_VAR_FREE_MB} MB"
|
||||
else
|
||||
fail "Недостаточно места для VM-хранилища /var/lib: ${var_free} MB. Нужно минимум ${MIN_VAR_FREE_MB} MB"
|
||||
fi
|
||||
|
||||
if (( ram_mb >= MIN_RAM_MB )); then
|
||||
ok "RAM: ${ram_mb} MB минимум ${MIN_RAM_MB} MB"
|
||||
else
|
||||
warn "RAM: ${ram_mb} MB меньше рекомендуемых ${MIN_RAM_MB} MB. Установка возможна, но VM будут ограничены"
|
||||
fi
|
||||
|
||||
if (( cpu_cores >= MIN_CPU_CORES )); then
|
||||
ok "CPU cores: ${cpu_cores} минимум ${MIN_CPU_CORES}"
|
||||
else
|
||||
warn "CPU cores: ${cpu_cores} меньше рекомендуемых ${MIN_CPU_CORES}"
|
||||
fi
|
||||
|
||||
if grep -E -q '(vmx|svm)' /proc/cpuinfo; then
|
||||
ok "CPU virtualization vmx/svm найдена"
|
||||
else
|
||||
warn "CPU virtualization vmx/svm не найдена. Проверь BIOS/UEFI или настройки VPS"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_user() {
|
||||
if [[ "$INSTALL_USER" == "root" ]]; then
|
||||
ok "Пользователь авторизации: root"
|
||||
check_password_state root
|
||||
return 0
|
||||
fi
|
||||
|
||||
if id "$INSTALL_USER" >/dev/null 2>&1; then
|
||||
ok "Пользователь уже существует: ${INSTALL_USER}"
|
||||
else
|
||||
run_logged "Создан пользователь ${INSTALL_USER}" adduser --disabled-password --gecos "" "$INSTALL_USER"
|
||||
warn "Пароль для ${INSTALL_USER} не задан. После установки выполни: sudo passwd ${INSTALL_USER}"
|
||||
fi
|
||||
usermod -aG sudo "$INSTALL_USER" >> "$LOG_FILE" 2>&1 || true
|
||||
ok "Пользователь ${INSTALL_USER} добавлен в sudo"
|
||||
check_password_state "$INSTALL_USER"
|
||||
}
|
||||
|
||||
run_as_user() {
|
||||
if [[ "$INSTALL_USER" == "root" ]]; then
|
||||
bash -lc "$*"
|
||||
else
|
||||
sudo -H -u "$INSTALL_USER" bash -lc "$*"
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_project_dir() {
|
||||
mkdir -p "$PROJECT_BASE_DIR"
|
||||
if [[ "$INSTALL_USER" == "root" ]]; then
|
||||
chown root:root "$PROJECT_BASE_DIR"
|
||||
else
|
||||
chown "$INSTALL_USER:$INSTALL_USER" "$PROJECT_BASE_DIR"
|
||||
fi
|
||||
chmod 755 "$PROJECT_BASE_DIR"
|
||||
ok "Рабочая директория подготовлена: ${PROJECT_BASE_DIR}"
|
||||
}
|
||||
|
||||
header
|
||||
|
||||
step "Проверяем root и apt"
|
||||
require_root
|
||||
command -v apt >/dev/null 2>&1 || fail "apt не найден. Нужен Debian/Ubuntu"
|
||||
ok "root подтверждён"
|
||||
ok "apt найден"
|
||||
|
||||
check_requirements
|
||||
|
||||
step "Устанавливаем базовые пакеты"
|
||||
run_logged "apt update выполнен" apt update
|
||||
run_logged "Установлены sudo/git/curl/ca-certificates" apt install -y sudo git curl ca-certificates openssh-client nano htop
|
||||
|
||||
step "Создаём или проверяем пользователя"
|
||||
ensure_user
|
||||
|
||||
step "Готовим рабочую директорию в /opt"
|
||||
prepare_project_dir
|
||||
|
||||
step "Проверяем место после подготовки пользователя"
|
||||
root_free_after="$(free_mb_for_path /)"
|
||||
if (( root_free_after < MIN_ROOT_FREE_MB )); then
|
||||
fail "После подготовки пользователя свободного места стало мало: ${root_free_after} MB на /. Очисти диск и повтори установку"
|
||||
fi
|
||||
ok "Место после подготовки пользователя: ${root_free_after} MB на /"
|
||||
|
||||
step "Клонируем или обновляем репозиторий"
|
||||
if [[ -d "$PROJECT_DIR/.git" ]]; then
|
||||
run_logged "Репозиторий обновлён" run_as_user "cd '$PROJECT_DIR' && git reset --hard origin/main && git pull"
|
||||
else
|
||||
run_logged "Репозиторий склонирован" run_as_user "cd '$PROJECT_BASE_DIR' && git clone '$REPO_URL' source"
|
||||
fi
|
||||
|
||||
step "Запускаем основной установщик ноды"
|
||||
run_logged "Virtuality Node установлен" bash "$PROJECT_DIR/install_virtuality_node.sh"
|
||||
|
||||
step "Устанавливаем healthcheck-команду"
|
||||
run_logged "Healthcheck установлен" bash "$PROJECT_DIR/scripts/install_healthcheck_command.sh"
|
||||
|
||||
step "Устанавливаем консольный dashboard"
|
||||
run_logged "Console dashboard установлен" bash "$PROJECT_DIR/scripts/install_console_dashboard.sh"
|
||||
|
||||
step "Устанавливаем web-панель"
|
||||
VIRTUALITY_AUTH_USER="$INSTALL_USER" VIRTUALITY_WEB_PORT="$WEB_PORT" bash "$PROJECT_DIR/scripts/install_web_panel.sh" >> "$LOG_FILE" 2>&1 &
|
||||
web_pid=$!
|
||||
web_start="$(date +%s)"
|
||||
web_i=0
|
||||
web_spinner=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
|
||||
while kill -0 "$web_pid" 2>/dev/null; do
|
||||
web_elapsed=$(( $(date +%s) - web_start ))
|
||||
printf "\r ${CYAN}%s${RESET} Web-панель устанавливается... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "${web_spinner[$((web_i % ${#web_spinner[@]}))]}" "$web_elapsed" "$LOG_FILE"
|
||||
sleep 1
|
||||
web_i=$((web_i + 1))
|
||||
done
|
||||
wait "$web_pid"
|
||||
web_code=$?
|
||||
printf "\r%*s\r" 120 ""
|
||||
[[ "$web_code" -eq 0 ]] && ok "Web-панель установлена" || fail "Web-панель не установлена"
|
||||
|
||||
if [[ "$RUN_BRIDGE" == "1" ]]; then
|
||||
step "Настраиваем bridge br0"
|
||||
if [[ -z "$BRIDGE_IFACE" ]]; then
|
||||
BRIDGE_IFACE="$(ip route | awk '/default/ {print $5; exit}')"
|
||||
fi
|
||||
if [[ -z "$BRIDGE_IFACE" ]]; then
|
||||
warn "Не удалось определить интерфейс для br0. Пропускаю bridge setup"
|
||||
else
|
||||
warn "Bridge меняет сеть. Если SSH зависнет, нужен физический доступ или консоль провайдера."
|
||||
bash "$PROJECT_DIR/scripts/setup_bridge_br0.sh" "$BRIDGE_IFACE" static >> "$LOG_FILE" 2>&1 && ok "Bridge br0 настроен на ${BRIDGE_IFACE}" || warn "Bridge setup завершился с предупреждением. Проверь лог"
|
||||
fi
|
||||
else
|
||||
warn "Bridge br0 не настраивался автоматически. Запуск вручную: sudo bash scripts/setup_bridge_br0.sh INTERFACE static"
|
||||
fi
|
||||
|
||||
if [[ "$RUN_TEST_VM" == "1" ]]; then
|
||||
step "Создаём тестовую VM"
|
||||
run_logged "Тестовая VM создана" bash "$PROJECT_DIR/scripts/create_test_vm.sh" || warn "Тестовая VM не создана. Проверь ISO/bridge/log"
|
||||
fi
|
||||
|
||||
step "Финальная диагностика"
|
||||
if command -v vhealth >/dev/null 2>&1; then
|
||||
vhealth >> "$LOG_FILE" 2>&1 && ok "vhealth: OK" || warn "vhealth вернул warnings/errors. Проверь: sudo vhealth"
|
||||
else
|
||||
warn "Команда vhealth не найдена"
|
||||
fi
|
||||
|
||||
SERVER_IP="$(hostname -I | awk '{print $1}')"
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
|
||||
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Virtuality Bootstrap завершён${RESET} ${GREEN}${BOLD}│${RESET}"
|
||||
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||
echo
|
||||
echo -e "${BOLD}Cockpit:${RESET} https://${SERVER_IP}:9090"
|
||||
echo -e "${BOLD}Web Panel:${RESET} http://${SERVER_IP}:${WEB_PORT}"
|
||||
echo -e "${BOLD}Login:${RESET} ${INSTALL_USER} / пароль Linux-пользователя"
|
||||
echo -e "${BOLD}Project:${RESET} ${PROJECT_DIR}"
|
||||
echo -e "${BOLD}Log:${RESET} ${LOG_FILE}"
|
||||
echo
|
||||
echo -e "${BOLD}После установки:${RESET}"
|
||||
echo " passwd ${INSTALL_USER} # если пароль ещё не задан или заблокирован"
|
||||
echo " sudo vhealth"
|
||||
echo " cd ${PROJECT_DIR}"
|
||||
echo " ip -br a"
|
||||
echo
|
||||
echo -e "${YELLOW}Важно:${RESET} bridge br0 по умолчанию не включается в one-command режиме, чтобы не уронить SSH."
|
||||
echo "Для bridge: cd ${PROJECT_DIR} && sudo bash scripts/setup_bridge_br0.sh INTERFACE static"
|
||||
echo
|
||||
@@ -1,122 +1,176 @@
|
||||
# Архитектура Virtuality
|
||||
|
||||
Virtuality — это платформа виртуализации в стиле Proxmox, но с более компактной и контролируемой архитектурой.
|
||||
|
||||
## Цель
|
||||
|
||||
Создать собственную систему управления виртуальными машинами на базе стабильных Linux-компонентов:
|
||||
|
||||
- KVM;
|
||||
- QEMU;
|
||||
- libvirt;
|
||||
- Cockpit на раннем этапе;
|
||||
- собственная веб-панель на следующих этапах.
|
||||
|
||||
## Базовая схема
|
||||
Virtuality — одна нода: сервер с KVM/QEMU/libvirt, поверх которого работает панель на FastAPI. Панель не хранит собственную базу данных: источник правды о машинах — libvirt (`virsh`), о ноде — файлы в `/var/lib/virtuality/config`, о задачах — файлы в `/var/log/virtuality/operations`. Всё, что меняет сам сервер (службы, firewall, сеть, диски), делает один скрипт — `virtuality-ctl`, и панель вызывает его же.
|
||||
|
||||
```text
|
||||
Веб-интерфейс
|
||||
↓
|
||||
Backend API
|
||||
↓
|
||||
Node services
|
||||
↓
|
||||
libvirt / QEMU / KVM
|
||||
↓
|
||||
Host OS / Storage / Network
|
||||
Браузер
|
||||
│ HTTP 8088 / HTTPS 8443
|
||||
▼
|
||||
virtuality-web.service — uvicorn, web/app.py (FastAPI, Jinja2)
|
||||
├── web/features/* мастер, настройки, снимки, копии, загрузки, операции с машиной
|
||||
├── web/core.py общие примитивы, хранилище задач
|
||||
├── web/nodectl.py ───────► /usr/local/bin/virtuality-ctl (systemd, TLS, ufw, br0, диск, пароль)
|
||||
├── web/network_core.py ──► virsh net-*, nft, ufw, iptables (сеть NAT и пробросы)
|
||||
└── virsh / virt-install / virt-clone / qemu-img ──► libvirtd ──► QEMU/KVM
|
||||
```
|
||||
|
||||
## Слои системы
|
||||
---
|
||||
|
||||
### 1. Web UI
|
||||
## Код панели: `web/`
|
||||
|
||||
Будущая пользовательская панель Virtuality.
|
||||
| Модуль | Назначение |
|
||||
|---|---|
|
||||
| `app.py` | Основное приложение: вход, обзор, машины (создание, действия, ресурсы, порядок загрузки, привод), ISO и образы дисков (загрузка, конвертация, распаковка), сеть и пробросы, обновления, задачи, журналы, экран машины (WebSocket-прокси к VNC), `/healthz`. В конце подключает роутеры из `features/`. |
|
||||
| `core.py` | Общие примитивы: настройки из `.env`, сессии и авторизация (`require_auth`, `require_api_auth`, защита от cross-origin POST, ограничение попыток входа), `run_cmd`, `render` и окружение шаблонов, `valid_vm_name`, хранилище задач (`new_operation`, `run_operation`, `update_operation`, `finish_operation`, `active_operations_for`, `running_operations`, `interrupt_orphaned_operations`). Импорт `core` выставляет `LC_ALL=C.UTF-8`: вывод `virsh` разбирается панелью и не должен переводиться. |
|
||||
| `auth.py` | Проверка пароля Linux-пользователя по `/etc/shadow`: через `crypt`/`spwd`, а на Python 3.13+ — через `libcrypt` (ctypes). Ограничение попыток входа. |
|
||||
| `features/setup.py` | Мастер первичной настройки `/setup`: шаги install → welcome → account → access → network → storage → updates → finish, страница «Применяем настройки», ожидание перестройки сети в мост. |
|
||||
| `features/settings.py` | Страница «Настройки»: доступ (HTTPS/порт), пароль, часовой пояс, обновления, сеть машин, хранилище, перезагрузка/выключение, архив настроек, отчёт для поддержки. |
|
||||
| `features/snapshots.py` | Снимки: внутренние снимки libvirt (`virsh snapshot-*`), до 8 на машину, создание и возврат — фоновые задачи. |
|
||||
| `features/backups.py` | Резервные копии: `vm.xml` + копии дисков + `meta.json` в `backups/<машина>/<ГГГГММДД-ЧЧММ>/`; восстановление под тем же или новым именем; автокопии помечены `auto: true`. |
|
||||
| `features/backup_schedule.py` | Копии по расписанию: настройка `config/backup_schedule.json` (каждый день или по дню недели, время, машины, сколько хранить), итог запуска `config/backup_schedule_state.json`. Копирует служба `virtuality-backup.service` (таймер раз в 15 минут), а не панель, поэтому перезапуск панели копию не обрывает. Пропущенное время догоняется только в течение 2 часов; после удачной копии у машины остаются последние N автокопий, ручные не удаляются. |
|
||||
| `features/downloads.py` | Скачивание ISO и образов по ссылке и из каталога — фоновые задачи типа `download` с прогрессом, проверкой контрольной суммы и отменой. |
|
||||
| `features/vmops.py` | Увеличение диска, клонирование (`virt-clone`), пауза, спящий режим (`virsh managedsave`), заметки (title/description машины), живая нагрузка (`virsh domstats`). |
|
||||
| `nodectl.py` | Факты о ноде (сеть, диски, оборудование, часовые пояса, состояние первой загрузки, выбор мастера) и действия через `virtuality-ctl`. Действия, которые перезапускают панель, запускаются отложенно через `systemd-run`. |
|
||||
| `network_core.py` | Сеть NAT `virtuality-nat`, резервации DHCP, пробросы портов и их применение (nftables, ufw, iptables). |
|
||||
| `catalog.py` | Каталог облачных образов (Ubuntu 26.04/24.04, Debian 13, Alpine 3.23 для x86_64 и aarch64) и типы ОС для установки с ISO (`osinfo` для `virt-install`). |
|
||||
| `cloudinit.py` | Seed cloud-init для машин из готовых образов: `user-data` (пользователь, хеш пароля SHA-512, SSH-ключи, рост диска, гостевой агент) и `meta-data`; подключается к `virt-install --cloud-init`. |
|
||||
| `host_profile.py` | Профиль сервера (`x86_64`, `raspberry-arm64`, `orangepi5-arm64`, `generic-arm64`), проверки готовности; хранится в `config/host_profile.json`. |
|
||||
| `update_core.py` | Проверка обновлений: канал, целевой ref, список пропущенных версий из `updates/versions.json`, запуск `scripts/apply_github_update.sh` через `systemd-run`. |
|
||||
| `presenters.py` | Человеческие названия состояний, профили ресурсов машин, список типовых портов, форматирование размеров. |
|
||||
| `restore_network.py` | Повторное применение пробросов после загрузки (`virtuality-network.service`). |
|
||||
| `redirect_http.py` | Перенаправление HTTP → HTTPS на порту 8088, когда включён TLS (`virtuality-redirect.service`). |
|
||||
| `templates/`, `static/` | Jinja2-шаблоны (`base.html` — каркас, `_ui.html` — компоненты), `app.css` (светлая и тёмная темы), `panel.js`, шрифт и иконки. Панель не ходит в интернет. |
|
||||
|
||||
Задачи:
|
||||
Правило для роутов модулей: POST-маршруты вида `/vm/{name}/<модуль>/<действие>` — минимум четыре сегмента, иначе их перехватит общий `@app.post("/vm/{name}/{action}")`.
|
||||
|
||||
- список виртуальных машин;
|
||||
- создание VM;
|
||||
- управление питанием;
|
||||
- консоль;
|
||||
- хранилища;
|
||||
- сеть;
|
||||
- бэкапы;
|
||||
- пользователи и роли.
|
||||
### Фоновые задачи
|
||||
|
||||
### 2. Backend API
|
||||
Долгие операции (создание машины, конвертация образа, скачивание, снимок, копия, клон, спящий режим) выполняются в потоках и записываются в `/var/log/virtuality/operations/<id>.json` (статус, прогресс, сообщение, метаданные) и `<id>.log` (журнал). Раздел «Задачи» и страницы машин читают эти файлы; `active_operations_for(vm, kinds)` защищает машину от двух одновременных операций. У каждой задачи записан процесс-владелец (`owner`: pid, время его запуска и boot id). При старте панели `interrupt_orphaned_operations()` закрывает задачи, оставшиеся в состоянии «выполняется», если их процесс уже не жив; задачи живого чужого процесса (копии по расписанию из `virtuality-backup.service`) не трогает. `virtuality-ctl maintenance` удаляет задачи старше 30 дней и оставляет не больше 300.
|
||||
|
||||
Единая точка управления нодой.
|
||||
---
|
||||
|
||||
Задачи:
|
||||
## Управление нодой: `scripts/virtuality-ctl`
|
||||
|
||||
- принимать команды от Web UI;
|
||||
- валидировать действия;
|
||||
- работать с libvirt;
|
||||
- вести журнал событий;
|
||||
- отдавать состояние VM, storage и сети.
|
||||
Один bash-скрипт, устанавливается в `/usr/local/bin`. Панель (через `nodectl.py`) и администратор в терминале делают одно и то же:
|
||||
|
||||
### 3. Node services
|
||||
- **`reconfigure`** — читает `/var/lib/virtuality/config/web.env` и пересоздаёт systemd-юниты, logrotate, `/etc/default/libvirt-guests`, правила ufw; переводит VNC старых машин на `127.0.0.1`; перезапускает панель.
|
||||
- **`set KEY=VALUE`** — проверяет и сохраняет настройку в `web.env`, затем `reconfigure` (для настроек обновления — только перезапись таймера).
|
||||
- **TLS** — при `VIRTUALITY_TLS=1` создаёт самоподписанный сертификат EC P-256 на 10 лет в `config/tls/panel.{key,crt}` (SAN: имя сервера, localhost, адрес), uvicorn слушает `VIRTUALITY_TLS_PORT` (8443), а `virtuality-redirect.service` перенаправляет с HTTP-порта.
|
||||
- **Мост `br0`** — `bridge <iface> [dhcp|static]` делает копию `/etc/netplan`, пишет `60-virtuality-br0.yaml`, проверяет `netplan generate`, взводит таймер `virtuality-netplan-revert` на 120 секунд и применяет; `bridge-confirm` снимает таймер, `bridge-revert` возвращает копию. Панель после перестройки сети сама вызывает подтверждение, когда снова получает ответ от сервера.
|
||||
- **`storage-use /dev/sdX`** — только пустой диск без разделов: `mkfs.ext4`, перенос текущего содержимого `/var/lib/virtuality`, запись в `/etc/fstab` (`nofail`), монтирование.
|
||||
- **`passwd`, `timezone`, `power`, `backup-config`, `restore-config`, `rollback`, `maintenance`, `support-bundle`, `uninstall`** — см. README.
|
||||
|
||||
Системные сервисы Virtuality.
|
||||
Журнал действий: `/var/log/virtuality/virtuality-ctl.log`.
|
||||
|
||||
Задачи:
|
||||
---
|
||||
|
||||
- диагностика ноды;
|
||||
- обновления;
|
||||
- резервное копирование;
|
||||
- мониторинг;
|
||||
- фоновые задачи.
|
||||
## Службы systemd
|
||||
|
||||
### 4. libvirt / QEMU / KVM
|
||||
Создаются `virtuality-ctl reconfigure`:
|
||||
|
||||
Нижний слой виртуализации.
|
||||
| Юнит | Что делает |
|
||||
|---|---|
|
||||
| `virtuality-web.service` | Панель: `uvicorn app:app` от root из `/opt/virtuality/web` с venv `/opt/virtuality/venv`; `Restart=always`. |
|
||||
| `virtuality-redirect.service` | Только при TLS: HTTP → HTTPS. |
|
||||
| `virtuality-network.service` | При загрузке заново применяет пробросы портов (`restore_network.py`). |
|
||||
| `virtuality-auto-update.timer` / `.service` | Ночная проверка обновлений (`scripts/auto_update_check.sh`), 04:00 ± 30 минут; включён при `VIRTUALITY_AUTO_UPDATE=1`. |
|
||||
| `virtuality-backup.timer` / `.service` | Раз в 15 минут запускает `features/backup_schedule.py`: если подошло время по расписанию, копирует машины по очереди и удаляет лишние автокопии. Итог пишется в журнал службы и показывается на странице «Резервные копии». |
|
||||
| `virtuality-maintenance.timer` / `.service` | Ежедневное обслуживание: старые задачи, временные файлы, недокачанные загрузки, `journalctl --vacuum-size=300M`, запись занятости диска в `config/health.json`. |
|
||||
| `libvirt-guests.service` | При выключении сервера машины выключаются корректно (`ON_SHUTDOWN=shutdown`, до 180 секунд), при загрузке не запускаются сами — только те, у кого включён автозапуск. |
|
||||
|
||||
Задачи:
|
||||
Отдельно, из образа: `virtuality-firstboot.service` — первая загрузка (см. [IMAGE.md](IMAGE.md)); временные юниты `virtuality-netplan-revert` (автооткат моста), `virtuality-apply-*` (отложенное применение настроек) и `virtuality-update-*` (обновление вне cgroup панели, чтобы перезапуск панели не убил сам процесс обновления).
|
||||
|
||||
- запуск виртуальных машин;
|
||||
- управление дисками;
|
||||
- управление сетевыми интерфейсами;
|
||||
- снапшоты;
|
||||
- консольный доступ.
|
||||
---
|
||||
|
||||
### 5. Host OS
|
||||
## Безопасность панели
|
||||
|
||||
Операционная система сервера.
|
||||
- Вход — по имени и паролю системного пользователя (`VIRTUALITY_AUTH_USER`); проверяется по `/etc/shadow`, поэтому панель работает от root. После 5 неверных паролей адрес блокируется на 5 минут; сессия (подписанная cookie, ключ в `config/session_secret`) живёт 12 часов.
|
||||
- POST-запросы принимаются только с того же origin; за reverse proxy нужно передавать исходный `Host` (и `Upgrade`/`Connection` для экрана машины).
|
||||
- Экран машины: VNC у всех машин слушает `127.0.0.1`, панель проксирует его в WebSocket по подписанному токену и только при действующей сессии. Порты 5900–5999 в ufw закрыты.
|
||||
- Пароль cloud-init хранится только как хеш SHA-512; секреты не попадают в журналы и отчёт для поддержки.
|
||||
- Firewall: `install_virtuality_node.sh` включает ufw и разрешает SSH; `virtuality-ctl` открывает только порт панели (и HTTPS-порт), список открытых портов хранит в `config/ports.state`.
|
||||
|
||||
Целевые системы:
|
||||
---
|
||||
|
||||
- Debian 12;
|
||||
- Ubuntu Server 22.04;
|
||||
- Ubuntu Server 24.04.
|
||||
## Сеть машин
|
||||
|
||||
## Пути
|
||||
### NAT: `virtuality-nat`
|
||||
|
||||
libvirt-сеть с мостом `virbr100`, подсеть `192.168.100.0/24`, шлюз `192.168.100.1`, DHCP `192.168.100.50–200`. Описание сохраняется в `/var/lib/virtuality/network/virtuality-nat.xml`. Создаётся мастером, страницей «Сеть и доступ» или автоматически при создании первой машины в режиме NAT. Включается `net.ipv4.ip_forward` (`/etc/sysctl.d/99-virtuality-forward.conf`) и ослабляется rp_filter (`98-virtuality-rpfilter.conf`).
|
||||
|
||||
**Постоянные адреса.** Для каждой машины MAC-адрес вычисляется из её имени (`stable_mac`), и в сети создаётся резервация DHCP (`virsh net-update add ip-dhcp-host`) — машина всегда получает один и тот же адрес, панель знает его ещё до первого запуска. При удалении машины резервация освобождается.
|
||||
|
||||
**Пробросы портов** хранятся в `/var/lib/virtuality/network/port_forwards.json` и применяются тремя способами сразу, чтобы работать на любом дистрибутиве:
|
||||
|
||||
1. nftables — таблица `ip virtuality` в `/etc/virtuality/nftables/virtuality.nft` (DNAT, forward, masquerade);
|
||||
2. ufw — правила `route allow`, список сохранённых правил в `network/ufw_rules.json`;
|
||||
3. iptables — запасной вариант, все правила помечены комментарием `virtuality-forward`, чтобы их можно было найти и удалить.
|
||||
|
||||
Адрес машины определяется через гостевой агент, `virsh domifaddr`, аренды DHCP по MAC машины, либо задаётся вручную. «Проверить» рядом с правилом делает TCP-подключение к машине и показывает команды для диагностики.
|
||||
|
||||
### Домашняя сеть: мост `br0`
|
||||
|
||||
Сервер объединяет свой проводной интерфейс в мост `br0` (netplan), машины подключаются к нему и получают адреса от роутера. Wi-Fi в мост объединить нельзя — тогда доступен только NAT. Как устроен автооткат — выше, в разделе про `virtuality-ctl`.
|
||||
|
||||
---
|
||||
|
||||
## Хранилище
|
||||
|
||||
```text
|
||||
/opt/virtuality # код и конфигурация проекта
|
||||
/var/lib/virtuality # runtime-хранилище
|
||||
/var/lib/virtuality/iso # ISO-образы
|
||||
/var/lib/virtuality/images # диски VM
|
||||
/var/lib/virtuality/backups # резервные копии
|
||||
/var/log/virtuality # логи
|
||||
/var/lib/virtuality/
|
||||
├── iso/ установочные образы; libvirt-пул virtuality-iso
|
||||
├── images/ диски машин <имя>.qcow2; libvirt-пул virtuality-images
|
||||
├── disk-images/ готовые образы систем (каталог, ссылки, загрузки с компьютера)
|
||||
├── backups/ резервные копии машин (<машина>/<ГГГГММДД-ЧЧММ>/) и архивы настроек config-*.tar.gz
|
||||
├── config/ web.env, setup.json, setup_done, wizard.json, host_profile.json, backup_schedule*.json,
|
||||
│ session_secret, ports.state, health.json, tls/, netplan-backup/
|
||||
├── network/ port_forwards.json, ufw_rules.json, virtuality-nat.xml
|
||||
├── update/ state.json, last_check.json, временные файлы обновления
|
||||
└── tmp/ загрузки (.<имя>.part, .<имя>.uploading), seed cloud-init; TMPDIR панели
|
||||
```
|
||||
|
||||
## Принципы
|
||||
Мастер и «Настройки» умеют отдать под всё это отдельный пустой диск — он монтируется в `/var/lib/virtuality` целиком. Память «спящих» машин libvirt хранит в `/var/lib/libvirt/qemu/save`.
|
||||
|
||||
- исходный код отдельно от runtime-данных;
|
||||
- диски VM и ISO не хранятся в Git;
|
||||
- конфиги должны быть прозрачными;
|
||||
- установщик должен быть повторяемым;
|
||||
- сетевые изменения делаются осторожно, чтобы не уронить SSH;
|
||||
- архитектура документируется до активной разработки.
|
||||
Код и служебные файлы:
|
||||
|
||||
## Этап v0.1
|
||||
```text
|
||||
/opt/virtuality/source git-репозиторий, из него ставится панель и берутся обновления
|
||||
/opt/virtuality/web установленная панель (+ .env с пользователем и ключом сессий)
|
||||
/opt/virtuality/web.prev предыдущая версия для virtuality-ctl rollback
|
||||
/opt/virtuality/venv virtualenv
|
||||
/opt/virtuality/virtuality.env пути ноды (пишет install_virtuality_node.sh)
|
||||
/etc/virtuality/image.env параметры образа (только после установки с ISO)
|
||||
/etc/virtuality/nftables/ правила пробросов
|
||||
/var/log/virtuality/ firstboot.log, update.log, virtuality-ctl.log, install_*.log, operations/
|
||||
```
|
||||
|
||||
На этапе v0.1 создаётся фундамент:
|
||||
---
|
||||
|
||||
- установщик ноды;
|
||||
- установка KVM/QEMU/libvirt;
|
||||
- установка Cockpit;
|
||||
- storage pools;
|
||||
- структура проекта;
|
||||
- GitHub-синхронизация;
|
||||
- базовая диагностика.
|
||||
## Обновления
|
||||
|
||||
Ноды обновляются из `origin` своего `/opt/virtuality/source` — по умолчанию https://git.stackworks.ru/viktor/virtuality.git. Проект переехал туда с GitHub в сентябре 2026 года; ноду со старым адресом GitHub `update_core.migrate_remote()` и `migrate_update_remote` в `scripts/update_target.sh` переключают на новый перед `git fetch`.
|
||||
|
||||
1. **Канал** (`VIRTUALITY_UPDATE_CHANNEL` в `web.env`): `stable` — последний тег `vX.Y.Z` (пока тегов нет — ветка `main`); `main` — ветка `main`. Логика одна и та же в `web/update_core.py` и `scripts/update_target.sh`.
|
||||
2. **Проверка** (`/update/check`, ночной `scripts/auto_update_check.sh`): `git fetch --tags`, сравнение текущего коммита с целевым; обновление предлагается, только если целевой коммит — потомок текущего (нода никогда не откатывается сама). Список изменений берётся из `updates/versions.json` целевой версии.
|
||||
3. **Применение** (`scripts/apply_github_update.sh`, запускается через `systemd-run`): `git checkout -B main <цель>` и `git clean`; если git недоступен — скачивание ZIP-архива того же репозитория (`<адрес>/archive/<ветка или тег>.zip`, так отдают и Gitea, и GitHub) и `rsync`; затем `scripts/install_web_panel.sh` (панель копируется в `web.new`, старая переименовывается в `web.prev`, venv обновляется) и `systemctl restart virtuality-web`. Состояние — `/var/lib/virtuality/update/state.json`, журнал — `/var/log/virtuality/update.log`.
|
||||
4. **Откат**: `virtuality-ctl rollback` меняет местами `web` и `web.prev`.
|
||||
|
||||
Установщики и скрипты вне `web/`:
|
||||
|
||||
```text
|
||||
install.sh установка одной командой (проверки, пакеты, пользователь, клон, три установщика ниже)
|
||||
install_virtuality_node.sh пакеты KVM/QEMU/libvirt (x86_64 или ARM64), пулы, ufw, группы пользователя
|
||||
scripts/install_web_panel.sh панель, venv (офлайн из wheels/ при наличии), virtuality-ctl, юниты
|
||||
scripts/install_healthcheck_command.sh команда vhealth (scripts/virtuality_healthcheck.sh)
|
||||
scripts/detect_host_profile.sh профиль сервера для установщика
|
||||
scripts/virtuality_firstboot.sh первая загрузка после ISO
|
||||
scripts/auto_update_check.sh, apply_github_update.sh, update_target.sh обновления
|
||||
scripts/bump_patch_version.py поднять patch-версию в VERSION
|
||||
image/build-iso.sh, autoinstall.yaml, files/, grub/ сборка ISO
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Тесты и CI
|
||||
|
||||
`tests/conftest.py` подменяет `run_cmd`, так что `virsh`, `systemctl`, `qemu-img` и остальные команды имитируются — тесты идут на любой машине без libvirt. `tests/dev_server.py` поднимает демо-панель с теми же подменами. `make check` = pyflakes + shellcheck + pytest, запускается перед каждым пушем. `.github/workflows/ci.yml` (то же на Python 3.11–3.14) и `.github/workflows/image.yml` (сборка ISO) заработают в Gitea Actions, когда будет подключён раннер.
|
||||
|
||||
@@ -1,104 +1,140 @@
|
||||
# Первая тестовая виртуальная машина
|
||||
# Первая виртуальная машина
|
||||
|
||||
Этот документ фиксирует проверку первого рабочего сценария Virtuality: сеть `br0`, libvirt storage pools и запуск тестовой VM.
|
||||
Всё в этом документе делается через панель в браузере — терминал не понадобится. Считаем, что Virtuality установлена и мастер настройки пройден (см. README).
|
||||
|
||||
## Требования
|
||||
Сначала — самый быстрый путь: готовая Ubuntu за минуту. Потом — установка Windows 11 с ISO, экран машины, доступ снаружи, снимки и резервные копии.
|
||||
|
||||
Перед созданием VM должны быть готовы:
|
||||
---
|
||||
|
||||
- KVM/QEMU;
|
||||
- libvirt;
|
||||
- Cockpit;
|
||||
- storage pools `virtuality-images` и `virtuality-iso`;
|
||||
- bridge `br0`;
|
||||
- default route через `br0`.
|
||||
## 1. Быстрая машина из каталога: Ubuntu Server 26.04
|
||||
|
||||
Проверка:
|
||||
Готовые (облачные) образы — это уже установленная система без пароля. Панель скачивает образ, делает из него диск нужного размера и через cloud-init настраивает первый вход: пользователя, пароль, SSH-ключ.
|
||||
|
||||
### Скачать образ
|
||||
|
||||
1. Откройте **Образы ОС** → вкладка **Готовые системы**.
|
||||
2. В карточке **Каталог готовых систем** найдите **Ubuntu Server 26.04 LTS** (помечена «Рекомендуем») и нажмите **Скачать**. Сервер скачает образ с сайта Ubuntu сам и проверит контрольную сумму; ход виден в **Задачах** и на этой же странице.
|
||||
3. Когда образ отмечен «Загружен», рядом появится кнопка **Создать машину** — она открывает мастер с уже выбранным образом.
|
||||
|
||||
В каталоге также Ubuntu Server 24.04 LTS, Debian 13 и Alpine Linux 3.23. Свой образ (`.qcow2`, `.img`, `.raw`, `.img.xz`) можно загрузить с компьютера перетаскиванием или скачать по ссылке в карточке **Скачать по ссылке**.
|
||||
|
||||
### Создать машину
|
||||
|
||||
Шаг **1. Название и система**:
|
||||
|
||||
- **Название машины** — латиница, цифры, точка, дефис, подчёркивание (например, `ubuntu-1`). Это же имя станет именем компьютера внутри системы.
|
||||
- Источник **Готовая система** уже выбран, образ подставлен.
|
||||
- Переключатель **Настроить первый вход** должен быть включён: у готовых образов нет пароля, без этого шага в машину не войти.
|
||||
- **Пользователь** — панель подставляет стандартного пользователя образа (`ubuntu`, у Debian — `debian`, у Alpine — `alpine`); можно указать свой. Он получит права администратора (`sudo`).
|
||||
- **Пароль** — не короче 6 символов.
|
||||
- **SSH-ключ** — необязательно; вставьте содержимое `~/.ssh/id_ed25519.pub`. Если указать только ключ без пароля, вход по SSH будет только по ключу.
|
||||
|
||||
Шаг **2. Мощность**: выберите профиль — **Мини** (1 ядро, 1 ГБ, 10 ГБ), **Стандарт** (2 ядра, 4 ГБ, 40 ГБ), **Мощная** (4 ядра, 8 ГБ, 80 ГБ) — или задайте ядра, память и диск сами. Диск образа увеличивается до выбранного размера, а система при первой загрузке сама расширит файловую систему.
|
||||
|
||||
Шаг **3. Сеть**: **Автоматически** — машина получит постоянный адрес `192.168.100.x` в отдельной сети и выйдет в интернет через сервер; снаружи её не видно, пока вы не откроете доступ. **В локальной сети** (если в мастере настройки выбран мост) — машина станет устройством вашей домашней сети и получит адрес от роутера.
|
||||
|
||||
Справа в **Итого** проверьте сводку и нажмите **Создать машину**. Панель откроет страницу задачи с живым журналом. Обычно в пределах минуты появится «Машина создана. Вход: ubuntu по паролю. Адрес в сети машин: 192.168.100.x».
|
||||
|
||||
### Войти
|
||||
|
||||
Откройте страницу машины (из **Обзора**) → **Открыть экран** и войдите с указанными пользователем и паролем. В машину сразу ставится гостевой агент, поэтому её адрес виден на странице машины и в **Сети и доступе** — по нему можно зайти по SSH из локальной сети (если выбран мост) или после проброса порта (см. ниже).
|
||||
|
||||
---
|
||||
|
||||
## 2. Windows 11 с установочного ISO
|
||||
|
||||
### Загрузить ISO
|
||||
|
||||
1. Скачайте ISO Windows 11 с сайта Microsoft на свой компьютер.
|
||||
2. **Образы ОС** → вкладка **Установочные образы** → перетащите файл в окно загрузки. Или вставьте прямую ссылку на `.iso` в карточку **Скачать по ссылке** — сервер скачает его сам.
|
||||
|
||||
### Создать машину
|
||||
|
||||
1. **Создать машину** → шаг 1: название (например, `win11`), источник **Установить с диска**, выберите ISO.
|
||||
2. **Что будете устанавливать** → **Windows 11 / Server 2025**. Панель сама включит UEFI и виртуальный TPM 2.0 (без него Windows 11 отказывается ставиться), подберёт диск SATA и сетевую карту Intel — драйверы во время установки не нужны.
|
||||
3. Шаг 2: профиль **Мощная** (4 ядра, 8 ГБ, 80 ГБ) — Windows 11 требует минимум 4 ГБ памяти и 64 ГБ диска.
|
||||
4. Шаг 3: сеть — как и для Linux.
|
||||
5. **Создать машину**. Задача завершится за несколько секунд — машина создана и запущена с установочного диска.
|
||||
|
||||
### Установить систему
|
||||
|
||||
1. На странице машины нажмите **Открыть экран**. Сразу после запуска Windows показывает «Press any key to boot from CD or DVD» — нажмите любую клавишу в окне экрана, иначе загрузка с диска пропустится. Если пропустили — **Перезагрузить** на странице машины и попробовать снова.
|
||||
2. Дальше установка идёт как на обычном компьютере. Диск в установщике будет пустым — выберите его целиком.
|
||||
3. После установки машина перезагрузится уже с диска: для машин из ISO панель ставит диск первым в порядке загрузки, пока он пустой, машина загружается с образа. Сам образ можно извлечь во вкладке **Диски и загрузка** → **Привод 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`.
|
||||
|
||||
Копии можно делать и автоматически: **Резервные копии** → **Настроить** в карточке «Автоматические копии». Выберите, как часто (каждый день или раз в неделю), время, машины и сколько копий хранить. Сервер будет сам копировать машины в это время и удалять старые автоматические копии, а ручные оставит.
|
||||
|
||||
Одну копию можно сделать и из терминала сервера:
|
||||
|
||||
```bash
|
||||
sudo vhealth
|
||||
ip -br a
|
||||
ip route
|
||||
virsh pool-list --all
|
||||
sudo /opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py web01 "ночная копия"
|
||||
```
|
||||
|
||||
Нормальная сеть:
|
||||
---
|
||||
|
||||
```text
|
||||
enp2s0 UP
|
||||
br0 UP 10.0.0.200/24
|
||||
default via 10.0.0.1 dev br0
|
||||
```
|
||||
## Что дальше
|
||||
|
||||
## Создание тестовой VM
|
||||
|
||||
```bash
|
||||
cd ~/virtuality
|
||||
git pull
|
||||
sudo bash scripts/create_test_vm.sh
|
||||
```
|
||||
|
||||
По умолчанию создаётся VM:
|
||||
|
||||
```text
|
||||
Name: test-alpine
|
||||
RAM: 1024 MB
|
||||
CPU: 1 vCPU
|
||||
Disk: 8 GB qcow2
|
||||
ISO: Alpine Linux
|
||||
Network: br0
|
||||
Graphics: VNC
|
||||
```
|
||||
|
||||
Можно задать имя:
|
||||
|
||||
```bash
|
||||
sudo bash scripts/create_test_vm.sh test-alpine-2
|
||||
```
|
||||
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
virsh list --all
|
||||
sudo virsh vncdisplay test-alpine
|
||||
sudo vhealth
|
||||
```
|
||||
|
||||
Если `vncdisplay` показывает `:0`, значит порт VNC — `5900`.
|
||||
|
||||
Если показывает `:1`, значит порт VNC — `5901`.
|
||||
|
||||
## Доступ через Cockpit
|
||||
|
||||
Открыть:
|
||||
|
||||
```text
|
||||
https://SERVER_IP:9090
|
||||
```
|
||||
|
||||
Дальше:
|
||||
|
||||
```text
|
||||
Virtual machines → test-alpine → Console
|
||||
```
|
||||
|
||||
## Что считается успешным результатом
|
||||
|
||||
Этап считается пройденным, если:
|
||||
|
||||
- VM появилась в `virsh list --all`;
|
||||
- VM запущена;
|
||||
- консоль открывается в Cockpit или через VNC;
|
||||
- VM подключена к bridge `br0`;
|
||||
- `sudo vhealth` не показывает критических ошибок по Virtuality.
|
||||
|
||||
## Следующий этап
|
||||
|
||||
После первой тестовой VM можно переходить к разработке собственной панели управления Virtuality:
|
||||
|
||||
- список VM;
|
||||
- статусы VM;
|
||||
- запуск/остановка;
|
||||
- просмотр storage pools;
|
||||
- создание VM через UI;
|
||||
- базовый журнал событий.
|
||||
- **Клонировать** (меню на странице машины) — точная копия с новым именем и своим адресом, удобно для тестов.
|
||||
- **Спящий режим** — сохранить память на диск и выключить; после запуска машина продолжит с того же места.
|
||||
- **Автозапуск** — переключатель на странице машины: машина включится сама после перезагрузки сервера.
|
||||
- **Ресурсы** — процессор и память можно поменять, когда машина выключена; диск — увеличить в любой момент во вкладке **Диски и загрузка**.
|
||||
- Раздел **Помощь** в панели повторяет главное из этого документа.
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
# Установочный образ Virtuality
|
||||
|
||||
Загрузочный ISO ставит Virtuality на чистый сервер «с нуля», как Proxmox: записали на флешку, загрузились, ответили на три экрана — после перезагрузки панель уже открыта в браузере, а мастер настройки показывает, как ставятся остальные компоненты.
|
||||
|
||||
Образ собирается из официального, ничем не изменённого **Ubuntu Server 26.04 LTS live-server ISO** (amd64 или arm64) и дополняется:
|
||||
|
||||
```text
|
||||
/autoinstall.yaml # сценарий установщика Ubuntu (subiquity autoinstall)
|
||||
/virtuality/source # исходники Virtuality нужной версии (git-репозиторий)
|
||||
/virtuality/source/wheels # Python-колёса панели и pip — для установки без интернета
|
||||
/virtuality/image.env # параметры сборки: версия, порт панели, автообновление
|
||||
/virtuality/virtuality-firstboot.service # служба первой загрузки
|
||||
/boot/grub/grub.cfg # меню «Установить Virtuality X.Y.Z» с фирменной темой
|
||||
/boot/grub/themes/virtuality/ # тема GRUB
|
||||
```
|
||||
|
||||
Загрузочные записи BIOS и UEFI берутся из оригинального ISO без изменений.
|
||||
|
||||
---
|
||||
|
||||
## Где взять готовый образ
|
||||
|
||||
Образ собирается вручную командой `make iso` (см. ниже): раннер Actions в Gitea не подключён. Workflow `.github/workflows/image.yml` остаётся в репозитории и заработает, как только раннер появится: он собирает ISO по тегу `v*` и вручную, проверяет загрузочную запись UEFI, `autoinstall.yaml` и пункт Virtuality в `grub.cfg` и сохраняет `.iso` и `.sha256` как artifacts запуска.
|
||||
|
||||
---
|
||||
|
||||
## Сборка своими руками
|
||||
|
||||
Нужна Linux-машина (root не нужен), около 10 ГБ свободного места и пакеты:
|
||||
|
||||
```bash
|
||||
sudo apt install -y xorriso git curl python3-pip
|
||||
```
|
||||
|
||||
Сборка:
|
||||
|
||||
```bash
|
||||
git clone https://git.stackworks.ru/viktor/virtuality.git
|
||||
cd virtuality
|
||||
make iso # amd64
|
||||
make iso ARCH=arm64 # ARM64-серверы с UEFI
|
||||
```
|
||||
|
||||
`image/build-iso.sh` сам находит последний Ubuntu 26.04.x live-server ISO, скачивает его (около 3 ГБ), проверяет SHA256 и кладёт результат в `dist/`:
|
||||
|
||||
```text
|
||||
dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso
|
||||
dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso.sha256
|
||||
```
|
||||
|
||||
Скачанный ISO Ubuntu кешируется в `.cache/iso` (или в `VIRTUALITY_ISO_CACHE`). Уже скачанный образ можно передать явно:
|
||||
|
||||
```bash
|
||||
./image/build-iso.sh --ubuntu-iso ~/Downloads/ubuntu-26.04.1-live-server-amd64.iso
|
||||
```
|
||||
|
||||
### Параметры `image/build-iso.sh`
|
||||
|
||||
```text
|
||||
--arch amd64|arm64 архитектура (по умолчанию amd64)
|
||||
--ubuntu 26.04|24.04 база образа (по умолчанию 26.04 LTS)
|
||||
--ubuntu-iso PATH использовать локальный live-server ISO вместо скачивания
|
||||
--cache-dir DIR где хранить скачанные ISO Ubuntu (по умолчанию .cache/iso)
|
||||
--output DIR куда класть результат (по умолчанию dist)
|
||||
--ref GIT_REF какую версию Virtuality положить в образ (по умолчанию HEAD)
|
||||
--repo-url URL откуда установленная нода будет получать обновления
|
||||
--web-port PORT порт панели на установленной ноде (по умолчанию 8088)
|
||||
--no-auto-update не включать ночное автообновление
|
||||
--locale LOCALE локаль установщика и системы (по умолчанию en_US.UTF-8)
|
||||
--keyboard LAYOUT раскладка (по умолчанию us)
|
||||
--timezone TZ часовой пояс (по умолчанию geoip — установщик определит по сети; мастер настройки может сменить)
|
||||
--no-wheels не класть Python-колёса (тогда первая загрузка скачает их из PyPI)
|
||||
```
|
||||
|
||||
Полностью автоматический режим (вопросов нет, **стирается самый большой диск**):
|
||||
|
||||
```text
|
||||
--unattended автоматическая установка
|
||||
--username NAME администратор (логин в панель), обязателен с --unattended
|
||||
--password-hash HASH хеш crypt(3), например от: openssl passwd -6
|
||||
--hostname NAME имя сервера (по умолчанию virtuality)
|
||||
```
|
||||
|
||||
`./image/build-iso.sh --help` печатает тот же список. Через `make` параметры передаются так:
|
||||
|
||||
```bash
|
||||
make iso ISO_ARGS="--web-port 8089 --no-auto-update --timezone Europe/Moscow"
|
||||
```
|
||||
|
||||
### Что кладётся в образ
|
||||
|
||||
- **Исходники**: `git clone` репозитория на указанный `--ref`; у клона `origin` переключается на `--repo-url`, чтобы нода обновлялась оттуда.
|
||||
- **Колёса**: `pip download` для Python целевого выпуска (3.14 для Ubuntu 26.04, 3.12 для 24.04) и архитектуры, только бинарные `manylinux`-колёса из `web/requirements.txt`. Сам `pip` тоже кладётся: в Ubuntu Server нет `python3-venv`, и установщик панели поднимает pip из этого колеса в virtualenv, созданный `--without-pip`. Благодаря этому панель ставится до появления сети.
|
||||
- **`image.env`**: `VIRTUALITY_IMAGE_VERSION`, `VIRTUALITY_IMAGE_BUILT`, `VIRTUALITY_WEB_PORT`, `VIRTUALITY_AUTO_UPDATE` и, в автоматическом режиме, `VIRTUALITY_USER`. Установщик копирует файл в `/etc/virtuality/image.env`, его читает первая загрузка.
|
||||
|
||||
### Полностью автоматическая установка
|
||||
|
||||
Для массовой раскатки:
|
||||
|
||||
```bash
|
||||
make iso ISO_ARGS="--unattended --username sysadmin --password-hash '$(openssl passwd -6)' --hostname node01"
|
||||
```
|
||||
|
||||
Системные имена вроде `admin`, `root` или `kvm` установщик Ubuntu не принимает, и установка остановится на первом шаге. Поэтому `build-iso.sh` сразу отказывается собирать образ с таким `--username`. Полный список лежит в `image/reserved-usernames`, это копия списка из установщика Ubuntu.
|
||||
|
||||
> ⚠️ Такой образ сам уходит в установку через 10 секунд в меню и **без подтверждения стирает самый большой диск** машины. Файл получает суффикс `-unattended`, пункты меню — пометку «автоматически, диск будет стёрт». Не оставляйте такую флешку в серверах.
|
||||
|
||||
---
|
||||
|
||||
## Что спрашивает установщик
|
||||
|
||||
Меню загрузки: **Установить Virtuality 0.12.5** (выбирается сам через 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.12.5-ubuntu-26.04-amd64.iso.sha256
|
||||
```
|
||||
|
||||
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
|
||||
|
||||
```bash
|
||||
xorriso -indev virtuality-0.12.5-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
|
||||
xorriso -osirrox on -indev virtuality-0.12.5-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.12.5-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`.
|
||||
@@ -1,95 +1,77 @@
|
||||
# Системные требования Virtuality
|
||||
# Требования
|
||||
|
||||
Virtuality — это серверная система управления виртуализацией на базе KVM/QEMU/libvirt/Cockpit и собственной web-панели.
|
||||
## Коротко
|
||||
|
||||
## Минимальные требования
|
||||
Подойдёт почти любой компьютер или мини-ПК не старше 10 лет с проводной сетью и включённой в BIOS/UEFI виртуализацией. Для одной-двух небольших машин достаточно 4 ГБ памяти; для Windows и нескольких машин сразу — 16 ГБ и SSD.
|
||||
|
||||
Подходят только для тестового стенда и одной-двух лёгких VM.
|
||||
## Операционная система
|
||||
|
||||
```text
|
||||
CPU: 2 ядра с аппаратной виртуализацией Intel VT-x / AMD-V
|
||||
RAM: 4 GB
|
||||
Диск /: минимум 8 GB свободно
|
||||
Диск под /var/lib/virtuality: минимум 20 GB свободно
|
||||
OS: Ubuntu Server 24.04 LTS / Debian-like с apt
|
||||
Network: один проводной интерфейс
|
||||
```
|
||||
Virtuality работает на:
|
||||
|
||||
## Рекомендуемые требования
|
||||
- **Ubuntu Server 26.04 LTS** — основа установочного ISO, рекомендуется;
|
||||
- **Ubuntu Server 24.04 LTS**;
|
||||
- **Debian 13**.
|
||||
|
||||
Для нормальной работы нескольких VM.
|
||||
Другие системы на базе Debian/Ubuntu с `apt` скорее всего заработают, но не проверяются. Нужен systemd и netplan (для режима «домашняя сеть»). Установка с ISO делает всё сама; на готовую систему Virtuality ставится скриптом `install.sh` — ставьте её на чистый сервер, а не на рабочий компьютер.
|
||||
|
||||
```text
|
||||
CPU: 4+ ядра с Intel VT-x / AMD-V
|
||||
RAM: 16+ GB
|
||||
Диск: 100+ GB SSD/NVMe под /var/lib/virtuality
|
||||
Network: проводной интерфейс 1 Gbit/s+
|
||||
OS: Ubuntu Server 24.04 LTS
|
||||
```
|
||||
## Процессор и память
|
||||
|
||||
## Проверки установщика
|
||||
| | Минимум | Рекомендуется |
|
||||
|---|---|---|
|
||||
| Процессор | 2 ядра x86_64 с Intel VT-x / AMD-V | 4+ ядер |
|
||||
| Память | 4 ГБ | 16 ГБ и больше |
|
||||
| Системный диск | 8 ГБ свободно на `/` | SSD |
|
||||
| Диск под машины | 20 ГБ свободно в `/var/lib` | 100+ ГБ SSD/NVMe, лучше отдельный диск |
|
||||
| Сеть | один проводной интерфейс | 1 Гбит/с |
|
||||
|
||||
`bootstrap.sh` и `install_virtuality_node.sh` проверяют:
|
||||
Как считать память: сервер и панель занимают около 1 ГБ, остальное делят машины. Ubuntu Server внутри машины — от 1 ГБ, Windows 11 — от 4 ГБ (лучше 8). Диск машины занимает место постепенно, по мере заполнения, но резервные копии и снимки требуют запаса: планируйте хотя бы вдвое больше, чем суммарный размер дисков машин.
|
||||
|
||||
- наличие root-прав;
|
||||
- наличие `apt`;
|
||||
- свободное место на `/`;
|
||||
- свободное место для `/var/lib`;
|
||||
- объём RAM;
|
||||
- количество CPU cores;
|
||||
- наличие флага аппаратной виртуализации `vmx` или `svm`.
|
||||
**Виртуализация должна быть включена в BIOS/UEFI** (Intel VT-x / Virtualization Technology, AMD-V / SVM Mode). Без неё машины будут работать через медленную программную эмуляцию; панель предупредит об этом в разделе «Сервер», а `sudo vhealth` покажет ошибку `/dev/kvm missing`.
|
||||
|
||||
## Значения по умолчанию
|
||||
Внутри другой виртуальной машины или на VPS Virtuality работает, только если включена вложенная виртуализация (nested virtualization).
|
||||
|
||||
## ARM64
|
||||
|
||||
Платы и серверы на ARM64 (Raspberry Pi 4/5, Orange Pi 5, серверы с UEFI) поддерживаются: панель определяет профиль, ставит `qemu-system-arm` и прошивку UEFI для ARM. Гости на таких серверах — **только ARM64** (в каталоге готовых систем есть образы aarch64 для Ubuntu, Debian и Alpine); образы x86_64 на них не запускаются. Установка Windows на ARM64 не проверялась.
|
||||
|
||||
Для Raspberry Pi и Orange Pi желательно 8 ГБ памяти и загрузка с SSD/NVMe. Установочный ISO для них не подходит (они грузятся иначе) — используйте штатный Ubuntu Server для платы и `install.sh`. ARM64-ISO предназначен для серверов с UEFI.
|
||||
|
||||
## Сеть
|
||||
|
||||
- Проводное подключение к роутеру. По Wi-Fi сервер работать может, но режим «домашняя сеть» (мост) недоступен — только NAT.
|
||||
- Доступ в интернет при установке: первая загрузка после ISO и `install.sh` скачивают пакеты из репозиториев Ubuntu/Debian (сотни мегабайт). Панель после установки в интернет не ходит, кроме проверки обновлений и скачивания образов по вашей команде.
|
||||
- Порты на сервере: `8088` (панель по HTTP), `8443` (панель по HTTPS), `22` (SSH). Открывать их на роутере наружу не обязательно и без HTTPS не рекомендуется.
|
||||
- Для сети NAT машинам выделяется подсеть `192.168.100.0/24` — она не должна совпадать с вашей домашней сетью. Если роутер раздаёт именно `192.168.100.x`, выбирайте режим «домашняя сеть» или смените подсеть роутера.
|
||||
- Если хотите, чтобы сервисы машин были доступны из интернета, потребуется проброс портов на роутере на адрес сервера (и «белый» адрес от провайдера).
|
||||
|
||||
## Что проверяет установщик
|
||||
|
||||
`install.sh` и `install_virtuality_node.sh` перед установкой проверяют права root, наличие `apt`, свободное место на `/` и в `/var/lib`, память, количество ядер и флаг виртуализации `vmx`/`svm`. Пороги можно изменить переменными окружения:
|
||||
|
||||
```text
|
||||
VIRTUALITY_MIN_ROOT_FREE_MB=8192
|
||||
VIRTUALITY_MIN_VAR_FREE_MB=20480
|
||||
VIRTUALITY_MIN_RAM_MB=4096
|
||||
VIRTUALITY_MIN_CPU_CORES=2
|
||||
VIRTUALITY_SKIP_REQUIREMENTS=1 # отключить проверки совсем — только для тестов
|
||||
```
|
||||
|
||||
## Переопределение требований
|
||||
|
||||
Для тестового стенда можно временно уменьшить требования:
|
||||
Например, для тестового стенда с маленьким диском:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/bootstrap.sh | sudo VIRTUALITY_MIN_VAR_FREE_MB=8192 bash
|
||||
curl -fsSL https://git.stackworks.ru/viktor/virtuality/raw/branch/main/install.sh | VIRTUALITY_MIN_VAR_FREE_MB=8192 bash
|
||||
```
|
||||
|
||||
Полностью отключить preflight-проверки можно так:
|
||||
Установка с ISO эти пороги не применяет: диск и память проверяются мастером настройки, который покажет предупреждение, но не остановит установку.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/bootstrap.sh | sudo VIRTUALITY_SKIP_REQUIREMENTS=1 bash
|
||||
```
|
||||
|
||||
Использовать отключение проверок стоит только для тестов. Для реального сервера это плохая идея: VM быстро съедают диск.
|
||||
|
||||
## Как быстро проверить место
|
||||
Освободить место перед установкой на существующей системе:
|
||||
|
||||
```bash
|
||||
df -h
|
||||
df -i
|
||||
du -xh / --max-depth=1 2>/dev/null | sort -h
|
||||
```
|
||||
|
||||
## Очистка перед установкой
|
||||
|
||||
```bash
|
||||
sudo apt clean
|
||||
sudo apt autoclean
|
||||
sudo apt autoremove -y
|
||||
sudo rm -rf /var/lib/apt/lists/*
|
||||
sudo rm -rf /tmp/* /var/tmp/*
|
||||
sudo apt clean && sudo apt autoremove -y
|
||||
sudo journalctl --vacuum-time=3d
|
||||
```
|
||||
|
||||
## Где Virtuality хранит данные
|
||||
## Браузер
|
||||
|
||||
```text
|
||||
/opt/virtuality # системные конфиги и установленная web-панель
|
||||
/home/<user>/virtuality # GitHub-репозиторий проекта
|
||||
/var/lib/virtuality/iso # ISO-образы
|
||||
/var/lib/virtuality/images # qcow2-диски VM
|
||||
/var/lib/virtuality/backups # будущие backup VM
|
||||
/var/log/virtuality # логи установки и диагностики
|
||||
```
|
||||
Панель работает в актуальных Chrome, Firefox, Safari и Edge, в том числе на телефоне. Экран машины (noVNC) требует поддержки WebSocket — она есть во всех перечисленных. При включённом HTTPS сертификат выпускается самим сервером, поэтому браузер один раз спросит, доверять ли ему.
|
||||
|
||||
@@ -1,115 +1,49 @@
|
||||
# Roadmap Virtuality
|
||||
# Планы
|
||||
|
||||
Документ фиксирует направление разработки, чтобы проект не превратился в хаотичный комбайн.
|
||||
Virtuality — панель для одного домашнего сервера. Этот документ честно фиксирует, что уже есть в версии 0.11 и что планируется дальше. Порядок в списке планов — примерный приоритет, сроков нет. Подробный журнал изменений по версиям — в `updates/versions.json` и в разделе «Обновления» панели.
|
||||
|
||||
## v0.1 — Фундамент ноды
|
||||
## Сделано в 0.11
|
||||
|
||||
Цель: получить рабочую базу для запуска виртуализации.
|
||||
**Установка и настройка**
|
||||
|
||||
Задачи:
|
||||
- Установочный ISO на базе Ubuntu Server 26.04 LTS (amd64, arm64), сборка `make iso`, полностью автоматический режим.
|
||||
- Первая загрузка: панель ставится первой и показывает ход установки в браузере, при ошибке продолжает сама.
|
||||
- Мастер настройки: часовой пояс, пароль, HTTPS, сеть машин (NAT или домашняя сеть с автооткатом), отдельный диск под машины, обновления.
|
||||
- Страница «Настройки», команда `virtuality-ctl`, установка одной командой на Ubuntu 24.04 / 26.04 и Debian 13.
|
||||
|
||||
- базовый установщик `install_virtuality_node.sh`;
|
||||
- установка KVM/QEMU/libvirt;
|
||||
- установка Cockpit и Cockpit Machines;
|
||||
- создание директорий хранения;
|
||||
- создание libvirt storage pools;
|
||||
- базовая настройка firewall;
|
||||
- подготовка GitHub-синхронизации;
|
||||
- документация проекта.
|
||||
**Машины**
|
||||
|
||||
## v0.2 — Сеть и диагностика
|
||||
- Создание с ISO с выбором типа системы: Linux, Windows 11 / Server 2025 (UEFI + TPM), Windows 10, другая.
|
||||
- Машина из готового образа за минуту: каталог (Ubuntu 26.04/24.04, Debian 13, Alpine), скачивание по ссылке, cloud-init с пользователем, паролем и SSH-ключом, гостевой агент.
|
||||
- Снимки (до 8 на машину), резервные копии с восстановлением, клонирование, увеличение диска, пауза и спящий режим, заметки, живая нагрузка.
|
||||
- Экран машины в браузере только через панель (VNC закрыт снаружи).
|
||||
|
||||
Цель: безопасно подготовить сетевую основу.
|
||||
**Сеть и сервер**
|
||||
|
||||
Задачи:
|
||||
- Сеть NAT с постоянными адресами, пробросы портов с проверкой и восстановлением после перезагрузки, домашняя сеть через мост `br0`.
|
||||
- HTTPS для панели, стабильный канал обновлений и ранний доступ, откат, ежедневное обслуживание, отчёт для поддержки, архив настроек.
|
||||
- Убрано: Cockpit, консольный dashboard, эмуляция ARM64 на x86, загрузка по PXE, старые вспомогательные скрипты.
|
||||
|
||||
- диагностический скрипт ноды;
|
||||
- определение активного сетевого интерфейса;
|
||||
- безопасная настройка bridge `br0`;
|
||||
- резервная копия сетевых конфигов перед изменением;
|
||||
- откат сетевой конфигурации;
|
||||
- улучшение логов установщика.
|
||||
## Сделано в 0.12
|
||||
|
||||
## v0.3 — Прототип панели
|
||||
- Резервные копии по расписанию: каждый день или раз в неделю, все машины или отмеченные, хранение последних N автокопий. Копии делает отдельная служба сервера, перезапуск панели их не прерывает.
|
||||
|
||||
Цель: начать собственную панель управления.
|
||||
## Планируется
|
||||
|
||||
Задачи:
|
||||
- **Копии на внешний диск или сетевую папку** — сейчас копии лежат только в `/var/lib/virtuality/backups`.
|
||||
- **Контейнеры LXC** — лёгкие Linux-контейнеры рядом с машинами, как в Proxmox.
|
||||
- **Проброс устройств (PCI/USB passthrough)** — видеокарты, USB-ключи, диски целиком в машину.
|
||||
- **Второй сервер (кластер из двух нод)** — общий список машин, перенос машины между серверами, копии на соседний сервер.
|
||||
- **Пользователи и роли** — сейчас в панель входит один администратор; нужны дополнительные пользователи с ограниченными правами.
|
||||
- **Уведомления** — о завершении задач, ошибках, нехватке места и доступных обновлениях (почта, Telegram).
|
||||
- **Шаблоны машин** — сохранить настроенную машину как шаблон и создавать из него новые.
|
||||
- **Дополнительные диски и сети у машины** — второй диск, вторая сетевая карта, изменение сети существующей машины.
|
||||
- **Сертификат Let's Encrypt** — вместо самоподписанного, для доступа к панели по доменному имени.
|
||||
- **История нагрузки** — графики процессора, памяти и диска за день и неделю по серверу и машинам.
|
||||
- **Проверка обновлений образов каталога** — предлагать обновить скачанный облачный образ, когда вышел новый.
|
||||
|
||||
- лёгкая веб-панель;
|
||||
- авторизация;
|
||||
- список виртуальных машин;
|
||||
- статус VM;
|
||||
- старт/стоп/перезагрузка VM через libvirt;
|
||||
- отображение storage pools;
|
||||
- базовый журнал событий.
|
||||
## Не планируется
|
||||
|
||||
## v0.4 — Создание VM
|
||||
|
||||
Цель: добавить мастер создания виртуальных машин.
|
||||
|
||||
Задачи:
|
||||
|
||||
- менеджер ISO;
|
||||
- форма создания VM;
|
||||
- выбор CPU/RAM/disk;
|
||||
- выбор ISO;
|
||||
- выбор сети;
|
||||
- создание qcow2-диска;
|
||||
- запуск установки ОС;
|
||||
- базовая консоль.
|
||||
|
||||
## v0.5 — Обновления и бэкапы
|
||||
|
||||
Цель: сделать платформу обслуживаемой.
|
||||
|
||||
Задачи:
|
||||
|
||||
- автообновлятор;
|
||||
- резервная копия конфигов перед обновлением;
|
||||
- история обновлений;
|
||||
- ручной backup VM;
|
||||
- список бэкапов;
|
||||
- восстановление из backup.
|
||||
|
||||
## v0.6 — Пользователи и роли
|
||||
|
||||
Цель: подготовить систему к реальному использованию несколькими пользователями.
|
||||
|
||||
Задачи:
|
||||
|
||||
- роли: admin, operator, viewer;
|
||||
- ограничения действий по ролям;
|
||||
- журнал действий пользователей;
|
||||
- базовые настройки безопасности.
|
||||
|
||||
## v0.7 — Мониторинг
|
||||
|
||||
Цель: видеть состояние ноды без терминального шаманства.
|
||||
|
||||
Задачи:
|
||||
|
||||
- CPU/RAM/disk usage;
|
||||
- статус libvirt;
|
||||
- статус Cockpit;
|
||||
- количество VM;
|
||||
- состояние storage pools;
|
||||
- алерты;
|
||||
- Telegram/email-уведомления в будущем.
|
||||
|
||||
## v1.0 — Первый стабильный релиз
|
||||
|
||||
Цель: рабочая одиночная нода Virtuality.
|
||||
|
||||
Минимальный состав:
|
||||
|
||||
- установка одной командой;
|
||||
- управление VM;
|
||||
- создание VM;
|
||||
- сеть bridge;
|
||||
- ISO manager;
|
||||
- backup/restore;
|
||||
- web dashboard;
|
||||
- базовая безопасность;
|
||||
- документация;
|
||||
- обновлятор.
|
||||
- Многосерверные кластеры с общим хранилищем, Ceph, живая миграция — это уровень Proxmox для дата-центров, не для дома.
|
||||
- Поддержка других гипервизоров, кроме KVM/QEMU/libvirt.
|
||||
- Гости x86_64 на ARM64-серверах через эмуляцию — слишком медленно, чтобы этим пользоваться.
|
||||
|
||||
|
После Ширина: | Высота: | Размер: 183 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 |
@@ -0,0 +1,31 @@
|
||||
#cloud-config
|
||||
# Virtuality installer configuration for Ubuntu Server 24.04 (subiquity autoinstall).
|
||||
# build-iso.sh puts this file at /autoinstall.yaml on the ISO.
|
||||
# @@VARIABLES@@ are substituted at build time.
|
||||
autoinstall:
|
||||
version: 1
|
||||
# The installer asks only for these screens; everything else is automated.
|
||||
interactive-sections:
|
||||
@@INTERACTIVE_SECTIONS@@
|
||||
locale: @@LOCALE@@
|
||||
keyboard:
|
||||
layout: @@KEYBOARD@@
|
||||
timezone: @@TIMEZONE@@
|
||||
source:
|
||||
id: ubuntu-server
|
||||
ssh:
|
||||
install-server: true
|
||||
allow-pw: true
|
||||
@@IDENTITY@@
|
||||
storage:
|
||||
layout:
|
||||
name: lvm
|
||||
sizing-policy: all
|
||||
late-commands:
|
||||
- mkdir -p /target/opt/virtuality /target/etc/virtuality
|
||||
- cp -a /cdrom/virtuality/source /target/opt/virtuality/source
|
||||
- chown -R root:root /target/opt/virtuality
|
||||
- chmod -R u+w /target/opt/virtuality/source
|
||||
- cp /cdrom/virtuality/image.env /target/etc/virtuality/image.env
|
||||
- cp /cdrom/virtuality/virtuality-firstboot.service /target/etc/systemd/system/virtuality-firstboot.service
|
||||
- curtin in-target --target=/target -- systemctl enable virtuality-firstboot.service
|
||||
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ==========================================================
|
||||
# Virtuality ISO builder
|
||||
# Remasters the official Ubuntu Server LTS live ISO (26.04 by default) into a
|
||||
# Virtuality installer: Ubuntu autoinstall + bundled Virtuality
|
||||
# source and Python wheels + first boot setup service.
|
||||
#
|
||||
# Usage: image/build-iso.sh [options] (see --help)
|
||||
# Needs: xorriso, git, curl, python3 with pip, sha256sum.
|
||||
# Does not need root.
|
||||
# ==========================================================
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
IMAGE_DIR="${REPO_ROOT}/image"
|
||||
|
||||
ARCH="amd64"
|
||||
UBUNTU_SERIES="26.04"
|
||||
UBUNTU_ISO=""
|
||||
CACHE_DIR="${VIRTUALITY_ISO_CACHE:-${REPO_ROOT}/.cache/iso}"
|
||||
OUTPUT_DIR="${REPO_ROOT}/dist"
|
||||
GIT_REF="HEAD"
|
||||
REPO_URL="https://git.stackworks.ru/viktor/virtuality.git"
|
||||
WEB_PORT="8088"
|
||||
AUTO_UPDATE="1"
|
||||
UNATTENDED="0"
|
||||
USERNAME=""
|
||||
PASSWORD_HASH=""
|
||||
HOSTNAME_VALUE="virtuality"
|
||||
LOCALE="en_US.UTF-8"
|
||||
KEYBOARD="us"
|
||||
TIMEZONE="geoip"
|
||||
WITH_WHEELS="1"
|
||||
TARGET_PYTHON=""
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [options]
|
||||
|
||||
Options:
|
||||
--arch amd64|arm64 Target architecture (default: ${ARCH})
|
||||
--ubuntu 26.04|24.04 Ubuntu Server LTS release to base on (default: ${UBUNTU_SERIES})
|
||||
--ubuntu-iso PATH Use a local Ubuntu live-server ISO of that release instead of downloading
|
||||
--cache-dir DIR Where downloaded Ubuntu ISOs are kept (default: ${CACHE_DIR})
|
||||
--output DIR Output directory (default: ${OUTPUT_DIR})
|
||||
--ref GIT_REF Git ref of Virtuality to bundle (default: ${GIT_REF})
|
||||
--repo-url URL Update remote configured on installed nodes (default: ${REPO_URL})
|
||||
--web-port PORT Web panel port on installed nodes (default: ${WEB_PORT})
|
||||
--no-auto-update Disable the nightly auto-update on installed nodes
|
||||
--locale LOCALE Installer/system locale (default: ${LOCALE})
|
||||
--keyboard LAYOUT Keyboard layout (default: ${KEYBOARD})
|
||||
--timezone TZ System timezone (default: ${TIMEZONE} — detected by the installer; the wizard can change it)
|
||||
--no-wheels Do not bundle Python wheels (first boot downloads them from PyPI)
|
||||
|
||||
Unattended mode (no questions at all, ERASES THE LARGEST DISK):
|
||||
--unattended Fully automatic install
|
||||
--username NAME Admin user (panel login), required with --unattended
|
||||
--password-hash HASH crypt(3) hash, e.g. from: openssl passwd -6
|
||||
--hostname NAME Hostname (default: ${HOSTNAME_VALUE})
|
||||
|
||||
Without --unattended the installer asks for network, disk and user
|
||||
like a regular Ubuntu Server install; everything else is automated.
|
||||
EOF
|
||||
}
|
||||
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
say() { echo "==> $*"; }
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--arch) ARCH="$2"; shift 2 ;;
|
||||
--ubuntu) UBUNTU_SERIES="$2"; shift 2 ;;
|
||||
--ubuntu-iso) UBUNTU_ISO="$2"; shift 2 ;;
|
||||
--cache-dir) CACHE_DIR="$2"; shift 2 ;;
|
||||
--output) OUTPUT_DIR="$2"; shift 2 ;;
|
||||
--ref) GIT_REF="$2"; shift 2 ;;
|
||||
--repo-url) REPO_URL="$2"; shift 2 ;;
|
||||
--web-port) WEB_PORT="$2"; shift 2 ;;
|
||||
--no-auto-update) AUTO_UPDATE="0"; shift ;;
|
||||
--locale) LOCALE="$2"; shift 2 ;;
|
||||
--keyboard) KEYBOARD="$2"; shift 2 ;;
|
||||
--timezone) TIMEZONE="$2"; shift 2 ;;
|
||||
--no-wheels) WITH_WHEELS="0"; shift ;;
|
||||
--unattended) UNATTENDED="1"; shift ;;
|
||||
--username) USERNAME="$2"; shift 2 ;;
|
||||
--password-hash) PASSWORD_HASH="$2"; shift 2 ;;
|
||||
--hostname) HOSTNAME_VALUE="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) usage >&2; die "unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Python of the target release decides which wheels are bundled.
|
||||
case "$UBUNTU_SERIES" in
|
||||
26.04) TARGET_PYTHON="3.14" ;;
|
||||
24.04) TARGET_PYTHON="3.12" ;;
|
||||
*) die "unsupported --ubuntu ${UBUNTU_SERIES} (26.04 or 24.04)" ;;
|
||||
esac
|
||||
|
||||
case "$ARCH" in
|
||||
amd64) PY_PLATFORM_ARCH="x86_64"; MIRROR="https://releases.ubuntu.com/${UBUNTU_SERIES}" ;;
|
||||
arm64) PY_PLATFORM_ARCH="aarch64"; MIRROR="https://cdimage.ubuntu.com/releases/${UBUNTU_SERIES}/release" ;;
|
||||
*) die "unsupported arch: $ARCH (amd64 or arm64)" ;;
|
||||
esac
|
||||
[[ "$WEB_PORT" =~ ^[0-9]+$ ]] && (( WEB_PORT >= 1 && WEB_PORT <= 65535 )) || die "invalid --web-port: $WEB_PORT"
|
||||
if [[ "$UNATTENDED" == "1" ]]; then
|
||||
[[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "--unattended needs a valid --username"
|
||||
! grep -qxF "$USERNAME" "${IMAGE_DIR}/reserved-usernames" || die "--username ${USERNAME} is reserved by the Ubuntu installer (like admin, root, kvm); pick another name"
|
||||
[[ "$PASSWORD_HASH" == \$* ]] || die "--unattended needs --password-hash (generate: openssl passwd -6)"
|
||||
[[ "$HOSTNAME_VALUE" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]{0,62}$ ]] || die "invalid --hostname"
|
||||
fi
|
||||
for cmd in xorriso git curl python3 sha256sum md5sum; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || die "$cmd not found (Ubuntu: sudo apt install xorriso git curl python3-pip)"
|
||||
done
|
||||
|
||||
VERSION="$(git -C "$REPO_ROOT" show "${GIT_REF}:VERSION" | tr -d '[:space:]')"
|
||||
[[ -n "$VERSION" ]] || die "cannot read VERSION at ${GIT_REF}"
|
||||
SUFFIX=""
|
||||
[[ "$UNATTENDED" == "1" ]] && SUFFIX="-unattended"
|
||||
OUTPUT_ISO="${OUTPUT_DIR}/virtuality-${VERSION}-ubuntu-${UBUNTU_SERIES}-${ARCH}${SUFFIX}.iso"
|
||||
|
||||
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/virtuality-iso.XXXXXX")"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
mkdir -p "$OUTPUT_DIR" "$CACHE_DIR"
|
||||
|
||||
# ---------------------------------------------------------- Ubuntu ISO
|
||||
if [[ -z "$UBUNTU_ISO" ]]; then
|
||||
say "Resolving the latest Ubuntu ${UBUNTU_SERIES} live-server ISO for ${ARCH}"
|
||||
curl -fsSL --retry 3 "${MIRROR}/SHA256SUMS" -o "${WORK_DIR}/SHA256SUMS"
|
||||
iso_name="$(grep -oE "ubuntu-${UBUNTU_SERIES}(\.[0-9]+)*-live-server-${ARCH}\.iso" "${WORK_DIR}/SHA256SUMS" | sort -uV | tail -n1)"
|
||||
[[ -n "$iso_name" ]] || die "live-server ISO for ${ARCH} not found in ${MIRROR}/SHA256SUMS"
|
||||
expected_sha="$(awk -v n="*${iso_name}" '$2 == n || $2 == substr(n, 2) {print $1}' "${WORK_DIR}/SHA256SUMS")"
|
||||
UBUNTU_ISO="${CACHE_DIR}/${iso_name}"
|
||||
if [[ -f "$UBUNTU_ISO" ]] && echo "${expected_sha} ${UBUNTU_ISO}" | sha256sum -c --status; then
|
||||
say "Using cached ${iso_name}"
|
||||
else
|
||||
say "Downloading ${iso_name}"
|
||||
curl -fL --retry 5 -C - -o "${UBUNTU_ISO}.part" "${MIRROR}/${iso_name}"
|
||||
mv "${UBUNTU_ISO}.part" "$UBUNTU_ISO"
|
||||
echo "${expected_sha} ${UBUNTU_ISO}" | sha256sum -c --status || die "checksum mismatch for ${iso_name}"
|
||||
fi
|
||||
say "Checksum OK: ${expected_sha}"
|
||||
fi
|
||||
[[ -f "$UBUNTU_ISO" ]] || die "Ubuntu ISO not found: $UBUNTU_ISO"
|
||||
|
||||
# ---------------------------------------------------------- payload
|
||||
PAYLOAD="${WORK_DIR}/virtuality"
|
||||
mkdir -p "$PAYLOAD"
|
||||
say "Bundling Virtuality ${VERSION} (${GIT_REF})"
|
||||
git clone --quiet --no-hardlinks "$REPO_ROOT" "${PAYLOAD}/source"
|
||||
git -C "${PAYLOAD}/source" checkout --quiet --detach "$(git -C "$REPO_ROOT" rev-parse "${GIT_REF}^{commit}")"
|
||||
git -C "${PAYLOAD}/source" remote set-url origin "$REPO_URL"
|
||||
|
||||
if [[ "$WITH_WHEELS" == "1" ]]; then
|
||||
say "Downloading Python ${TARGET_PYTHON} wheels for ${PY_PLATFORM_ARCH}"
|
||||
platform_args=(--platform "manylinux2014_${PY_PLATFORM_ARCH}")
|
||||
for glibc_minor in 17 28 31 34 35 39; do
|
||||
platform_args+=(--platform "manylinux_2_${glibc_minor}_${PY_PLATFORM_ARCH}")
|
||||
done
|
||||
# pip itself is bundled as well: Ubuntu Server has no python3-venv, the panel
|
||||
# installer bootstraps pip from this wheel into a --without-pip virtualenv.
|
||||
python3 -m pip download --quiet --disable-pip-version-check \
|
||||
-r "${PAYLOAD}/source/web/requirements.txt" pip -d "${PAYLOAD}/source/wheels" \
|
||||
--only-binary=:all: --implementation cp --python-version "$TARGET_PYTHON" \
|
||||
--abi "cp${TARGET_PYTHON/./}" --abi abi3 --abi none "${platform_args[@]}"
|
||||
fi
|
||||
|
||||
cat > "${PAYLOAD}/image.env" <<EOF
|
||||
# Written by image/build-iso.sh, read by scripts/virtuality_firstboot.sh
|
||||
VIRTUALITY_IMAGE_VERSION=${VERSION}
|
||||
VIRTUALITY_IMAGE_BUILT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
VIRTUALITY_WEB_PORT=${WEB_PORT}
|
||||
VIRTUALITY_AUTO_UPDATE=${AUTO_UPDATE}
|
||||
EOF
|
||||
[[ "$UNATTENDED" == "1" ]] && echo "VIRTUALITY_USER=${USERNAME}" >> "${PAYLOAD}/image.env"
|
||||
cp "${IMAGE_DIR}/files/virtuality-firstboot.service" "${PAYLOAD}/virtuality-firstboot.service"
|
||||
|
||||
# ---------------------------------------------------------- autoinstall
|
||||
if [[ "$UNATTENDED" == "1" ]]; then
|
||||
interactive_sections=" []"
|
||||
identity=" identity:
|
||||
hostname: ${HOSTNAME_VALUE}
|
||||
username: ${USERNAME}
|
||||
realname: Virtuality Admin
|
||||
password: '${PASSWORD_HASH}'"
|
||||
else
|
||||
interactive_sections=" - network
|
||||
- storage
|
||||
- identity"
|
||||
identity=""
|
||||
fi
|
||||
python3 - "${IMAGE_DIR}/autoinstall.yaml" "${WORK_DIR}/autoinstall.yaml" <<PYEOF
|
||||
import sys
|
||||
text = open(sys.argv[1]).read()
|
||||
values = {
|
||||
"@@INTERACTIVE_SECTIONS@@": """${interactive_sections}""",
|
||||
"@@IDENTITY@@": """${identity}""",
|
||||
"@@LOCALE@@": "${LOCALE}",
|
||||
"@@KEYBOARD@@": "${KEYBOARD}",
|
||||
"@@TIMEZONE@@": "${TIMEZONE}",
|
||||
}
|
||||
for key, value in values.items():
|
||||
text = text.replace(key, value)
|
||||
open(sys.argv[2], "w").write(text)
|
||||
PYEOF
|
||||
python3 -c 'import sys, yaml; data = yaml.safe_load(open(sys.argv[1])); assert data["autoinstall"]["version"] == 1' "${WORK_DIR}/autoinstall.yaml" 2>/dev/null \
|
||||
|| python3 -c 'import sys, json; print("PyYAML not available, skipping YAML validation")'
|
||||
|
||||
# ---------------------------------------------------------- boot menu
|
||||
xorriso -osirrox on -indev "$UBUNTU_ISO" \
|
||||
-extract /boot/grub/grub.cfg "${WORK_DIR}/grub.cfg" \
|
||||
-extract /md5sum.txt "${WORK_DIR}/md5sum.txt" >/dev/null 2>&1 || die "cannot read grub.cfg from $UBUNTU_ISO"
|
||||
chmod u+w "${WORK_DIR}/grub.cfg" "${WORK_DIR}/md5sum.txt"
|
||||
python3 - "${WORK_DIR}/grub.cfg" "$UNATTENDED" "$VERSION" <<'PYEOF'
|
||||
import re
|
||||
import sys
|
||||
|
||||
path, unattended, version = sys.argv[1], sys.argv[2] == "1", sys.argv[3]
|
||||
text = open(path).read()
|
||||
# /autoinstall.yaml applies to every entry, so all Ubuntu entries become Virtuality entries.
|
||||
suffix = " — автоматически, диск будет стёрт" if unattended else ""
|
||||
text = text.replace('menuentry "Try or Install Ubuntu Server"', 'menuentry "Установить Virtuality %s%s"' % (version, suffix), 1)
|
||||
text = text.replace('menuentry "Ubuntu Server with the HWE kernel"', 'menuentry "Установить Virtuality %s (новое ядро HWE)%s"' % (version, suffix), 1)
|
||||
text = text.replace("menuentry 'Boot from next volume'", "menuentry 'Загрузить с другого диска'", 1)
|
||||
text = text.replace("menuentry 'UEFI Firmware Settings'", "menuentry 'Настройки UEFI'", 1)
|
||||
text = text.replace("menuentry 'Test memory'", "menuentry 'Проверить память'", 1)
|
||||
if "Установить Virtuality" not in text:
|
||||
raise SystemExit("unexpected grub.cfg menu titles")
|
||||
if unattended:
|
||||
text = re.sub(r"^(\s*linux\s+\S+\s+)---", r"\1autoinstall ---", text, flags=re.M)
|
||||
text = re.sub(r"^set timeout=\d+\n", "", text, count=1, flags=re.M)
|
||||
text = text.replace("loadfont unicode\n", "", 1)
|
||||
# Branded graphical menu; if a module or the font is missing GRUB falls back to the plain text menu.
|
||||
theme = """insmod all_video
|
||||
insmod gfxterm
|
||||
insmod png
|
||||
insmod gfxmenu
|
||||
set gfxmode=1024x768,auto
|
||||
if loadfont /boot/grub/fonts/unicode.pf2; then
|
||||
terminal_output gfxterm
|
||||
set theme=/boot/grub/themes/virtuality/theme.txt
|
||||
export theme
|
||||
fi
|
||||
set default=0
|
||||
set timeout=10
|
||||
"""
|
||||
open(path, "w").write(theme + text)
|
||||
PYEOF
|
||||
new_md5="$(md5sum "${WORK_DIR}/grub.cfg" | cut -d' ' -f1)"
|
||||
sed -i "s|^[0-9a-f]\{32\} \./boot/grub/grub.cfg$|${new_md5} ./boot/grub/grub.cfg|" "${WORK_DIR}/md5sum.txt"
|
||||
|
||||
# ---------------------------------------------------------- repack
|
||||
THEME_DIR="${WORK_DIR}/theme"
|
||||
mkdir -p "$THEME_DIR"
|
||||
cp "${IMAGE_DIR}"/grub/theme.txt "${IMAGE_DIR}"/grub/*.png "$THEME_DIR"/
|
||||
say "Writing ${OUTPUT_ISO}"
|
||||
rm -f "$OUTPUT_ISO"
|
||||
chmod -R a+rX,go-w "$PAYLOAD"
|
||||
if ! xorriso -indev "$UBUNTU_ISO" -outdev "$OUTPUT_ISO" \
|
||||
-uid 0 -gid 0 \
|
||||
-map "$PAYLOAD" /virtuality \
|
||||
-map "${WORK_DIR}/autoinstall.yaml" /autoinstall.yaml \
|
||||
-map "${WORK_DIR}/grub.cfg" /boot/grub/grub.cfg \
|
||||
-map "${THEME_DIR}" /boot/grub/themes/virtuality \
|
||||
-map "${WORK_DIR}/md5sum.txt" /md5sum.txt \
|
||||
-boot_image any replay > "${WORK_DIR}/xorriso.log" 2>&1; then
|
||||
tail -n 30 "${WORK_DIR}/xorriso.log" >&2
|
||||
die "xorriso failed"
|
||||
fi
|
||||
[[ -s "$OUTPUT_ISO" ]] || die "xorriso did not produce $OUTPUT_ISO"
|
||||
|
||||
(cd "$OUTPUT_DIR" && sha256sum "$(basename "$OUTPUT_ISO")" > "$(basename "$OUTPUT_ISO").sha256")
|
||||
say "Done: ${OUTPUT_ISO} ($(du -h "$OUTPUT_ISO" | cut -f1))"
|
||||
say "SHA256: $(cut -d' ' -f1 "${OUTPUT_ISO}.sha256")"
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Virtuality first boot setup
|
||||
Documentation=https://git.stackworks.ru/viktor/virtuality/src/branch/main/docs/IMAGE.md
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
ConditionPathExists=!/var/lib/virtuality/.firstboot-done
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
# simple, not oneshot: boot must not wait for a multi-minute package install.
|
||||
Type=simple
|
||||
ExecStart=/bin/bash /opt/virtuality/source/scripts/virtuality_firstboot.sh
|
||||
Restart=on-failure
|
||||
RestartSec=60
|
||||
StandardOutput=journal+console
|
||||
StandardError=journal+console
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,12 @@
|
||||
# Тема загрузочного меню
|
||||
|
||||
`theme.txt` — тема GRUB (gfxmenu) для установочного ISO, `background.png` — фон 1024×768,
|
||||
`select_*.png` — подсветка выбранного пункта.
|
||||
|
||||
Фон рендерится из `background.html` (тот же стиль, что у страницы входа панели):
|
||||
|
||||
```bash
|
||||
NODE_PATH=$(npm root -g) node tests/render_html.mjs image/grub/background.html image/grub/background.png 1024 768
|
||||
```
|
||||
|
||||
Пиксмапы подсветки — однотонные RGBA 6×6 (110, 96, 255, 150).
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
html,body{margin:0;width:1024px;height:768px;overflow:hidden;font-family:Inter,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
|
||||
body{background:radial-gradient(900px 520px at 8% 0%, rgba(123,107,255,.55), transparent 60%), radial-gradient(760px 520px at 100% 100%, rgba(47,115,240,.45), transparent 55%), #0b0a1f;color:#fff;position:relative}
|
||||
.grid{position:absolute;inset:0;background-image:linear-gradient(rgba(255,255,255,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.045) 1px,transparent 1px);background-size:48px 48px;-webkit-mask-image:radial-gradient(ellipse at 30% 30%,#000 15%,transparent 72%)}
|
||||
.brand{position:absolute;left:72px;top:64px;display:flex;align-items:center;gap:18px}
|
||||
.mark{width:64px;height:64px;border-radius:18px;background:linear-gradient(135deg,#7b6bff 0%,#5a48f5 45%,#2f73f0 100%);box-shadow:0 14px 40px -10px rgba(90,72,245,.9), inset 0 1px 0 rgba(255,255,255,.3);display:grid;place-items:center}
|
||||
.mark svg{width:38px;height:38px}
|
||||
.name{font-size:38px;font-weight:700;letter-spacing:-.02em;line-height:1}
|
||||
.name small{display:block;margin-top:8px;font-size:16px;font-weight:500;color:rgba(255,255,255,.62);letter-spacing:0}
|
||||
.tag{position:absolute;left:72px;top:176px;font-size:30px;font-weight:600;letter-spacing:-.02em;line-height:1.25;max-width:620px}
|
||||
.tag span{background:linear-gradient(90deg,#c4bcff,#8fb8ff);-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.foot{position:absolute;left:72px;bottom:56px;color:rgba(255,255,255,.5);font-size:15px}
|
||||
.hint{position:absolute;right:72px;bottom:56px;color:rgba(255,255,255,.5);font-size:15px;text-align:right}
|
||||
</style></head><body><div class="grid"></div>
|
||||
<div class="brand"><div class="mark"><svg viewBox="0 0 24 24" fill="none"><path d="M4.5 5.5 12 19l7.5-13.5" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M9 5.5h6" stroke="#fff" stroke-opacity=".55" stroke-width="2.6" stroke-linecap="round"/></svg></div><div class="name">Virtuality<small>Платформа виртуализации</small></div></div>
|
||||
<div class="tag">Ваши серверы.<br><span>Под полным контролем.</span></div>
|
||||
<div class="foot">Установщик · KVM · QEMU · libvirt</div>
|
||||
<div class="hint">↑ ↓ — выбор пункта, Enter — запуск</div>
|
||||
</body></html>
|
||||
|
После Ширина: | Высота: | Размер: 151 KiB |
|
После Ширина: | Высота: | Размер: 75 B |
|
После Ширина: | Высота: | Размер: 75 B |
|
После Ширина: | Высота: | Размер: 75 B |
|
После Ширина: | Высота: | Размер: 75 B |
|
После Ширина: | Высота: | Размер: 75 B |
|
После Ширина: | Высота: | Размер: 75 B |
|
После Ширина: | Высота: | Размер: 75 B |
|
После Ширина: | Высота: | Размер: 75 B |
|
После Ширина: | Высота: | Размер: 75 B |
@@ -0,0 +1,41 @@
|
||||
desktop-image: "background.png"
|
||||
desktop-image-scale-method: "stretch"
|
||||
desktop-color: "#0b0a1f"
|
||||
title-text: ""
|
||||
terminal-font: "Unicode Regular"
|
||||
terminal-left: "7%"
|
||||
terminal-top: "36%"
|
||||
terminal-width: "86%"
|
||||
terminal-height: "50%"
|
||||
terminal-border: "0"
|
||||
|
||||
+ boot_menu {
|
||||
left = 7%
|
||||
top = 36%
|
||||
width = 60%
|
||||
height = 42%
|
||||
item_font = "Unicode Regular"
|
||||
item_color = "#b9b8cf"
|
||||
selected_item_color = "#ffffff"
|
||||
item_height = 36
|
||||
item_padding = 10
|
||||
item_spacing = 6
|
||||
icon_width = 0
|
||||
icon_height = 0
|
||||
scrollbar = false
|
||||
selected_item_pixmap_style = "select_*.png"
|
||||
}
|
||||
|
||||
+ progress_bar {
|
||||
id = "__timeout__"
|
||||
left = 7%
|
||||
top = 86%
|
||||
width = 60%
|
||||
height = 24
|
||||
font = "Unicode Regular"
|
||||
text_color = "#ffffff"
|
||||
fg_color = "#7b6bff"
|
||||
bg_color = "#1c1a36"
|
||||
border_color = "#2a2748"
|
||||
text = "Автоматический запуск через %d с"
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
# Имена, которые установщик Ubuntu (subiquity) не даёт выбрать для первого пользователя.
|
||||
# Копия https://github.com/canonical/subiquity/blob/main/reserved-usernames (27.09.2026).
|
||||
# build-iso.sh проверяет по ней --username, иначе автоматическая установка падает на первом шаге.
|
||||
|
||||
# Static users from base-passwd/passwd.master (3.5.41).
|
||||
root
|
||||
daemon
|
||||
bin
|
||||
sys
|
||||
sync
|
||||
games
|
||||
man
|
||||
lp
|
||||
mail
|
||||
news
|
||||
uucp
|
||||
proxy
|
||||
www-data
|
||||
backup
|
||||
list
|
||||
irc
|
||||
gnats
|
||||
nobody
|
||||
|
||||
# Other static groups from base-passwd/group.master (3.5.41).
|
||||
adm
|
||||
tty
|
||||
disk
|
||||
kmem
|
||||
dialout
|
||||
fax
|
||||
voice
|
||||
cdrom
|
||||
floppy
|
||||
tape
|
||||
sudo
|
||||
audio
|
||||
dip
|
||||
operator
|
||||
src
|
||||
shadow
|
||||
utmp
|
||||
video
|
||||
sasl
|
||||
plugdev
|
||||
staff
|
||||
users
|
||||
nogroup
|
||||
|
||||
# Reserved usernames listed in base-passwd/README (3.5.41).
|
||||
netplan
|
||||
ftn
|
||||
mysql
|
||||
tac-plus
|
||||
alias
|
||||
qmail
|
||||
qmaild
|
||||
qmails
|
||||
qmailr
|
||||
qmailq
|
||||
qmaill
|
||||
qmailp
|
||||
asterisk
|
||||
vpopmail
|
||||
vchkpw
|
||||
slurm
|
||||
hacluster
|
||||
haclient
|
||||
grsec-tpe
|
||||
grsec-sock-all
|
||||
grsec-sock-clt
|
||||
grsec-sock-srv
|
||||
grsec-proc
|
||||
ceph
|
||||
opensrf
|
||||
libvirt-qemu
|
||||
|
||||
# Ubuntu creates the admin group and adds the first user to it in order to
|
||||
# grant them sudo privileges.
|
||||
admin
|
||||
|
||||
# Other miscellaneous system users/groups created by common packages. While
|
||||
# it's useful to add things here that people might run into, it's not
|
||||
# absolutely critical; the worst that will happen is that the installation
|
||||
# will fail at some later point.
|
||||
Debian-exim
|
||||
bind
|
||||
crontab
|
||||
cupsys
|
||||
dcc
|
||||
dhcp
|
||||
dictd
|
||||
dnsmasq
|
||||
dovecot
|
||||
fetchmail
|
||||
firebird
|
||||
ftp
|
||||
fuse
|
||||
gdm
|
||||
haldaemon
|
||||
hplilp
|
||||
identd
|
||||
input
|
||||
jwhois
|
||||
klog
|
||||
kvm
|
||||
lpadmin
|
||||
maas
|
||||
messagebus
|
||||
mythtv
|
||||
netdev
|
||||
powerdev
|
||||
radvd
|
||||
render
|
||||
saned
|
||||
sbuild
|
||||
scanner
|
||||
sgx
|
||||
slocate
|
||||
ssh
|
||||
sshd
|
||||
ssl-cert
|
||||
sslwrap
|
||||
statd
|
||||
syslog
|
||||
telnetd
|
||||
tftpd
|
||||
@@ -6,7 +6,7 @@ set -euo pipefail
|
||||
# Public install entrypoint for clean Ubuntu/Debian servers.
|
||||
# ==========================================================
|
||||
|
||||
SELF_INSTALL_URL="${VIRTUALITY_INSTALL_URL:-https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh}"
|
||||
SELF_INSTALL_URL="${VIRTUALITY_INSTALL_URL:-https://git.stackworks.ru/viktor/virtuality/raw/branch/main/install.sh}"
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
@@ -21,20 +21,18 @@ if [[ "${EUID}" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$tmp_installer"
|
||||
exec sudo --preserve-env=VIRTUALITY_INSTALL_URL,VIRTUALITY_USER,VIRTUALITY_WEB_PORT,VIRTUALITY_MIN_ROOT_FREE_MB,VIRTUALITY_MIN_VAR_FREE_MB,VIRTUALITY_MIN_RAM_MB,VIRTUALITY_MIN_CPU_CORES,VIRTUALITY_SKIP_REQUIREMENTS,VIRTUALITY_SETUP_BRIDGE,VIRTUALITY_BRIDGE_IFACE,VIRTUALITY_CREATE_TEST_VM,VIRTUALITY_REPO_URL,VIRTUALITY_PROJECT_BASE_DIR,VIRTUALITY_PROJECT_DIR,VIRTUALITY_INSTALL_VERBOSE,VIRTUALITY_CLEAN_BEFORE_INSTALL bash "$tmp_installer" "$@"
|
||||
exec sudo --preserve-env=VIRTUALITY_INSTALL_URL,VIRTUALITY_USER,VIRTUALITY_WEB_PORT,VIRTUALITY_MIN_ROOT_FREE_MB,VIRTUALITY_MIN_VAR_FREE_MB,VIRTUALITY_MIN_RAM_MB,VIRTUALITY_MIN_CPU_CORES,VIRTUALITY_SKIP_REQUIREMENTS,VIRTUALITY_REPO_URL,VIRTUALITY_PROJECT_BASE_DIR,VIRTUALITY_PROJECT_DIR,VIRTUALITY_INSTALL_VERBOSE,VIRTUALITY_AUTO_UPDATE,VIRTUALITY_BRANCH bash "$tmp_installer" "$@"
|
||||
fi
|
||||
echo "Root or sudo is required. Run: curl -fsSL $SELF_INSTALL_URL | sudo bash" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_URL="${VIRTUALITY_REPO_URL:-https://github.com/viktor138irk/virtuality.git}"
|
||||
REPO_URL="${VIRTUALITY_REPO_URL:-https://git.stackworks.ru/viktor/virtuality.git}"
|
||||
DEFAULT_INSTALL_USER="${SUDO_USER:-root}"
|
||||
INSTALL_USER="${VIRTUALITY_USER:-$DEFAULT_INSTALL_USER}"
|
||||
PROJECT_BASE_DIR="${VIRTUALITY_PROJECT_BASE_DIR:-/opt/virtuality}"
|
||||
PROJECT_DIR="${VIRTUALITY_PROJECT_DIR:-${PROJECT_BASE_DIR}/source}"
|
||||
RUN_BRIDGE="${VIRTUALITY_SETUP_BRIDGE:-0}"
|
||||
BRIDGE_IFACE="${VIRTUALITY_BRIDGE_IFACE:-}"
|
||||
RUN_TEST_VM="${VIRTUALITY_CREATE_TEST_VM:-0}"
|
||||
BRANCH="${VIRTUALITY_BRANCH:-main}"
|
||||
WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}"
|
||||
LOG_DIR="/var/log/virtuality"
|
||||
LOG_FILE="${LOG_DIR}/install_$(date +%Y%m%d_%H%M%S).log"
|
||||
@@ -44,7 +42,7 @@ MIN_RAM_MB="${VIRTUALITY_MIN_RAM_MB:-4096}"
|
||||
MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}"
|
||||
SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}"
|
||||
INSTALL_VERBOSE="${VIRTUALITY_INSTALL_VERBOSE:-0}"
|
||||
CLEAN_BEFORE_INSTALL="${VIRTUALITY_CLEAN_BEFORE_INSTALL:-0}"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ESC="\033"
|
||||
RESET="${ESC}[0m"
|
||||
@@ -117,7 +115,7 @@ header() {
|
||||
clear 2>/dev/null || true
|
||||
echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
|
||||
echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Installer${RESET} ${CYAN}${BOLD}│${RESET}"
|
||||
echo -e "${CYAN}${BOLD}│${RESET} One-command KVM / libvirt / Cockpit / Web Panel setup ${CYAN}${BOLD}│${RESET}"
|
||||
echo -e "${CYAN}${BOLD}│${RESET} One-command KVM / libvirt / web panel setup ${CYAN}${BOLD}│${RESET}"
|
||||
echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||
echo
|
||||
echo -e "${GRAY}Repository:${RESET} ${REPO_URL}"
|
||||
@@ -250,13 +248,6 @@ ensure_user
|
||||
step "Готовим рабочую директорию в /opt"
|
||||
prepare_project_dir
|
||||
|
||||
if [[ "$CLEAN_BEFORE_INSTALL" == "1" && -x "$PROJECT_DIR/scripts/clean_install.sh" ]]; then
|
||||
step "Очищаем старую кривую установку перед установкой"
|
||||
run_logged "Старая установка очищена" bash "$PROJECT_DIR/scripts/clean_install.sh" --yes
|
||||
elif [[ "$CLEAN_BEFORE_INSTALL" == "1" ]]; then
|
||||
warn "Очистка запрошена, но clean_install.sh пока недоступен. Продолжаю установку."
|
||||
fi
|
||||
|
||||
step "Проверяем место после подготовки пользователя"
|
||||
root_free_after="$(free_mb_for_path /)"
|
||||
if (( root_free_after < MIN_ROOT_FREE_MB )); then
|
||||
@@ -266,25 +257,17 @@ ok "Место после подготовки пользователя: ${root_
|
||||
|
||||
step "Клонируем или обновляем репозиторий"
|
||||
if [[ -d "$PROJECT_DIR/.git" ]]; then
|
||||
run_logged "Репозиторий обновлён" run_as_user "cd '$PROJECT_DIR' && git fetch origin main && git reset --hard origin/main && git pull --ff-only"
|
||||
run_logged "Репозиторий обновлён (${BRANCH})" run_as_user "cd '$PROJECT_DIR' && git fetch origin '$BRANCH' && git checkout -q -B '$BRANCH' 'origin/$BRANCH'"
|
||||
else
|
||||
run_logged "Репозиторий склонирован" run_as_user "cd '$PROJECT_BASE_DIR' && git clone '$REPO_URL' source"
|
||||
fi
|
||||
|
||||
if [[ "$CLEAN_BEFORE_INSTALL" == "1" && -x "$PROJECT_DIR/scripts/clean_install.sh" ]]; then
|
||||
step "Очищаем старую кривую установку после обновления исходников"
|
||||
run_logged "Старая установка очищена" bash "$PROJECT_DIR/scripts/clean_install.sh" --yes
|
||||
run_logged "Репозиторий склонирован (${BRANCH})" run_as_user "cd '$PROJECT_BASE_DIR' && git clone --branch '$BRANCH' '$REPO_URL' source"
|
||||
fi
|
||||
|
||||
step "Запускаем основной установщик ноды"
|
||||
run_logged "Virtuality Node установлен" bash "$PROJECT_DIR/install_virtuality_node.sh"
|
||||
run_logged "Virtuality Node установлен" env VIRTUALITY_USER="$INSTALL_USER" bash "$PROJECT_DIR/install_virtuality_node.sh"
|
||||
|
||||
step "Устанавливаем healthcheck-команду"
|
||||
run_logged "Healthcheck установлен" bash "$PROJECT_DIR/scripts/install_healthcheck_command.sh"
|
||||
|
||||
step "Устанавливаем консольный dashboard"
|
||||
run_logged "Console dashboard установлен" bash "$PROJECT_DIR/scripts/install_console_dashboard.sh"
|
||||
|
||||
step "Устанавливаем web-панель"
|
||||
if [[ "$INSTALL_VERBOSE" == "1" ]]; then
|
||||
VIRTUALITY_AUTH_USER="$INSTALL_USER" VIRTUALITY_WEB_PORT="$WEB_PORT" bash "$PROJECT_DIR/scripts/install_web_panel.sh" 2>&1 | tee -a "$LOG_FILE"
|
||||
@@ -312,25 +295,6 @@ else
|
||||
[[ "$web_code" -eq 0 ]] && ok "Web-панель установлена" || fail "Web-панель не установлена"
|
||||
fi
|
||||
|
||||
if [[ "$RUN_BRIDGE" == "1" ]]; then
|
||||
step "Настраиваем bridge br0"
|
||||
if [[ -z "$BRIDGE_IFACE" ]]; then
|
||||
BRIDGE_IFACE="$(ip route | awk '/default/ {print $5; exit}')"
|
||||
fi
|
||||
if [[ -z "$BRIDGE_IFACE" ]]; then
|
||||
warn "Не удалось определить интерфейс для br0. Пропускаю bridge setup"
|
||||
else
|
||||
warn "Bridge меняет сеть. Если SSH зависнет, нужен физический доступ или консоль провайдера."
|
||||
bash "$PROJECT_DIR/scripts/setup_bridge_br0.sh" "$BRIDGE_IFACE" static >> "$LOG_FILE" 2>&1 && ok "Bridge br0 настроен на ${BRIDGE_IFACE}" || warn "Bridge setup завершился с предупреждением. Проверь лог"
|
||||
fi
|
||||
else
|
||||
warn "Bridge br0 не настраивался автоматически. Запуск вручную: sudo bash scripts/setup_bridge_br0.sh INTERFACE static"
|
||||
fi
|
||||
|
||||
if [[ "$RUN_TEST_VM" == "1" ]]; then
|
||||
step "Создаём тестовую VM"
|
||||
run_logged "Тестовая VM создана" bash "$PROJECT_DIR/scripts/create_test_vm.sh" || warn "Тестовая VM не создана. Проверь ISO/bridge/log"
|
||||
fi
|
||||
|
||||
step "Финальная диагностика"
|
||||
if command -v vhealth >/dev/null 2>&1; then
|
||||
@@ -346,18 +310,12 @@ echo -e "${GREEN}${BOLD}╭─────────────────
|
||||
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Virtuality установка завершена${RESET} ${GREEN}${BOLD}│${RESET}"
|
||||
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||
echo
|
||||
echo -e "${BOLD}Cockpit:${RESET} https://${SERVER_IP}:9090"
|
||||
echo -e "${BOLD}Web Panel:${RESET} http://${SERVER_IP}:${WEB_PORT}"
|
||||
echo -e "${BOLD}Login:${RESET} ${INSTALL_USER} / пароль Linux-пользователя"
|
||||
echo -e "${BOLD}Project:${RESET} ${PROJECT_DIR}"
|
||||
echo -e "${BOLD}Log:${RESET} ${LOG_FILE}"
|
||||
echo -e "${BOLD}Панель:${RESET} http://${SERVER_IP}:${WEB_PORT}"
|
||||
echo -e "${BOLD}Логин:${RESET} ${INSTALL_USER} / пароль пользователя Linux"
|
||||
echo -e "${BOLD}Исходники:${RESET} ${PROJECT_DIR}"
|
||||
echo -e "${BOLD}Лог:${RESET} ${LOG_FILE}"
|
||||
echo
|
||||
echo -e "${BOLD}После установки:${RESET}"
|
||||
echo " passwd ${INSTALL_USER} # если пароль ещё не задан или заблокирован"
|
||||
echo " sudo vhealth"
|
||||
echo " cd ${PROJECT_DIR}"
|
||||
echo " ip -br a"
|
||||
echo
|
||||
echo -e "${YELLOW}Важно:${RESET} bridge br0 по умолчанию не включается в one-command режиме, чтобы не уронить SSH."
|
||||
echo "Для bridge: cd ${PROJECT_DIR} && sudo bash scripts/setup_bridge_br0.sh INTERFACE static"
|
||||
echo -e "${BOLD}Дальше:${RESET} откройте панель в браузере — мастер настройки поможет включить HTTPS,"
|
||||
echo "выбрать сеть для машин (NAT или ваша локальная сеть) и хранилище."
|
||||
echo "Если пароль ещё не задан: passwd ${INSTALL_USER}"
|
||||
echo
|
||||
|
||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
|
||||
# ==========================================================
|
||||
# Virtuality Node Installer
|
||||
# Clean step-by-step installer for KVM/QEMU/libvirt/Cockpit
|
||||
# Clean step-by-step installer for KVM/QEMU/libvirt
|
||||
# ==========================================================
|
||||
|
||||
PROJECT_NAME="Virtuality"
|
||||
@@ -13,15 +13,17 @@ ISO_DIR="${STORAGE_DIR}/iso"
|
||||
IMAGES_DIR="${STORAGE_DIR}/images"
|
||||
BACKUP_DIR="${STORAGE_DIR}/backups"
|
||||
LOG_DIR="/var/log/virtuality"
|
||||
COCKPIT_PORT="9090"
|
||||
LOG_FILE="${LOG_DIR}/install_node_$(date +%Y%m%d_%H%M%S).log"
|
||||
TOTAL_STEPS=13
|
||||
TOTAL_STEPS=12
|
||||
CURRENT_STEP=0
|
||||
MIN_ROOT_FREE_MB="${VIRTUALITY_MIN_ROOT_FREE_MB:-8192}"
|
||||
MIN_VAR_FREE_MB="${VIRTUALITY_MIN_VAR_FREE_MB:-20480}"
|
||||
MIN_RAM_MB="${VIRTUALITY_MIN_RAM_MB:-4096}"
|
||||
MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}"
|
||||
SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
# Keep existing configuration files without asking: there is nobody to answer on first boot.
|
||||
APT_OPTS=(-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold)
|
||||
|
||||
ESC="\033"
|
||||
RESET="${ESC}[0m"
|
||||
@@ -44,12 +46,11 @@ print_header() {
|
||||
clear 2>/dev/null || true
|
||||
echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
|
||||
echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Node Installer${RESET} ${CYAN}${BOLD}│${RESET}"
|
||||
echo -e "${CYAN}${BOLD}│${RESET} KVM / QEMU / libvirt / Cockpit virtualization node ${CYAN}${BOLD}│${RESET}"
|
||||
echo -e "${CYAN}${BOLD}│${RESET} KVM / QEMU / libvirt virtualization node ${CYAN}${BOLD}│${RESET}"
|
||||
echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||
echo
|
||||
echo -e "${GRAY}Лог установки:${RESET} ${LOG_FILE}"
|
||||
echo -e "${GRAY}Storage:${RESET} ${STORAGE_DIR}"
|
||||
echo -e "${GRAY}Cockpit:${RESET} ${COCKPIT_PORT}/tcp"
|
||||
echo
|
||||
}
|
||||
|
||||
@@ -75,15 +76,18 @@ run_logged() {
|
||||
spinner=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
|
||||
i=0
|
||||
while kill -0 "$pid" 2>/dev/null; do
|
||||
elapsed=$(( $(date +%s) - start_ts ))
|
||||
symbol="${spinner[$((i % ${#spinner[@]}))]}"
|
||||
printf "\r ${CYAN}%s${RESET} %s... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "$symbol" "$description" "$elapsed" "$LOG_FILE"
|
||||
# The spinner only makes sense on a terminal; first boot writes this output to a log.
|
||||
if [[ -t 1 ]]; then
|
||||
elapsed=$(( $(date +%s) - start_ts ))
|
||||
symbol="${spinner[$((i % ${#spinner[@]}))]}"
|
||||
printf "\r ${CYAN}%s${RESET} %s... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "$symbol" "$description" "$elapsed" "$LOG_FILE"
|
||||
fi
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
done
|
||||
wait "$pid"
|
||||
code=$?
|
||||
printf "\r%*s\r" 120 ""
|
||||
code=0
|
||||
wait "$pid" || code=$?
|
||||
[[ -t 1 ]] && printf "\r%*s\r" 120 ""
|
||||
if [[ "$code" -eq 0 ]]; then
|
||||
ok "$description"
|
||||
else
|
||||
@@ -91,6 +95,22 @@ run_logged() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Installs the packages this distribution has; package names differ between
|
||||
# Ubuntu 24.04/26.04 and Debian 13 (e.g. qemu-kvm became a virtual package).
|
||||
install_available() {
|
||||
local description="$1"
|
||||
shift
|
||||
local pkg available=()
|
||||
for pkg in "$@"; do
|
||||
if apt-cache show "$pkg" >/dev/null 2>&1; then
|
||||
available+=("$pkg")
|
||||
else
|
||||
warn "Пакет ${pkg} недоступен в этом дистрибутиве, пропускаю"
|
||||
fi
|
||||
done
|
||||
run_logged "$description" apt-get install -y "${APT_OPTS[@]}" "${available[@]}"
|
||||
}
|
||||
|
||||
service_state() { systemctl is-active "$1" 2>/dev/null || echo "inactive"; }
|
||||
service_enabled() { systemctl is-enabled "$1" 2>/dev/null || echo "disabled"; }
|
||||
free_mb_for_path() { local path="$1"; mkdir -p "$path" 2>/dev/null || true; df -Pm "$path" | awk 'NR==2 {print $4}'; }
|
||||
@@ -149,21 +169,27 @@ else
|
||||
fi
|
||||
|
||||
step "Обновляем apt cache"
|
||||
run_logged "apt update выполнен" apt update
|
||||
run_logged "apt update выполнен" apt-get update
|
||||
# A previous attempt may have stopped half way (power loss, a question from dpkg): finish it first.
|
||||
run_logged "Незавершённые установки пакетов доделаны" dpkg --configure -a --force-confdef --force-confold
|
||||
|
||||
step "Устанавливаем базовые утилиты и зависимости диагностики"
|
||||
run_logged "Базовые пакеты и диагностические утилиты установлены" apt install -y \
|
||||
install_available "Базовые пакеты и диагностические утилиты установлены" \
|
||||
curl wget git nano htop btop tree ncdu jq unzip xz-utils tar gzip ca-certificates gnupg lsb-release \
|
||||
software-properties-common apt-transport-https ufw rsync iproute2 iptables nftables dnsutils net-tools openssh-client
|
||||
software-properties-common apt-transport-https ufw rsync iproute2 iptables nftables bind9-dnsutils net-tools openssh-client
|
||||
|
||||
step "Устанавливаем KVM/QEMU/libvirt"
|
||||
run_logged "Пакеты виртуализации установлены" apt install -y \
|
||||
qemu-kvm qemu-utils libvirt-daemon-system libvirt-clients virtinst \
|
||||
bridge-utils dnsmasq-base ovmf swtpm cloud-image-utils
|
||||
case "$(uname -m)" in
|
||||
aarch64|arm64) QEMU_SYSTEM_PACKAGES=(qemu-system-arm qemu-efi-aarch64) ;;
|
||||
*) QEMU_SYSTEM_PACKAGES=(qemu-system-x86 ovmf) ;;
|
||||
esac
|
||||
install_available "Пакеты виртуализации установлены" \
|
||||
"${QEMU_SYSTEM_PACKAGES[@]}" qemu-utils libvirt-daemon-system libvirt-clients virtinst \
|
||||
bridge-utils dnsmasq-base swtpm cloud-image-utils xorriso
|
||||
|
||||
step "Устанавливаем Cockpit и модули"
|
||||
run_logged "Cockpit установлен" apt install -y \
|
||||
cockpit cockpit-machines cockpit-networkmanager cockpit-storaged cockpit-packagekit
|
||||
step "Устанавливаем зависимости панели управления"
|
||||
install_available "Пакеты панели установлены" \
|
||||
python3 python3-venv rsync openssl nftables novnc python3-websockify
|
||||
|
||||
step "Создаём структуру Virtuality"
|
||||
run_logged "Директории Virtuality созданы" mkdir -p "$PROJECT_DIR" "$STORAGE_DIR" "$ISO_DIR" "$IMAGES_DIR" "$BACKUP_DIR" "$LOG_DIR"
|
||||
@@ -174,28 +200,26 @@ ok "Images: ${IMAGES_DIR}"
|
||||
ok "Backups: ${BACKUP_DIR}"
|
||||
|
||||
step "Добавляем пользователя в группы libvirt/kvm"
|
||||
REAL_USER="${SUDO_USER:-root}"
|
||||
REAL_USER="${VIRTUALITY_USER:-${SUDO_USER:-root}}"
|
||||
if [[ "$REAL_USER" != "root" ]]; then
|
||||
usermod -aG libvirt "$REAL_USER" >> "$LOG_FILE" 2>&1 || warn "Не удалось добавить ${REAL_USER} в libvirt"
|
||||
usermod -aG kvm "$REAL_USER" >> "$LOG_FILE" 2>&1 || warn "Не удалось добавить ${REAL_USER} в kvm"
|
||||
ok "Пользователь ${REAL_USER} добавлен в группы libvirt/kvm"
|
||||
warn "Чтобы группы применились, нужно выйти из SSH и зайти снова"
|
||||
else
|
||||
warn "Запуск под root без SUDO_USER; пользователь не добавлен в группы"
|
||||
warn "Запуск под root без SUDO_USER/VIRTUALITY_USER; пользователь не добавлен в группы"
|
||||
fi
|
||||
|
||||
step "Включаем systemd-сервисы"
|
||||
run_logged "libvirtd включён и запущен" systemctl enable --now libvirtd
|
||||
run_logged "virtlogd включён и запущен" systemctl enable --now virtlogd
|
||||
run_logged "cockpit.socket включён и запущен" systemctl enable --now cockpit.socket
|
||||
ok "libvirtd: $(service_state libvirtd) / $(service_enabled libvirtd)"
|
||||
ok "virtlogd: $(service_state virtlogd) / $(service_enabled virtlogd)"
|
||||
ok "cockpit.socket: $(service_state cockpit.socket) / $(service_enabled cockpit.socket)"
|
||||
|
||||
step "Настраиваем firewall"
|
||||
run_logged "Разрешён OpenSSH" ufw allow OpenSSH
|
||||
run_logged "Разрешён Cockpit ${COCKPIT_PORT}/tcp" ufw allow "${COCKPIT_PORT}/tcp"
|
||||
run_logged "Разрешены VNC-порты 5900:5999/tcp" ufw allow 5900:5999/tcp
|
||||
# Consoles are proxied by the panel; VNC ports stay closed from outside.
|
||||
ufw delete allow 5900:5999/tcp >> "$LOG_FILE" 2>&1 || true
|
||||
if ufw status | grep -qi inactive; then
|
||||
echo "y" | ufw enable >> "$LOG_FILE" 2>&1 && ok "UFW включён" || warn "Не удалось включить UFW"
|
||||
else
|
||||
@@ -222,7 +246,6 @@ ISO_DIR="${ISO_DIR}"
|
||||
IMAGES_DIR="${IMAGES_DIR}"
|
||||
BACKUP_DIR="${BACKUP_DIR}"
|
||||
LOG_DIR="${LOG_DIR}"
|
||||
COCKPIT_PORT="${COCKPIT_PORT}"
|
||||
EOF
|
||||
ok "Создан ${PROJECT_DIR}/virtuality.env"
|
||||
|
||||
@@ -233,11 +256,6 @@ if command -v virsh >/dev/null 2>&1; then
|
||||
else
|
||||
warn "virsh не найден после установки"
|
||||
fi
|
||||
if ss -tulpn 2>/dev/null | grep -q ':9090'; then
|
||||
ok "Cockpit слушает порт 9090"
|
||||
else
|
||||
warn "Порт 9090 не виден в ss; Cockpit может быть socket-activated"
|
||||
fi
|
||||
if [[ -e /dev/kvm ]]; then
|
||||
ok "/dev/kvm доступен"
|
||||
else
|
||||
@@ -251,7 +269,6 @@ echo -e "${GREEN}${BOLD}╭─────────────────
|
||||
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Установка Virtuality Node завершена${RESET} ${GREEN}${BOLD}│${RESET}"
|
||||
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||
echo
|
||||
echo -e "${BOLD}Cockpit:${RESET} https://${SERVER_IP}:${COCKPIT_PORT}"
|
||||
echo -e "${BOLD}Project:${RESET} ${PROJECT_DIR}"
|
||||
echo -e "${BOLD}ISO:${RESET} ${ISO_DIR}"
|
||||
echo -e "${BOLD}Images:${RESET} ${IMAGES_DIR}"
|
||||
@@ -262,9 +279,7 @@ echo -e "${BOLD}Проверка:${RESET}"
|
||||
echo " virsh list --all"
|
||||
echo " virsh pool-list --all"
|
||||
echo " systemctl status libvirtd --no-pager"
|
||||
echo " systemctl status cockpit.socket --no-pager"
|
||||
echo
|
||||
line
|
||||
echo -e "${DIM}Следующий шаг: sudo bash scripts/setup_bridge_br0.sh enp2s0 static${RESET}"
|
||||
echo -e "${DIM}После bridge: sudo bash scripts/create_test_vm.sh${RESET}"
|
||||
echo -e "${DIM}Дальше: sudo bash scripts/install_web_panel.sh — панель управления и мастер настройки${RESET}"
|
||||
line
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
addopts = -p no:cacheprovider
|
||||
@@ -0,0 +1,4 @@
|
||||
# Development and CI tools
|
||||
pytest==8.3.4
|
||||
httpx==0.28.1
|
||||
pyflakes==3.2.0
|
||||
@@ -7,10 +7,13 @@ STATE_FILE="${STATE_DIR}/state.json"
|
||||
LOG_FILE="/var/log/virtuality/update.log"
|
||||
REMOTE="${VIRTUALITY_UPDATE_REMOTE:-origin}"
|
||||
BRANCH="${VIRTUALITY_UPDATE_BRANCH:-main}"
|
||||
REPO_ZIP_URL="${VIRTUALITY_UPDATE_ZIP_URL:-https://github.com/viktor138irk/virtuality/archive/refs/heads/${BRANCH}.zip}"
|
||||
TMP_DIR="${STATE_DIR}/tmp"
|
||||
|
||||
# shellcheck source=scripts/update_target.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/update_target.sh"
|
||||
mkdir -p "$STATE_DIR" "$TMP_DIR" "$(dirname "$LOG_FILE")"
|
||||
MOVED_REMOTE="$(migrate_update_remote)"
|
||||
resolve_update_target
|
||||
REPO_ZIP_URL="${VIRTUALITY_UPDATE_ZIP_URL:-$TARGET_ZIP_URL}"
|
||||
|
||||
now() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||
json_escape() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])'; }
|
||||
@@ -48,14 +51,14 @@ try_run() {
|
||||
|
||||
fetch_zip_update() {
|
||||
local zip_file extract_dir extracted_root
|
||||
zip_file="${TMP_DIR}/virtuality-${BRANCH}.zip"
|
||||
zip_file="${TMP_DIR}/virtuality-${TARGET_LABEL}.zip"
|
||||
extract_dir="${TMP_DIR}/zip-update"
|
||||
rm -rf "$zip_file" "$extract_dir"
|
||||
mkdir -p "$extract_dir"
|
||||
|
||||
log "Fallback update via ZIP started"
|
||||
log "ZIP URL: $REPO_ZIP_URL"
|
||||
write_state "running" "GitHub git fetch не сработал, пробуем ZIP fallback"
|
||||
write_state "running" "git fetch не сработал, пробуем скачать ZIP-архив"
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if ! try_run curl -L --connect-timeout 20 --max-time 240 --retry 3 --retry-delay 3 -o "$zip_file" "$REPO_ZIP_URL"; then
|
||||
@@ -108,7 +111,8 @@ fetch_zip_update() {
|
||||
STARTED_AT="$(now)"
|
||||
write_state "running" "Обновление запущено"
|
||||
log "============================================================"
|
||||
log "Virtuality GitHub update started"
|
||||
log "Virtuality update started"
|
||||
[ -n "$MOVED_REMOTE" ] && log "$MOVED_REMOTE"
|
||||
log "source: $SOURCE_DIR"
|
||||
log "remote: $REMOTE"
|
||||
log "branch: $BRANCH"
|
||||
@@ -126,19 +130,20 @@ log "before version: $BEFORE_VERSION"
|
||||
GIT_UPDATED=0
|
||||
if [ -d "$SOURCE_DIR/.git" ]; then
|
||||
cd "$SOURCE_DIR" || exit 1
|
||||
write_state "running" "Получаем изменения из GitHub через git"
|
||||
if try_run git fetch "$REMOTE" "$BRANCH"; then
|
||||
TARGET_REF="${REMOTE}/${BRANCH}"
|
||||
TARGET_COMMIT="$(git rev-parse "$TARGET_REF" 2>/dev/null || true)"
|
||||
write_state "running" "Получаем изменения через git"
|
||||
if try_run git fetch --tags --force "$REMOTE" "$BRANCH"; then
|
||||
resolve_update_target
|
||||
log "channel: ${UPDATE_CHANNEL}, target: ${TARGET_LABEL}"
|
||||
TARGET_COMMIT="$(git rev-parse "${TARGET_REF}^{commit}" 2>/dev/null || true)"
|
||||
if [ -n "$TARGET_COMMIT" ]; then
|
||||
LOCAL_STATUS="$(git status --porcelain 2>/dev/null || true)"
|
||||
if [ -n "$LOCAL_STATUS" ]; then
|
||||
log "Local changes before update:"
|
||||
printf '%s\n' "$LOCAL_STATUS" >> "$LOG_FILE"
|
||||
fi
|
||||
write_state "running" "Синхронизируем исходники с GitHub через git reset"
|
||||
log "Deploy mode: git reset --hard $TARGET_REF"
|
||||
if try_run git reset --hard "$TARGET_REF" && try_run git clean -fd; then
|
||||
write_state "running" "Синхронизируем исходники через git reset"
|
||||
log "Deploy mode: git checkout -B ${BRANCH} ${TARGET_LABEL}"
|
||||
if try_run git checkout -q -f -B "$BRANCH" "$TARGET_COMMIT" && try_run git clean -fd; then
|
||||
GIT_UPDATED=1
|
||||
else
|
||||
log "git reset/clean failed, ZIP fallback will be used"
|
||||
@@ -155,7 +160,7 @@ fi
|
||||
|
||||
if [ "$GIT_UPDATED" != "1" ]; then
|
||||
if ! fetch_zip_update; then
|
||||
write_state "error" "Не удалось обновиться ни через git, ни через ZIP fallback. Проверь доступ к github.com, curl/wget/unzip и DNS."
|
||||
write_state "error" "Не удалось обновиться ни через git, ни через ZIP fallback. Проверьте доступ сервера к ${REPO_ZIP_URL%%/archive/*}, curl/wget/unzip и DNS."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
@@ -176,6 +181,6 @@ run_step bash scripts/install_web_panel.sh
|
||||
write_state "running" "Перезапускаем virtuality-web"
|
||||
run_step systemctl restart virtuality-web
|
||||
|
||||
log "Virtuality GitHub update finished successfully"
|
||||
log "Virtuality update finished successfully"
|
||||
write_state "success" "Обновление завершено: ${BEFORE_VERSION} → ${AFTER_VERSION}"
|
||||
exit 0
|
||||
|
||||
@@ -9,7 +9,8 @@ LOG_FILE="/var/log/virtuality/update.log"
|
||||
REMOTE="${VIRTUALITY_UPDATE_REMOTE:-origin}"
|
||||
BRANCH="${VIRTUALITY_UPDATE_BRANCH:-main}"
|
||||
APPLY_SCRIPT="${SOURCE_DIR}/scripts/apply_github_update.sh"
|
||||
ZIP_URL="${VIRTUALITY_UPDATE_ZIP_URL:-https://github.com/viktor138irk/virtuality/archive/refs/heads/${BRANCH}.zip}"
|
||||
# shellcheck source=scripts/update_target.sh
|
||||
. "${SOURCE_DIR}/scripts/update_target.sh"
|
||||
|
||||
mkdir -p "$STATE_DIR" "$(dirname "$LOG_FILE")"
|
||||
|
||||
@@ -34,10 +35,14 @@ JSON
|
||||
}
|
||||
|
||||
diag_network() {
|
||||
local web repo_host
|
||||
web="$(repo_web_url)"
|
||||
repo_host="${web#https://}"
|
||||
repo_host="${repo_host%%/*}"
|
||||
log "diagnostics: host=$(hostname 2>/dev/null || echo unknown)"
|
||||
log "diagnostics: date=$(date -Is 2>/dev/null || date)"
|
||||
if command -v getent >/dev/null 2>&1; then
|
||||
getent hosts github.com >> "$LOG_FILE" 2>&1 || log "diagnostics: DNS github.com failed"
|
||||
getent hosts "$repo_host" >> "$LOG_FILE" 2>&1 || log "diagnostics: DNS $repo_host failed"
|
||||
fi
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
git --version >> "$LOG_FILE" 2>&1 || true
|
||||
@@ -46,7 +51,7 @@ diag_network() {
|
||||
log "diagnostics: git not found"
|
||||
fi
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -I -L --connect-timeout 10 --max-time 25 https://github.com/ >> "$LOG_FILE" 2>&1 || log "diagnostics: curl github.com failed"
|
||||
curl -I -L --connect-timeout 10 --max-time 25 "$web" >> "$LOG_FILE" 2>&1 || log "diagnostics: curl $repo_host failed"
|
||||
else
|
||||
log "diagnostics: curl not found"
|
||||
fi
|
||||
@@ -88,14 +93,24 @@ diag_network() {
|
||||
current="unknown"
|
||||
fi
|
||||
|
||||
moved="$(migrate_update_remote)"
|
||||
[ -n "$moved" ] && log "$moved"
|
||||
|
||||
if [ -d "$SOURCE_DIR/.git" ]; then
|
||||
if git fetch --quiet "$REMOTE" "$BRANCH" >> "$LOG_FILE" 2>&1; then
|
||||
latest="$(git rev-parse "${REMOTE}/${BRANCH}" 2>/dev/null || true)"
|
||||
if git fetch --quiet --tags --force "$REMOTE" "$BRANCH" >> "$LOG_FILE" 2>&1; then
|
||||
resolve_update_target
|
||||
log "channel: ${UPDATE_CHANNEL}, target: ${TARGET_LABEL}"
|
||||
latest="$(git rev-parse "${TARGET_REF}^{commit}" 2>/dev/null || true)"
|
||||
if [ -n "$latest" ] && [ "$current" = "$latest" ]; then
|
||||
log "no updates: ${current:0:12} / version $current_version"
|
||||
write_state "idle" "Автообновление: новых обновлений нет"
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "$latest" ] && [ "$current" != "unknown" ] && ! git merge-base --is-ancestor "$current" "$latest" 2>/dev/null; then
|
||||
log "local build ${current:0:12} is not behind ${TARGET_LABEL} (${latest:0:12}); skip to avoid a downgrade"
|
||||
write_state "idle" "Автообновление: установленная версия новее ${TARGET_LABEL}, обновление пропущено"
|
||||
exit 0
|
||||
fi
|
||||
log "update may be available or current commit unknown: ${current:0:12} -> ${latest:0:12}"
|
||||
else
|
||||
log "git fetch failed; diagnostics and apply ZIP fallback will be used"
|
||||
@@ -112,6 +127,6 @@ diag_network() {
|
||||
exit 0
|
||||
fi
|
||||
|
||||
write_state "running" "Автообновление: проверяем и устанавливаем обновление через устойчивый механизм"
|
||||
VIRTUALITY_SOURCE_DIR="$SOURCE_DIR" VIRTUALITY_UPDATE_REMOTE="$REMOTE" VIRTUALITY_UPDATE_BRANCH="$BRANCH" VIRTUALITY_UPDATE_ZIP_URL="$ZIP_URL" bash "$APPLY_SCRIPT"
|
||||
write_state "running" "Автообновление: устанавливаем обновление"
|
||||
VIRTUALITY_SOURCE_DIR="$SOURCE_DIR" VIRTUALITY_UPDATE_REMOTE="$REMOTE" VIRTUALITY_UPDATE_BRANCH="$BRANCH" bash "$APPLY_SCRIPT"
|
||||
) 9>"$LOCK_FILE"
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "=================================================="
|
||||
echo " Virtuality Node Diagnostics"
|
||||
echo "=================================================="
|
||||
|
||||
echo
|
||||
echo "[System]"
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
. /etc/os-release
|
||||
echo "OS: ${PRETTY_NAME}"
|
||||
fi
|
||||
uname -a
|
||||
|
||||
echo
|
||||
echo "[CPU virtualization]"
|
||||
if grep -E -q '(vmx|svm)' /proc/cpuinfo; then
|
||||
echo "OK: hardware virtualization flag found"
|
||||
else
|
||||
echo "WARNING: vmx/svm flag not found"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "[Services]"
|
||||
for service in libvirtd virtlogd cockpit.socket; do
|
||||
if systemctl list-unit-files | grep -q "^${service}"; then
|
||||
echo -n "${service}: "
|
||||
systemctl is-active "$service" || true
|
||||
else
|
||||
echo "${service}: not installed"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "[Packages]"
|
||||
for bin in virsh qemu-system-x86_64 virt-install cockpit-bridge; do
|
||||
if command -v "$bin" >/dev/null 2>&1; then
|
||||
echo "OK: $bin -> $(command -v "$bin")"
|
||||
else
|
||||
echo "MISSING: $bin"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "[libvirt VMs]"
|
||||
if command -v virsh >/dev/null 2>&1; then
|
||||
virsh list --all || true
|
||||
else
|
||||
echo "virsh not found"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "[libvirt storage pools]"
|
||||
if command -v virsh >/dev/null 2>&1; then
|
||||
virsh pool-list --all || true
|
||||
else
|
||||
echo "virsh not found"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "[Network interfaces]"
|
||||
ip -br a || true
|
||||
|
||||
echo
|
||||
echo "[Routes]"
|
||||
ip route || true
|
||||
|
||||
echo
|
||||
echo "[Virtuality paths]"
|
||||
for dir in /opt/virtuality /var/lib/virtuality /var/lib/virtuality/iso /var/lib/virtuality/images /var/lib/virtuality/backups /var/log/virtuality; do
|
||||
if [[ -d "$dir" ]]; then
|
||||
echo "OK: $dir"
|
||||
else
|
||||
echo "MISSING: $dir"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "[Firewall]"
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
ufw status || true
|
||||
else
|
||||
echo "ufw not installed"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=================================================="
|
||||
echo " Diagnostics completed"
|
||||
echo "=================================================="
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Virtuality clean install helper.
|
||||
# Default mode preserves user data: VM disks, ISO files, uploaded disk images and backups.
|
||||
# Use --purge-data only when you intentionally want to remove Virtuality data directories too.
|
||||
|
||||
PURGE_DATA=0
|
||||
YES=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--purge-data) PURGE_DATA=1 ;;
|
||||
--yes|-y) YES=1 ;;
|
||||
*) echo "Unknown argument: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
exec sudo bash "$0" "$@"
|
||||
fi
|
||||
|
||||
RED="\033[31m"
|
||||
GREEN="\033[32m"
|
||||
YELLOW="\033[33m"
|
||||
BOLD="\033[1m"
|
||||
RESET="\033[0m"
|
||||
|
||||
say() { echo -e "${GREEN}✓${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}!${RESET} $*"; }
|
||||
remove_path() {
|
||||
local path="$1"
|
||||
if [[ -e "$path" || -L "$path" ]]; then
|
||||
rm -rf "$path"
|
||||
say "removed: $path"
|
||||
else
|
||||
warn "not found: $path"
|
||||
fi
|
||||
}
|
||||
|
||||
cat <<EOF
|
||||
${BOLD}Virtuality clean install${RESET}
|
||||
|
||||
Will remove:
|
||||
- /opt/virtuality/web
|
||||
- /opt/virtuality/venv
|
||||
- /etc/systemd/system/virtuality-web.service
|
||||
- /var/lib/virtuality/config
|
||||
- /var/lib/virtuality/update
|
||||
- /var/log/virtuality/update.log and install logs
|
||||
|
||||
Will preserve by default:
|
||||
- /opt/virtuality/source
|
||||
- /var/lib/virtuality/iso
|
||||
- /var/lib/virtuality/images
|
||||
- /var/lib/virtuality/disk-images
|
||||
- /var/lib/virtuality/backups
|
||||
- existing libvirt VMs and storage pools
|
||||
EOF
|
||||
|
||||
if [[ "$PURGE_DATA" -eq 1 ]]; then
|
||||
cat <<EOF
|
||||
|
||||
${RED}${BOLD}PURGE DATA MODE ENABLED${RESET}
|
||||
Will also remove:
|
||||
- /var/lib/virtuality/iso
|
||||
- /var/lib/virtuality/images
|
||||
- /var/lib/virtuality/disk-images
|
||||
- /var/lib/virtuality/backups
|
||||
- /var/lib/virtuality/network
|
||||
|
||||
Existing libvirt VM definitions are not deleted automatically.
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [[ "$YES" -ne 1 ]]; then
|
||||
echo
|
||||
read -r -p "Continue? Type YES: " answer
|
||||
if [[ "$answer" != "YES" ]]; then
|
||||
echo "Cancelled."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
systemctl stop virtuality-web.service 2>/dev/null || true
|
||||
systemctl disable virtuality-web.service 2>/dev/null || true
|
||||
|
||||
remove_path /etc/systemd/system/virtuality-web.service
|
||||
systemctl daemon-reload || true
|
||||
|
||||
remove_path /opt/virtuality/web
|
||||
remove_path /opt/virtuality/venv
|
||||
remove_path /var/lib/virtuality/config
|
||||
remove_path /var/lib/virtuality/update
|
||||
remove_path /var/log/virtuality/update.log
|
||||
find /var/log/virtuality -maxdepth 1 -type f -name 'install_web_panel_*.log' -delete 2>/dev/null || true
|
||||
say "old installer logs removed"
|
||||
|
||||
if [[ "$PURGE_DATA" -eq 1 ]]; then
|
||||
remove_path /var/lib/virtuality/iso
|
||||
remove_path /var/lib/virtuality/images
|
||||
remove_path /var/lib/virtuality/disk-images
|
||||
remove_path /var/lib/virtuality/backups
|
||||
remove_path /var/lib/virtuality/network
|
||||
fi
|
||||
|
||||
mkdir -p /opt/virtuality /var/log/virtuality /var/lib/virtuality
|
||||
say "base directories recreated"
|
||||
|
||||
echo
|
||||
say "Clean install cleanup finished. Run: sudo bash scripts/install_web_panel.sh"
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VM_NAME="${1:-test-alpine}"
|
||||
ISO_URL="${VIRTUALITY_TEST_ISO_URL:-https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-standard-3.20.3-x86_64.iso}"
|
||||
ISO_PATH="/var/lib/virtuality/iso/alpine-standard.iso"
|
||||
DISK_PATH="/var/lib/virtuality/images/${VM_NAME}.qcow2"
|
||||
BRIDGE="${VIRTUALITY_BRIDGE:-br0}"
|
||||
MEMORY="${VIRTUALITY_TEST_VM_MEMORY:-1024}"
|
||||
VCPUS="${VIRTUALITY_TEST_VM_VCPUS:-1}"
|
||||
DISK_SIZE="${VIRTUALITY_TEST_VM_DISK_SIZE:-8}"
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
echo "Ошибка: запусти через sudo: sudo bash scripts/create_test_vm.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v virt-install >/dev/null 2>&1; then
|
||||
echo "Ошибка: virt-install не найден. Запусти install_virtuality_node.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v virsh >/dev/null 2>&1; then
|
||||
echo "Ошибка: virsh не найден. Запусти install_virtuality_node.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! ip link show "$BRIDGE" >/dev/null 2>&1; then
|
||||
echo "Ошибка: bridge $BRIDGE не найден. Сначала настрой br0."
|
||||
ip -br a
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p /var/lib/virtuality/iso /var/lib/virtuality/images
|
||||
|
||||
if virsh dominfo "$VM_NAME" >/dev/null 2>&1; then
|
||||
echo "Виртуальная машина $VM_NAME уже существует."
|
||||
virsh list --all
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ISO_PATH" ]]; then
|
||||
echo "Скачиваю тестовый ISO: $ISO_URL"
|
||||
wget -O "$ISO_PATH" "$ISO_URL"
|
||||
fi
|
||||
|
||||
virsh pool-refresh virtuality-iso >/dev/null 2>&1 || true
|
||||
virsh pool-refresh virtuality-images >/dev/null 2>&1 || true
|
||||
|
||||
echo "Создаю тестовую VM: $VM_NAME"
|
||||
virt-install \
|
||||
--name "$VM_NAME" \
|
||||
--memory "$MEMORY" \
|
||||
--vcpus "$VCPUS" \
|
||||
--disk "path=${DISK_PATH},size=${DISK_SIZE},format=qcow2,bus=virtio" \
|
||||
--cdrom "$ISO_PATH" \
|
||||
--os-variant generic \
|
||||
--network "bridge=${BRIDGE},model=virtio" \
|
||||
--graphics vnc,listen=0.0.0.0 \
|
||||
--noautoconsole
|
||||
|
||||
echo
|
||||
echo "VM создана."
|
||||
virsh list --all
|
||||
|
||||
echo
|
||||
echo "VNC display:"
|
||||
virsh vncdisplay "$VM_NAME" || true
|
||||
|
||||
echo
|
||||
echo "Открыть в Cockpit: https://SERVER_IP:9090 → Virtual machines → ${VM_NAME}"
|
||||
@@ -1,104 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Virtuality Cockpit auth helper
|
||||
# Checks Linux user password state, Cockpit disallowed-users and required groups.
|
||||
|
||||
USER_NAME="${1:-${VIRTUALITY_USER:-${SUDO_USER:-root}}}"
|
||||
DISALLOWED_FILE="/etc/cockpit/disallowed-users"
|
||||
|
||||
ESC="\033"
|
||||
RESET="${ESC}[0m"
|
||||
BOLD="${ESC}[1m"
|
||||
GREEN="${ESC}[32m"
|
||||
YELLOW="${ESC}[33m"
|
||||
RED="${ESC}[31m"
|
||||
CYAN="${ESC}[36m"
|
||||
GRAY="${ESC}[90m"
|
||||
|
||||
ok() { echo -e "${GREEN}✓${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}!${RESET} $*"; }
|
||||
fail() { echo -e "${RED}✗${RESET} $*"; exit 1; }
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
fail "Запусти от root: sudo bash scripts/fix_cockpit_auth.sh [user]"
|
||||
fi
|
||||
|
||||
clear 2>/dev/null || true
|
||||
cat <<EOF
|
||||
${CYAN}${BOLD}Virtuality Cockpit auth check${RESET}
|
||||
${GRAY}User:${RESET} ${USER_NAME}
|
||||
EOF
|
||||
|
||||
echo
|
||||
|
||||
if ! id "$USER_NAME" >/dev/null 2>&1; then
|
||||
fail "Пользователь ${USER_NAME} не найден"
|
||||
fi
|
||||
ok "Пользователь найден: ${USER_NAME}"
|
||||
|
||||
HASH="$(getent shadow "$USER_NAME" | cut -d: -f2 || true)"
|
||||
if [[ -z "$HASH" || "$HASH" == "!" || "$HASH" == "*" || "$HASH" == "!!" ]]; then
|
||||
warn "У пользователя ${USER_NAME} пароль не задан или заблокирован"
|
||||
echo
|
||||
echo -e "${BOLD}Для входа в Cockpit задай пароль:${RESET}"
|
||||
if [[ "$USER_NAME" == "root" ]]; then
|
||||
echo " passwd root"
|
||||
else
|
||||
echo " sudo passwd ${USER_NAME}"
|
||||
fi
|
||||
else
|
||||
ok "У пользователя ${USER_NAME} есть пароль"
|
||||
fi
|
||||
|
||||
if [[ -f "$DISALLOWED_FILE" ]]; then
|
||||
if grep -qx "$USER_NAME" "$DISALLOWED_FILE"; then
|
||||
cp "$DISALLOWED_FILE" "${DISALLOWED_FILE}.bak.$(date +%Y%m%d_%H%M%S)"
|
||||
sed -i "/^${USER_NAME}$/d" "$DISALLOWED_FILE"
|
||||
ok "Пользователь ${USER_NAME} удалён из ${DISALLOWED_FILE}"
|
||||
else
|
||||
ok "Пользователь ${USER_NAME} не запрещён в ${DISALLOWED_FILE}"
|
||||
fi
|
||||
else
|
||||
warn "Файл ${DISALLOWED_FILE} не найден, пропускаю проверку запрета пользователей"
|
||||
fi
|
||||
|
||||
if [[ "$USER_NAME" != "root" ]]; then
|
||||
usermod -aG sudo "$USER_NAME" || true
|
||||
usermod -aG libvirt "$USER_NAME" || true
|
||||
usermod -aG kvm "$USER_NAME" || true
|
||||
ok "Пользователь ${USER_NAME} добавлен в группы sudo/libvirt/kvm"
|
||||
warn "Для применения групп нужно выйти из SSH и войти снова"
|
||||
else
|
||||
ok "root не требует добавления в группы"
|
||||
fi
|
||||
|
||||
if command -v apt >/dev/null 2>&1; then
|
||||
apt install -y cockpit cockpit-machines sscg >/dev/null 2>&1 || true
|
||||
ok "Пакеты cockpit/cockpit-machines/sscg проверены"
|
||||
fi
|
||||
|
||||
systemctl enable --now cockpit.socket >/dev/null 2>&1 || fail "Не удалось запустить cockpit.socket"
|
||||
ok "cockpit.socket включён и запущен"
|
||||
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
ufw allow 9090/tcp >/dev/null 2>&1 || true
|
||||
ok "UFW правило 9090/tcp добавлено/проверено"
|
||||
fi
|
||||
|
||||
systemctl restart cockpit.socket >/dev/null 2>&1 || true
|
||||
systemctl restart cockpit.service >/dev/null 2>&1 || true
|
||||
ok "Cockpit перезапущен"
|
||||
|
||||
SERVER_IP="$(hostname -I | awk '{print $1}')"
|
||||
PUBLIC_IP="$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") {print $(i+1); exit}}')"
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}${BOLD}Готово.${RESET}"
|
||||
echo -e "${BOLD}Cockpit local/private:${RESET} https://${SERVER_IP}:9090"
|
||||
if [[ -n "$PUBLIC_IP" && "$PUBLIC_IP" != "$SERVER_IP" ]]; then
|
||||
echo -e "${BOLD}Cockpit route IP:${RESET} https://${PUBLIC_IP}:9090"
|
||||
fi
|
||||
echo -e "${BOLD}Login:${RESET} ${USER_NAME}"
|
||||
echo -e "${BOLD}Password:${RESET} пароль Linux-пользователя ${USER_NAME}"
|
||||
echo
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DASH_SRC="${REPO_DIR}/scripts/virtuality_console_dashboard.sh"
|
||||
DASH_DST="/usr/local/bin/virtuality-console-dashboard"
|
||||
SERVICE_FILE="/etc/systemd/system/virtuality-console-dashboard.service"
|
||||
TARGET_USER="${SUDO_USER:-${USER:-root}}"
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
echo "Ошибка: запусти через sudo: sudo bash scripts/install_console_dashboard.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$DASH_SRC" ]]; then
|
||||
echo "Ошибка: не найден файл $DASH_SRC"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
apt update
|
||||
apt install -y btop procps iproute2 util-linux coreutils
|
||||
|
||||
install -m 0755 "$DASH_SRC" "$DASH_DST"
|
||||
|
||||
cat > "$SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Virtuality console dashboard on tty1
|
||||
After=multi-user.target network-online.target libvirtd.service cockpit.socket
|
||||
Wants=network-online.target
|
||||
Conflicts=getty@tty1.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/virtuality-console-dashboard
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
StandardInput=tty
|
||||
StandardOutput=tty
|
||||
TTYPath=/dev/tty1
|
||||
TTYReset=yes
|
||||
TTYVHangup=yes
|
||||
TTYVTDisallocate=yes
|
||||
Environment=TERM=linux
|
||||
Environment=VIRTUALITY_DASHBOARD_INTERVAL=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl disable --now getty@tty1.service >/dev/null 2>&1 || true
|
||||
systemctl enable --now virtuality-console-dashboard.service
|
||||
|
||||
USER_HOME="$(getent passwd "$TARGET_USER" | cut -d: -f6 || true)"
|
||||
if [[ -n "$USER_HOME" && -d "$USER_HOME" ]]; then
|
||||
BASHRC="$USER_HOME/.bashrc"
|
||||
touch "$BASHRC"
|
||||
grep -q "alias bt=" "$BASHRC" || cat >> "$BASHRC" <<'EOF'
|
||||
|
||||
# Virtuality quick aliases
|
||||
alias bt='btop'
|
||||
alias dash='sudo systemctl restart virtuality-console-dashboard && sudo chvt 1'
|
||||
alias dash-stop='sudo systemctl stop virtuality-console-dashboard'
|
||||
alias dash-start='sudo systemctl start virtuality-console-dashboard'
|
||||
alias dash-status='systemctl status virtuality-console-dashboard --no-pager'
|
||||
alias vcheck='cd ~/virtuality && sudo bash scripts/check_node.sh'
|
||||
EOF
|
||||
chown "$TARGET_USER:$TARGET_USER" "$BASHRC" || true
|
||||
fi
|
||||
|
||||
echo "============================================================"
|
||||
echo "Virtuality console dashboard installed"
|
||||
echo "============================================================"
|
||||
echo "Физический монитор: tty1"
|
||||
echo "Перейти на дашборд: sudo chvt 1"
|
||||
echo "Команда btop: bt"
|
||||
echo "Перезапустить дашборд: dash"
|
||||
echo "Остановить дашборд: dash-stop"
|
||||
echo "Запустить дашборд: dash-start"
|
||||
echo "Статус: dash-status"
|
||||
echo "============================================================"
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/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"
|
||||
@@ -4,7 +4,7 @@ set -euo pipefail
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SRC="${REPO_DIR}/scripts/virtuality_healthcheck.sh"
|
||||
DST="/usr/local/bin/vhealth"
|
||||
TARGET_USER="${SUDO_USER:-${USER:-root}}"
|
||||
TARGET_USER="${VIRTUALITY_USER:-${SUDO_USER:-${USER:-root}}}"
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
echo "Ошибка: запусти через sudo: sudo bash scripts/install_healthcheck_command.sh"
|
||||
|
||||
@@ -1,26 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Virtuality Web Panel Installer
|
||||
# Auto-detects x86_64 / Raspberry Pi ARM64 / Orange Pi 5 ARM64 / generic ARM64 profile.
|
||||
# ==========================================================
|
||||
# Virtuality web panel installer
|
||||
# Copies web/ to /opt/virtuality/web (keeping the previous
|
||||
# version for rollback), builds the virtualenv (offline from
|
||||
# bundled wheels when available), installs virtuality-ctl and
|
||||
# lets it create the systemd units, TLS and firewall rules.
|
||||
#
|
||||
# VIRTUALITY_PANEL_ONLY=1 skip apt entirely (first boot from the image,
|
||||
# before the network is up)
|
||||
# VIRTUALITY_AUTH_USER Linux user that logs in to the panel
|
||||
# VIRTUALITY_WEB_PORT HTTP port (default 8088)
|
||||
# ==========================================================
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WEB_DIR="${REPO_DIR}/web"
|
||||
APP_DIR="/opt/virtuality/web"
|
||||
VENV_DIR="/opt/virtuality/venv"
|
||||
SERVICE_FILE="/etc/systemd/system/virtuality-web.service"
|
||||
AUTO_UPDATE_SERVICE_FILE="/etc/systemd/system/virtuality-auto-update.service"
|
||||
AUTO_UPDATE_TIMER_FILE="/etc/systemd/system/virtuality-auto-update.timer"
|
||||
PORT="${VIRTUALITY_WEB_PORT:-8088}"
|
||||
AUTH_USER="${VIRTUALITY_AUTH_USER:-${SUDO_USER:-viktor}}"
|
||||
CTL_SRC="${REPO_DIR}/scripts/virtuality-ctl"
|
||||
CTL_DST="/usr/local/bin/virtuality-ctl"
|
||||
LOG_DIR="/var/log/virtuality"
|
||||
LOG_FILE="${LOG_DIR}/install_web_panel_$(date +%Y%m%d_%H%M%S).log"
|
||||
PROFILE_DIR="/var/lib/virtuality/config"
|
||||
PROFILE_FILE="${PROFILE_DIR}/host_profile.json"
|
||||
SESSION_SECRET_FILE="${PROFILE_DIR}/session_secret"
|
||||
CONFIG_DIR="/var/lib/virtuality/config"
|
||||
PROFILE_FILE="${CONFIG_DIR}/host_profile.json"
|
||||
SESSION_SECRET_FILE="${CONFIG_DIR}/session_secret"
|
||||
NODE_CONFIG_FILE="${CONFIG_DIR}/web.env"
|
||||
SETUP_STATE_FILE="${CONFIG_DIR}/setup.json"
|
||||
UPLOAD_TMP_DIR="/var/lib/virtuality/tmp"
|
||||
TOTAL_STEPS=13
|
||||
WHEELS_DIR="${VIRTUALITY_WHEELS_DIR:-${REPO_DIR}/wheels}"
|
||||
PANEL_ONLY="${VIRTUALITY_PANEL_ONLY:-0}"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Settings survive updates: explicit env > saved node config > defaults.
|
||||
saved_setting() {
|
||||
local key="$1"
|
||||
[[ -f "$NODE_CONFIG_FILE" ]] || return 0
|
||||
sed -n "s/^${key}=//p" "$NODE_CONFIG_FILE" | tail -n1 | tr -d "\"'"
|
||||
}
|
||||
first_human_user() {
|
||||
getent passwd | awk -F: '$3 >= 1000 && $3 < 60000 && $7 !~ /(nologin|false)$/ {print $1; exit}'
|
||||
}
|
||||
env_set() {
|
||||
local key="$1" value="$2"
|
||||
if grep -q "^${key}=" "$NODE_CONFIG_FILE" 2>/dev/null; then
|
||||
sed -i "s|^${key}=.*|${key}=${value}|" "$NODE_CONFIG_FILE"
|
||||
else
|
||||
printf '%s=%s\n' "$key" "$value" >> "$NODE_CONFIG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
PORT="${VIRTUALITY_WEB_PORT:-$(saved_setting VIRTUALITY_WEB_PORT)}"
|
||||
PORT="${PORT:-8088}"
|
||||
AUTH_USER="${VIRTUALITY_AUTH_USER:-$(saved_setting VIRTUALITY_AUTH_USER)}"
|
||||
AUTH_USER="${AUTH_USER:-${SUDO_USER:-}}"
|
||||
AUTH_USER="${AUTH_USER:-$(first_human_user)}"
|
||||
AUTH_USER="${AUTH_USER:-root}"
|
||||
AUTO_UPDATE="${VIRTUALITY_AUTO_UPDATE:-$(saved_setting VIRTUALITY_AUTO_UPDATE)}"
|
||||
AUTO_UPDATE="${AUTO_UPDATE:-1}"
|
||||
CURRENT_STEP=0
|
||||
TOTAL_STEPS=9
|
||||
|
||||
ESC="\033"
|
||||
RESET="${ESC}[0m"
|
||||
@@ -33,36 +72,22 @@ CYAN="${ESC}[36m"
|
||||
BLUE="${ESC}[34m"
|
||||
GRAY="${ESC}[90m"
|
||||
|
||||
mkdir -p "$LOG_DIR" "$PROFILE_DIR" "$UPLOAD_TMP_DIR"
|
||||
mkdir -p "$LOG_DIR" "$CONFIG_DIR" "$UPLOAD_TMP_DIR"
|
||||
chmod 1777 "$UPLOAD_TMP_DIR"
|
||||
|
||||
timestamp() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||
line() { printf '%*s\n' 72 '' | tr ' ' '─'; }
|
||||
log() { echo "[$(timestamp)] $*" >> "$LOG_FILE"; }
|
||||
|
||||
print_header() {
|
||||
clear 2>/dev/null || true
|
||||
echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
|
||||
echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Web Panel Installer${RESET} ${CYAN}${BOLD}│${RESET}"
|
||||
echo -e "${CYAN}${BOLD}│${RESET} Auto profile: x86 / VPS / Raspberry / Orange Pi 5 ${CYAN}${BOLD}│${RESET}"
|
||||
echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||
echo
|
||||
echo -e "${GRAY}Лог установки:${RESET} ${LOG_FILE}"
|
||||
echo -e "${GRAY}Порт панели:${RESET} ${PORT}"
|
||||
echo -e "${GRAY}Linux auth user:${RESET} ${AUTH_USER}"
|
||||
echo
|
||||
}
|
||||
|
||||
step() {
|
||||
CURRENT_STEP=$((CURRENT_STEP + 1))
|
||||
echo
|
||||
echo -e "${BLUE}${BOLD}[${CURRENT_STEP}/${TOTAL_STEPS}]${RESET} ${BOLD}$*${RESET}"
|
||||
log "STEP ${CURRENT_STEP}/${TOTAL_STEPS}: $*"
|
||||
}
|
||||
|
||||
ok() { echo -e " ${GREEN}✓${RESET} $*"; log "OK: $*"; }
|
||||
warn() { echo -e " ${YELLOW}!${RESET} $*"; log "WARN: $*"; }
|
||||
fail() { echo -e " ${RED}✗${RESET} $*"; log "ERROR: $*"; echo; echo -e "${RED}${BOLD}Установка остановлена.${RESET} Подробности в логе: ${LOG_FILE}"; exit 1; }
|
||||
fail() { echo -e " ${RED}✗${RESET} $*"; log "ERROR: $*"; echo; echo -e "${RED}${BOLD}Установка остановлена.${RESET} Подробности в логе: ${LOG_FILE}"; tail -n 20 "$LOG_FILE" 2>/dev/null || true; exit 1; }
|
||||
|
||||
run_logged() {
|
||||
local description="$1"
|
||||
@@ -75,353 +100,149 @@ run_logged() {
|
||||
fi
|
||||
}
|
||||
|
||||
restore_canonical_templates() {
|
||||
local templates=("_sidebar.html" "dashboard.html" "vm_create.html" "vm_detail.html" "iso.html" "disk_images.html" "operations.html" "operation_detail.html" "host.html" "network.html" "logs.html" "update.html")
|
||||
local name=""
|
||||
for name in "${templates[@]}"; do
|
||||
if [[ -f "${WEB_DIR}/templates/${name}" ]]; then
|
||||
cp "${WEB_DIR}/templates/${name}" "${APP_DIR}/templates/${name}"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
service_state() { systemctl is-active "$1" 2>/dev/null || echo "inactive"; }
|
||||
require_root() { [[ "$EUID" -eq 0 ]] || fail "Запусти через sudo: sudo bash scripts/install_web_panel.sh"; }
|
||||
json_value() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], ""))' "$PROFILE_FILE" "$1" 2>/dev/null || true; }
|
||||
|
||||
print_header
|
||||
echo -e "${CYAN}${BOLD}Virtuality — установка панели управления${RESET}"
|
||||
echo -e "${GRAY}Лог: ${LOG_FILE}${RESET}"
|
||||
|
||||
step "Проверяем права и структуру проекта"
|
||||
require_root
|
||||
ok "Права root подтверждены"
|
||||
[[ -d "$WEB_DIR" ]] || fail "Не найдена директория web: $WEB_DIR"
|
||||
ok "Исходники web найдены: $WEB_DIR"
|
||||
[[ -f "$WEB_DIR/app.py" ]] || fail "Не найден web/app.py"
|
||||
ok "FastAPI entrypoint найден: web/app.py"
|
||||
[[ -f "$WEB_DIR/requirements.txt" ]] || fail "Не найден web/requirements.txt"
|
||||
ok "requirements.txt найден"
|
||||
step "Проверяем права и исходники"
|
||||
[[ "$EUID" -eq 0 ]] || fail "Запустите через sudo: sudo bash scripts/install_web_panel.sh"
|
||||
[[ -f "$WEB_DIR/app.py" && -f "$WEB_DIR/requirements.txt" ]] || fail "Не найдены исходники панели в ${WEB_DIR}"
|
||||
[[ -f "$CTL_SRC" ]] || fail "Не найден ${CTL_SRC}"
|
||||
for cmd in systemctl rsync python3 hostname openssl; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || fail "Не найдена команда ${cmd}"
|
||||
done
|
||||
ok "Исходники: ${WEB_DIR}"
|
||||
if id "$AUTH_USER" >/dev/null 2>&1; then
|
||||
ok "Пользователь для входа: ${AUTH_USER}"
|
||||
else
|
||||
fail "Пользователь ${AUTH_USER} не найден. Создайте его или передайте VIRTUALITY_AUTH_USER=имя"
|
||||
fi
|
||||
if getent shadow "$AUTH_USER" | cut -d: -f2 | grep -Eq '^(!|\*|!!)?$'; then
|
||||
warn "У пользователя ${AUTH_USER} нет пароля — задайте его: sudo passwd ${AUTH_USER}"
|
||||
fi
|
||||
|
||||
step "Автоматически определяем профиль сборки"
|
||||
step "Определяем профиль сервера"
|
||||
if [[ -x "${REPO_DIR}/scripts/detect_host_profile.sh" ]]; then
|
||||
PROFILE="$(${REPO_DIR}/scripts/detect_host_profile.sh 2>>"$LOG_FILE" | tail -n1)"
|
||||
PROFILE="$("${REPO_DIR}/scripts/detect_host_profile.sh" 2>>"$LOG_FILE" | tail -n1 || true)"
|
||||
else
|
||||
bash "${REPO_DIR}/scripts/detect_host_profile.sh" >>"$LOG_FILE" 2>&1 || true
|
||||
PROFILE="$(json_value profile)"
|
||||
fi
|
||||
[[ -n "$PROFILE" ]] || PROFILE="x86_64"
|
||||
ARCH="$(json_value arch)"
|
||||
MODEL="$(json_value model)"
|
||||
LABEL="$(json_value label)"
|
||||
PACKAGE_PROFILE="$(json_value package_profile)"
|
||||
ok "Профиль: ${LABEL:-$PROFILE}"
|
||||
ok "Архитектура: ${ARCH:-unknown}"
|
||||
ok "Модель: ${MODEL:-unknown}"
|
||||
ok "Конфиг профиля: $PROFILE_FILE"
|
||||
ok "Профиль: $(json_value label)${PROFILE:+ (${PROFILE})}"
|
||||
|
||||
step "Проверяем Linux-пользователя для входа"
|
||||
if id "$AUTH_USER" >/dev/null 2>&1; then
|
||||
ok "Пользователь найден: $AUTH_USER"
|
||||
step "Системные пакеты"
|
||||
if [[ "$PANEL_ONLY" == "1" ]]; then
|
||||
ok "Пропущено (VIRTUALITY_PANEL_ONLY=1): пакеты поставит установщик ноды"
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
run_logged "apt update выполнен" apt-get update
|
||||
run_logged "Пакеты панели установлены" apt-get install -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold python3 python3-venv rsync openssl curl nftables novnc python3-websockify qemu-utils virtinst libvirt-clients cloud-image-utils
|
||||
else
|
||||
fail "Пользователь $AUTH_USER не найден. Создай его или передай VIRTUALITY_AUTH_USER=username"
|
||||
fi
|
||||
if getent shadow "$AUTH_USER" | cut -d: -f2 | grep -Eq '^(!|\*|!!)?$'; then
|
||||
warn "У пользователя $AUTH_USER может быть заблокирован пароль. Установи пароль: sudo passwd $AUTH_USER"
|
||||
else
|
||||
ok "У пользователя $AUTH_USER есть пароль для Linux-auth"
|
||||
warn "apt не найден — пакеты не устанавливались"
|
||||
fi
|
||||
|
||||
step "Проверяем базовые команды"
|
||||
for cmd in apt systemctl rsync python3 hostname openssl; do
|
||||
if command -v "$cmd" >/dev/null 2>&1; then
|
||||
ok "$cmd найден: $(command -v "$cmd")"
|
||||
else
|
||||
fail "$cmd не найден"
|
||||
fi
|
||||
done
|
||||
step "Копируем панель в ${APP_DIR}"
|
||||
run_logged "Новая версия скопирована" rsync -a --delete --exclude='__pycache__/' --exclude='.env' "$WEB_DIR/" "${APP_DIR}.new/"
|
||||
install -m 0644 "${REPO_DIR}/VERSION" "${APP_DIR}.new/VERSION"
|
||||
FRESH_INSTALL=1
|
||||
if [[ -d "$APP_DIR" ]]; then
|
||||
FRESH_INSTALL=0
|
||||
rm -rf "${APP_DIR}.prev"
|
||||
mv "$APP_DIR" "${APP_DIR}.prev"
|
||||
ok "Предыдущая версия сохранена в ${APP_DIR}.prev (virtuality-ctl rollback)"
|
||||
fi
|
||||
mv "${APP_DIR}.new" "$APP_DIR"
|
||||
ok "Версия панели: $(cat "${APP_DIR}/VERSION")"
|
||||
|
||||
step "Обновляем apt cache"
|
||||
run_logged "apt update выполнен" apt update
|
||||
|
||||
step "Устанавливаем системные зависимости под выбранную сборку"
|
||||
COMMON_PACKAGES=(python3 python3-venv python3-pip 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)"
|
||||
step "Python-окружение"
|
||||
if [[ -x "$VENV_DIR/bin/python" ]] && "$VENV_DIR/bin/python" -c 'import sys' >/dev/null 2>&1; then
|
||||
ok "Virtualenv уже есть: ${VENV_DIR}"
|
||||
else
|
||||
[[ -d "$VENV_DIR" ]] && { warn "Virtualenv повреждён (например, после обновления Python) — пересоздаём"; rm -rf -- "${VENV_DIR:?}"; }
|
||||
if python3 -c 'import ensurepip' >/dev/null 2>&1; then
|
||||
run_logged "Virtualenv создан" python3 -m venv "$VENV_DIR"
|
||||
else
|
||||
fail "Не найден qemu-system-aarch64. Установи пакет: sudo apt install -y qemu-system-arm qemu-efi-aarch64"
|
||||
# Ubuntu Server without python3-venv: pip is bootstrapped from the bundled wheel.
|
||||
run_logged "Virtualenv создан (без pip)" python3 -m venv --without-pip "$VENV_DIR"
|
||||
fi
|
||||
fi
|
||||
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 "Копируем web-панель в /opt/virtuality"
|
||||
run_logged "Создана директория /opt/virtuality" mkdir -p /opt/virtuality
|
||||
run_logged "Файлы панели синхронизированы в $APP_DIR" rsync -a --delete "$WEB_DIR/" "$APP_DIR/"
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_web_console.py" ]]; then
|
||||
run_logged "noVNC web-console patch применён" python3 "${REPO_DIR}/scripts/patch_web_console.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_web_console.py не найден, noVNC console patch пропущен"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_upload_compat.py" ]]; then
|
||||
run_logged "upload compatibility patch применён" python3 "${REPO_DIR}/scripts/patch_upload_compat.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_upload_compat.py не найден, совместимость загрузки файлов пропущена"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_upload_navigation_guard.py" ]]; then
|
||||
run_logged "upload navigation guard patch применён" python3 "${REPO_DIR}/scripts/patch_upload_navigation_guard.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_upload_navigation_guard.py не найден, защита загрузок от переходов пропущена"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_disk_images.py" ]]; then
|
||||
run_logged "disk images patch применён" python3 "${REPO_DIR}/scripts/patch_disk_images.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_disk_images.py не найден, менеджер дисковых образов пропущен"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_vm_boot_order.py" ]]; then
|
||||
run_logged "VM boot order patch применён" python3 "${REPO_DIR}/scripts/patch_vm_boot_order.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_vm_boot_order.py не найден, порядок загрузки VM пропущен"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_boot_order.py" ]]; then
|
||||
run_logged "existing VM boot order patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_boot_order.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_existing_vm_boot_order.py не найден, порядок загрузки существующих VM пропущен"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_resources.py" ]]; then
|
||||
run_logged "existing VM resources patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_resources.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_existing_vm_resources.py не найден, ресурсы существующих VM пропущены"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_iso_mount.py" ]]; then
|
||||
run_logged "existing VM ISO mount patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_iso_mount.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_existing_vm_iso_mount.py не найден, монтирование ISO в VM пропущено"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_vm_detail_resource_layout.py" ]]; then
|
||||
run_logged "VM detail resource layout patch применён" python3 "${REPO_DIR}/scripts/patch_vm_detail_resource_layout.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_vm_detail_resource_layout.py не найден, раскладка ресурсов VM пропущена"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_remove_legacy_boot_order_card.py" ]]; then
|
||||
run_logged "legacy boot order cleanup patch применён" python3 "${REPO_DIR}/scripts/patch_remove_legacy_boot_order_card.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_remove_legacy_boot_order_card.py не найден, удаление старого блока порядка загрузки пропущено"
|
||||
fi
|
||||
run_logged "Канонические шаблоны панели восстановлены после VM-патчей" restore_canonical_templates
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_disk_archives.py" ]]; then
|
||||
run_logged "disk archive import patch применён" python3 "${REPO_DIR}/scripts/patch_disk_archives.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_disk_archives.py не найден, импорт архивов дисков пропущен"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_dhcp_leases_empty.py" ]]; then
|
||||
run_logged "DHCP leases empty-state patch применён" python3 "${REPO_DIR}/scripts/patch_dhcp_leases_empty.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_dhcp_leases_empty.py не найден, диагностика DHCP leases пропущена"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_network_diagnostics.py" ]]; then
|
||||
run_logged "network diagnostics patch применён" python3 "${REPO_DIR}/scripts/patch_network_diagnostics.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_network_diagnostics.py не найден, диагностика сети пропущена"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_network_ranges.py" ]]; then
|
||||
run_logged "network port ranges patch применён" python3 "${REPO_DIR}/scripts/patch_network_ranges.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_network_ranges.py не найден, поддержка диапазонов портов пропущена"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_network_bridge_forwards.py" ]]; then
|
||||
run_logged "network bridge forward patch применён" python3 "${REPO_DIR}/scripts/patch_network_bridge_forwards.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_network_bridge_forwards.py не найден, проброс bridge/static VM пропущен"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_network_nat_errors.py" ]]; then
|
||||
run_logged "network NAT error patch применён" python3 "${REPO_DIR}/scripts/patch_network_nat_errors.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_network_nat_errors.py не найден, безопасные ошибки NAT пропущены"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_logs_center.py" ]]; then
|
||||
run_logged "logs center patch применён" python3 "${REPO_DIR}/scripts/patch_logs_center.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_logs_center.py не найден, центр журналов пропущен"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_vm_network_guard.py" ]]; then
|
||||
run_logged "VM network guard patch применён" python3 "${REPO_DIR}/scripts/patch_vm_network_guard.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_vm_network_guard.py не найден, защита от отсутствующего bridge пропущена"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_update_center.py" ]]; then
|
||||
run_logged "update center patch применён" python3 "${REPO_DIR}/scripts/patch_update_center.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_update_center.py не найден, центр обновлений пропущен"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_live_status.py" ]]; then
|
||||
run_logged "live status patch применён" python3 "${REPO_DIR}/scripts/patch_live_status.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_live_status.py не найден, live-статусы пропущены"
|
||||
fi
|
||||
if [[ -f "${REPO_DIR}/scripts/patch_vm_autostart.py" ]]; then
|
||||
run_logged "VM autostart patch применён" python3 "${REPO_DIR}/scripts/patch_vm_autostart.py" "${APP_DIR}/app.py"
|
||||
else
|
||||
warn "patch_vm_autostart.py не найден, нормализация автозапуска VM пропущена"
|
||||
fi
|
||||
run_logged "Канонические шаблоны панели финально восстановлены" restore_canonical_templates
|
||||
run_logged "Конфиг профиля доступен web-панели" mkdir -p "$PROFILE_DIR"
|
||||
if [[ -f "$PROFILE_FILE" ]]; then
|
||||
ok "Профиль уже сохранён: $PROFILE_FILE"
|
||||
else
|
||||
warn "Профиль не найден после детекта, будет создан web-панелью при старте"
|
||||
fi
|
||||
|
||||
step "Создаём конфиг авторизации"
|
||||
step "Настройки ноды"
|
||||
touch "$NODE_CONFIG_FILE"
|
||||
grep -q '^#' "$NODE_CONFIG_FILE" || sed -i '1i # Настройки ноды Virtuality. Менять: sudo virtuality-ctl set KEY=VALUE' "$NODE_CONFIG_FILE"
|
||||
env_set VIRTUALITY_WEB_PORT "$PORT"
|
||||
env_set VIRTUALITY_AUTH_USER "$AUTH_USER"
|
||||
env_set VIRTUALITY_AUTO_UPDATE "$AUTO_UPDATE"
|
||||
env_set VIRTUALITY_SOURCE_DIR "$REPO_DIR"
|
||||
[[ -n "$(saved_setting VIRTUALITY_WEB_HOST)" ]] || env_set VIRTUALITY_WEB_HOST 0.0.0.0
|
||||
[[ -n "$(saved_setting VIRTUALITY_TLS)" ]] || env_set VIRTUALITY_TLS 0
|
||||
[[ -n "$(saved_setting VIRTUALITY_UPDATE_CHANNEL)" ]] || env_set VIRTUALITY_UPDATE_CHANNEL stable
|
||||
chmod 644 "$NODE_CONFIG_FILE"
|
||||
ok "Сохранено в ${NODE_CONFIG_FILE}"
|
||||
if [[ -s "$SESSION_SECRET_FILE" ]]; then
|
||||
SESSION_SECRET="$(cat "$SESSION_SECRET_FILE")"
|
||||
ok "Используем существующий session secret: $SESSION_SECRET_FILE"
|
||||
else
|
||||
SESSION_SECRET="$(openssl rand -hex 32)"
|
||||
printf '%s\n' "$SESSION_SECRET" > "$SESSION_SECRET_FILE"
|
||||
chmod 600 "$SESSION_SECRET_FILE"
|
||||
ok "Создан постоянный session secret: $SESSION_SECRET_FILE"
|
||||
ok "Создан ключ сессий: ${SESSION_SECRET_FILE}"
|
||||
fi
|
||||
cat > "${APP_DIR}/.env" <<EOF
|
||||
VIRTUALITY_AUTH_USER=${AUTH_USER}
|
||||
VIRTUALITY_SESSION_SECRET=${SESSION_SECRET}
|
||||
VIRTUALITY_COOKIE_SECURE=$(saved_setting VIRTUALITY_COOKIE_SECURE)
|
||||
TMPDIR=${UPLOAD_TMP_DIR}
|
||||
TEMP=${UPLOAD_TMP_DIR}
|
||||
TMP=${UPLOAD_TMP_DIR}
|
||||
EOF
|
||||
chmod 600 "${APP_DIR}/.env"
|
||||
ok "Создан ${APP_DIR}/.env"
|
||||
ok "Временный каталог загрузок: ${UPLOAD_TMP_DIR}"
|
||||
ok "Вход будет по Linux-пользователю: ${AUTH_USER}"
|
||||
|
||||
step "Создаём Python virtualenv"
|
||||
if [[ -d "$VENV_DIR" ]]; then
|
||||
warn "Virtualenv уже существует, будет переиспользован: $VENV_DIR"
|
||||
else
|
||||
run_logged "Virtualenv создан: $VENV_DIR" python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
step "Устанавливаем virtuality-ctl"
|
||||
install -m 0755 "$CTL_SRC" "$CTL_DST"
|
||||
ok "${CTL_DST}"
|
||||
|
||||
step "Устанавливаем Python-зависимости"
|
||||
run_logged "pip обновлён" "$VENV_DIR/bin/pip" install --upgrade pip
|
||||
run_logged "Python-зависимости установлены" "$VENV_DIR/bin/pip" install -r "$APP_DIR/requirements.txt"
|
||||
|
||||
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
|
||||
step "Службы, firewall и запуск"
|
||||
run_logged "Юниты созданы, панель запущена" env VIRTUALITY_SOURCE_DIR="$REPO_DIR" "$CTL_DST" reconfigure
|
||||
sleep 2
|
||||
STATE="$(service_state virtuality-web.service)"
|
||||
if [[ "$STATE" == "active" ]]; then
|
||||
ok "virtuality-web.service active"
|
||||
if systemctl is-active --quiet virtuality-web.service; then
|
||||
ok "virtuality-web.service работает"
|
||||
else
|
||||
warn "virtuality-web.service состояние: $STATE"
|
||||
journalctl -u virtuality-web.service -n 40 --no-pager >> "$LOG_FILE" 2>&1 || true
|
||||
fail "virtuality-web.service не запустился"
|
||||
fi
|
||||
|
||||
step "Настраиваем firewall и проверяем порт"
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
ufw allow "${PORT}/tcp" >> "$LOG_FILE" 2>&1 || warn "Не удалось добавить UFW правило для ${PORT}/tcp"
|
||||
ok "UFW правило добавлено/проверено: ${PORT}/tcp"
|
||||
step "Мастер настройки"
|
||||
if [[ -f "$SETUP_STATE_FILE" ]]; then
|
||||
ok "Состояние установки уже ведётся"
|
||||
elif [[ "${VIRTUALITY_FIRSTBOOT:-0}" == "1" ]]; then
|
||||
ok "Прогресс пишет первая загрузка"
|
||||
elif [[ "$FRESH_INSTALL" != "1" ]]; then
|
||||
ok "Обновление уже настроенного сервера — мастер не нужен (его можно запустить в Настройках)"
|
||||
else
|
||||
warn "ufw не установлен, firewall-правило пропущено"
|
||||
fi
|
||||
if command -v ss >/dev/null 2>&1 && ss -tulpn | grep -q ":${PORT}"; then
|
||||
ok "Порт ${PORT} слушается"
|
||||
else
|
||||
warn "Порт ${PORT} пока не найден в ss; проверь service logs"
|
||||
# A node installed by hand gets the same first-run wizard as one installed from the image.
|
||||
printf '{"stage": "done", "message": "", "steps": []}\n' > "$SETUP_STATE_FILE"
|
||||
ok "Мастер настройки откроется при первом входе в панель"
|
||||
fi
|
||||
|
||||
SERVER_IP="$(hostname -I | awk '{print $1}')"
|
||||
|
||||
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}"
|
||||
PANEL_URL="$("$CTL_DST" url 2>/dev/null || echo "http://$(hostname -I | awk '{print $1}'):${PORT}")"
|
||||
echo
|
||||
echo -e "${GREEN}${BOLD}Панель управления установлена${RESET}"
|
||||
line
|
||||
echo -e "${DIM}Следующий шаг: /host, /network, /logs, /update, /vm/create, /vm/NAME/console.${RESET}"
|
||||
echo -e "${BOLD}Адрес:${RESET} ${PANEL_URL}"
|
||||
echo -e "${BOLD}Логин:${RESET} ${AUTH_USER} (пароль пользователя Linux)"
|
||||
echo -e "${BOLD}Служба:${RESET} systemctl status virtuality-web --no-pager"
|
||||
echo -e "${BOLD}Журнал:${RESET} journalctl -u virtuality-web -f"
|
||||
echo -e "${BOLD}Настройки:${RESET} sudo virtuality-ctl status"
|
||||
line
|
||||
echo -e "${DIM}Автообновление: $([[ "$AUTO_UPDATE" == "1" ]] && echo "включено (ночью)" || echo "отключено")${RESET}"
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
#!/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("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
|
||||
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}')
|
||||
@@ -1,271 +0,0 @@
|
||||
#!/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)
|
||||
@@ -1,316 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,171 +0,0 @@
|
||||
#!/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)
|
||||
@@ -1,298 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,224 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,280 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,205 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,154 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,166 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,148 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/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')
|
||||
@@ -1,126 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,143 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,127 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,147 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,214 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,161 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,101 +0,0 @@
|
||||
#!/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}')
|
||||
@@ -1,169 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
IFACE="${1:-enp2s0}"
|
||||
BRIDGE="br0"
|
||||
NETPLAN_DIR="/etc/netplan"
|
||||
BACKUP_DIR="/root/virtuality-netplan-backups/$(date +%Y%m%d_%H%M%S)"
|
||||
BRIDGE_FILE="${NETPLAN_DIR}/60-virtuality-br0.yaml"
|
||||
ROLLBACK_SCRIPT="/root/virtuality-rollback-netplan.sh"
|
||||
MODE="${2:-dhcp}"
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
echo "Ошибка: запусти через sudo: sudo bash scripts/setup_bridge_br0.sh enp2s0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v netplan >/dev/null 2>&1; then
|
||||
echo "Ошибка: netplan не найден."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! ip link show "$IFACE" >/dev/null 2>&1; then
|
||||
echo "Ошибка: интерфейс $IFACE не найден."
|
||||
ip -br a
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT_IP="$(ip -4 addr show "$IFACE" | awk '/inet / {print $2; exit}')"
|
||||
CURRENT_GW="$(ip route | awk '/default/ {print $3; exit}')"
|
||||
|
||||
# Use stable IPv4 DNS by default. Link-local IPv6 DNS can break YAML/routes without scope id.
|
||||
DNS_1="${VIRTUALITY_DNS_1:-10.0.0.1}"
|
||||
DNS_2="${VIRTUALITY_DNS_2:-1.1.1.1}"
|
||||
|
||||
echo "============================================================"
|
||||
echo "Virtuality safe bridge setup"
|
||||
echo "============================================================"
|
||||
echo "Interface: $IFACE"
|
||||
echo "Bridge: $BRIDGE"
|
||||
echo "Mode: $MODE"
|
||||
echo "Current IP: ${CURRENT_IP:-unknown}"
|
||||
echo "Current gateway: ${CURRENT_GW:-unknown}"
|
||||
echo "DNS: ${DNS_1}, ${DNS_2}"
|
||||
echo "Backup: $BACKUP_DIR"
|
||||
echo
|
||||
|
||||
echo "ВАЖНО: скрипт использует netplan try."
|
||||
echo "Если сеть пропадёт, netplan должен автоматически откатить изменения."
|
||||
echo "Если видишь подтверждение — нажми Enter, только если SSH/сеть не отвалилась."
|
||||
echo
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
cp -a "$NETPLAN_DIR"/*.yaml "$BACKUP_DIR"/ 2>/dev/null || true
|
||||
|
||||
cat > "$ROLLBACK_SCRIPT" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
rm -f "$BRIDGE_FILE"
|
||||
cp -a "$BACKUP_DIR"/*.yaml "$NETPLAN_DIR"/
|
||||
chmod 600 "$NETPLAN_DIR"/*.yaml 2>/dev/null || true
|
||||
netplan generate
|
||||
netplan apply
|
||||
echo "Netplan rollback applied from $BACKUP_DIR"
|
||||
EOF
|
||||
chmod +x "$ROLLBACK_SCRIPT"
|
||||
|
||||
# Keep existing netplan files neutral for this interface to avoid duplicate DHCP/IP.
|
||||
for file in "$NETPLAN_DIR"/*.yaml; do
|
||||
[[ "$file" == "$BRIDGE_FILE" ]] && continue
|
||||
if grep -q "${IFACE}:" "$file"; then
|
||||
cp -a "$file" "${file}.virtuality-before-br0"
|
||||
cat > "$file" <<EOF
|
||||
network:
|
||||
version: 2
|
||||
ethernets:
|
||||
${IFACE}:
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
EOF
|
||||
chmod 600 "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$MODE" == "static" ]]; then
|
||||
if [[ -z "${CURRENT_IP:-}" || -z "${CURRENT_GW:-}" ]]; then
|
||||
echo "Ошибка: не удалось определить текущий IP/gateway для static mode."
|
||||
echo "Откат: sudo $ROLLBACK_SCRIPT"
|
||||
exit 1
|
||||
fi
|
||||
cat > "$BRIDGE_FILE" <<EOF
|
||||
network:
|
||||
version: 2
|
||||
ethernets:
|
||||
${IFACE}:
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
bridges:
|
||||
${BRIDGE}:
|
||||
interfaces:
|
||||
- ${IFACE}
|
||||
addresses:
|
||||
- ${CURRENT_IP}
|
||||
routes:
|
||||
- to: default
|
||||
via: ${CURRENT_GW}
|
||||
nameservers:
|
||||
addresses:
|
||||
- ${DNS_1}
|
||||
- ${DNS_2}
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
parameters:
|
||||
stp: false
|
||||
forward-delay: 0
|
||||
EOF
|
||||
else
|
||||
cat > "$BRIDGE_FILE" <<EOF
|
||||
network:
|
||||
version: 2
|
||||
ethernets:
|
||||
${IFACE}:
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
bridges:
|
||||
${BRIDGE}:
|
||||
interfaces:
|
||||
- ${IFACE}
|
||||
dhcp4: true
|
||||
dhcp6: false
|
||||
parameters:
|
||||
stp: false
|
||||
forward-delay: 0
|
||||
EOF
|
||||
fi
|
||||
|
||||
chmod 600 "$BRIDGE_FILE"
|
||||
chmod 600 "$NETPLAN_DIR"/*.yaml 2>/dev/null || true
|
||||
|
||||
echo "[1/5] Generated config:"
|
||||
cat "$BRIDGE_FILE"
|
||||
|
||||
echo
|
||||
echo "[2/5] Validating netplan..."
|
||||
netplan generate
|
||||
|
||||
echo
|
||||
echo "[3/5] Testing config with netplan try..."
|
||||
echo "Если после применения сеть работает — подтверди netplan try клавишей Enter."
|
||||
echo "Если SSH зависнет — жди автооткат или используй физический монитор: sudo $ROLLBACK_SCRIPT"
|
||||
netplan try --timeout 30
|
||||
|
||||
echo
|
||||
echo "[4/5] Applying final netplan..."
|
||||
netplan apply
|
||||
sleep 3
|
||||
|
||||
echo
|
||||
echo "[5/5] Result:"
|
||||
ip -br a
|
||||
ip route
|
||||
|
||||
echo
|
||||
echo "============================================================"
|
||||
echo "Bridge setup completed"
|
||||
echo "============================================================"
|
||||
echo "Rollback command: sudo $ROLLBACK_SCRIPT"
|
||||
echo "If DHCP mode failed, try static mode:"
|
||||
echo "sudo bash scripts/setup_bridge_br0.sh ${IFACE} static"
|
||||
echo "============================================================"
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sourced by auto_update_check.sh and apply_github_update.sh: which git ref a node updates to.
|
||||
# stable (default) — the latest release tag vX.Y.Z; main until the first release is tagged
|
||||
# main — every change on the main branch
|
||||
# Needs SOURCE_DIR, REMOTE and BRANCH; sets UPDATE_CHANNEL, TARGET_REF, TARGET_LABEL, TARGET_ZIP_URL.
|
||||
|
||||
# Where the project lives. Nodes installed before the move from GitHub are switched over by migrate_update_remote.
|
||||
DEFAULT_REPO_URL="https://git.stackworks.ru/viktor/virtuality.git"
|
||||
LEGACY_REPO_URLS=("https://github.com/viktor138irk/virtuality.git" "https://github.com/viktor138irk/virtuality")
|
||||
|
||||
update_channel() {
|
||||
local value="${VIRTUALITY_UPDATE_CHANNEL:-}"
|
||||
if [[ -z "$value" && -f /var/lib/virtuality/config/web.env ]]; then
|
||||
value="$(sed -n 's/^VIRTUALITY_UPDATE_CHANNEL=//p' /var/lib/virtuality/config/web.env | tail -n1 | tr -d "\"'")"
|
||||
fi
|
||||
[[ "$value" == "main" ]] && echo main || echo stable
|
||||
}
|
||||
|
||||
remote_url() {
|
||||
git -C "$SOURCE_DIR" remote get-url "$REMOTE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
migrate_update_remote() {
|
||||
[[ -d "${SOURCE_DIR}/.git" ]] || return 0
|
||||
local url legacy
|
||||
url="$(remote_url)"
|
||||
for legacy in "${LEGACY_REPO_URLS[@]}"; do
|
||||
if [[ "$url" == "$legacy" ]]; then
|
||||
git -C "$SOURCE_DIR" remote set-url "$REMOTE" "$DEFAULT_REPO_URL" && echo "update remote moved: $url -> $DEFAULT_REPO_URL"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Web address of the repository for ZIP downloads: the remote itself when it is https, otherwise the default.
|
||||
# Gitea and GitHub both serve <repo>/archive/<branch or tag>.zip.
|
||||
repo_web_url() {
|
||||
local url=""
|
||||
[[ -d "${SOURCE_DIR}/.git" ]] && url="$(remote_url)"
|
||||
[[ "$url" == https://* ]] || url="$DEFAULT_REPO_URL"
|
||||
echo "${url%.git}"
|
||||
}
|
||||
|
||||
resolve_update_target() {
|
||||
local tag="" web
|
||||
web="$(repo_web_url)"
|
||||
UPDATE_CHANNEL="$(update_channel)"
|
||||
TARGET_REF="${REMOTE}/${BRANCH}"
|
||||
TARGET_LABEL="${BRANCH}"
|
||||
TARGET_ZIP_URL="${web}/archive/${BRANCH}.zip"
|
||||
if [[ "$UPDATE_CHANNEL" == "stable" && -d "${SOURCE_DIR}/.git" ]]; then
|
||||
tag="$(git -C "$SOURCE_DIR" tag --list 'v[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+(\.[0-9]+)*$' | head -n1 || true)"
|
||||
if [[ -n "$tag" ]]; then
|
||||
TARGET_REF="refs/tags/${tag}"
|
||||
TARGET_LABEL="$tag"
|
||||
TARGET_ZIP_URL="${web}/archive/${tag}.zip"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
#!/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
|
||||
|
||||
# Scheduled VM backups run in their own unit: a panel restart (nightly update) must not cut a copy short.
|
||||
cat > "${UNIT_DIR}/virtuality-backup.service" <<EOF
|
||||
[Unit]
|
||||
Description=Virtuality scheduled VM backups
|
||||
After=libvirtd.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
TimeoutStartSec=infinity
|
||||
User=root
|
||||
WorkingDirectory=${APP_DIR}
|
||||
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/features/backup_schedule.py
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=LC_ALL=C.UTF-8
|
||||
Environment=VIRTUALITY_AUTH_USER=${auth_user}
|
||||
Environment=VIRTUALITY_SOURCE_DIR=${SOURCE_DIR}
|
||||
Environment=VIRTUALITY_NODE_ENV=${NODE_ENV}
|
||||
Environment=TMPDIR=${DATA_DIR}/tmp
|
||||
EOF
|
||||
cat > "${UNIT_DIR}/virtuality-backup.timer" <<EOF
|
||||
[Unit]
|
||||
Description=Virtuality scheduled VM backups (checks the schedule every 15 minutes)
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*:0/15
|
||||
AccuracySec=1min
|
||||
Unit=virtuality-backup.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
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 virtuality-backup.timer >/dev/null 2>&1 || true
|
||||
systemctl enable libvirt-guests.service >/dev/null 2>&1 || true
|
||||
if [[ "$tls" == "1" ]]; then
|
||||
systemctl enable virtuality-redirect.service >/dev/null 2>&1 || true
|
||||
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 virtuality-backup.timer >/dev/null 2>&1 || true
|
||||
if [[ "$(env_get VIRTUALITY_AUTO_UPDATE 1)" == "1" ]]; then
|
||||
systemctl enable --now virtuality-auto-update.timer >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
# 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-backup.timer virtuality-backup.service virtuality-console-dashboard.service virtuality-firstboot.service; do
|
||||
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||
rm -f "${UNIT_DIR}/${unit}"
|
||||
done
|
||||
systemctl enable --now getty@tty1.service >/dev/null 2>&1 || true
|
||||
systemctl daemon-reload
|
||||
nft delete table ip virtuality >/dev/null 2>&1 || true
|
||||
if command -v ufw >/dev/null 2>&1 && [[ -f "$STATE_FILE" ]]; then
|
||||
while read -r old; do [[ -n "$old" ]] && ufw delete allow "${old}/tcp" >/dev/null 2>&1 || true; done < "$STATE_FILE"
|
||||
fi
|
||||
rm -rf /opt/virtuality /etc/virtuality /etc/logrotate.d/virtuality /etc/sysctl.d/98-virtuality-rpfilter.conf /etc/sysctl.d/99-virtuality-forward.conf
|
||||
rm -f /usr/local/bin/vhealth /usr/local/bin/virtuality-console-dashboard /etc/issue.d/virtuality.issue
|
||||
if [[ "$purge" -eq 1 ]]; then
|
||||
if command -v virsh >/dev/null 2>&1; then
|
||||
local name
|
||||
while read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
virsh destroy "$name" >/dev/null 2>&1 || true
|
||||
virsh undefine "$name" --remove-all-storage --nvram --managed-save --snapshots-metadata >/dev/null 2>&1 || true
|
||||
done < <(virsh list --all --name 2>/dev/null)
|
||||
virsh net-destroy virtuality-nat >/dev/null 2>&1 || true
|
||||
virsh net-undefine virtuality-nat >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "$DATA_DIR" "$LOG_DIR"
|
||||
else
|
||||
rm -rf "$CONFIG_DIR" "${DATA_DIR}/update" "${DATA_DIR}/tmp"
|
||||
echo "Машины, образы и резервные копии сохранены в ${DATA_DIR}"
|
||||
fi
|
||||
rm -f /usr/local/bin/virtuality-ctl
|
||||
echo "Virtuality удалена"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
status) cmd_status ;;
|
||||
url) panel_url; echo ;;
|
||||
reconfigure) cmd_reconfigure ;;
|
||||
set) shift; cmd_set "$@" ;;
|
||||
passwd) shift; cmd_passwd "$@" ;;
|
||||
timezone) shift; cmd_timezone "$@" ;;
|
||||
power) shift; cmd_power "$@" ;;
|
||||
backup-config) shift; cmd_backup_config "$@" ;;
|
||||
restore-config) shift; cmd_restore_config "$@" ;;
|
||||
rollback) cmd_rollback ;;
|
||||
maintenance) cmd_maintenance ;;
|
||||
bridge) shift; cmd_bridge "$@" ;;
|
||||
bridge-confirm) cmd_bridge_confirm ;;
|
||||
bridge-revert) cmd_bridge_revert ;;
|
||||
storage-use) shift; cmd_storage_use "$@" ;;
|
||||
support-bundle) shift; cmd_support_bundle "$@" ;;
|
||||
uninstall) shift; cmd_uninstall "$@" ;;
|
||||
-h|--help|help|"") usage ;;
|
||||
*) usage >&2; die "неизвестная команда: $1" ;;
|
||||
esac
|
||||
@@ -1,224 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Virtuality Console Dashboard
|
||||
# btop-like compact TUI for physical server console tty1
|
||||
|
||||
refresh_interval="${VIRTUALITY_DASHBOARD_INTERVAL:-2}"
|
||||
|
||||
has_cmd() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
ESC="\033"
|
||||
RESET="${ESC}[0m"
|
||||
BOLD="${ESC}[1m"
|
||||
DIM="${ESC}[2m"
|
||||
FG_CYAN="${ESC}[38;5;51m"
|
||||
FG_BLUE="${ESC}[38;5;39m"
|
||||
FG_GREEN="${ESC}[38;5;46m"
|
||||
FG_YELLOW="${ESC}[38;5;226m"
|
||||
FG_RED="${ESC}[38;5;196m"
|
||||
FG_MAGENTA="${ESC}[38;5;201m"
|
||||
FG_GRAY="${ESC}[38;5;245m"
|
||||
FG_WHITE="${ESC}[38;5;255m"
|
||||
BG_PANEL="${ESC}[48;5;235m"
|
||||
BG_HEADER="${ESC}[48;5;24m"
|
||||
|
||||
move() { printf "${ESC}[%s;%sH" "$1" "$2"; }
|
||||
clear_screen() { printf "${ESC}[2J${ESC}[H"; }
|
||||
hide_cursor() { printf "${ESC}[?25l"; }
|
||||
show_cursor() { printf "${ESC}[?25h${RESET}"; }
|
||||
trap show_cursor EXIT
|
||||
|
||||
term_cols() { tput cols 2>/dev/null || echo 120; }
|
||||
term_rows() { tput lines 2>/dev/null || echo 40; }
|
||||
|
||||
repeat_char() {
|
||||
local char="$1" count="$2"
|
||||
printf '%*s' "$count" '' | tr ' ' "$char"
|
||||
}
|
||||
|
||||
strip_ansi() {
|
||||
sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g'
|
||||
}
|
||||
|
||||
print_fit() {
|
||||
local text="$1" width="$2"
|
||||
printf "%-${width}.${width}s" "$text"
|
||||
}
|
||||
|
||||
box() {
|
||||
local row="$1" col="$2" height="$3" width="$4" title="$5"
|
||||
local inner=$((width - 2))
|
||||
move "$row" "$col"; printf "${FG_BLUE}╭%s╮${RESET}" "$(repeat_char '─' "$inner")"
|
||||
move "$row" $((col + 2)); printf "${BG_PANEL}${FG_CYAN}${BOLD} %s ${RESET}" "$title"
|
||||
for ((i=1; i<height-1; i++)); do
|
||||
move $((row+i)) "$col"; printf "${FG_BLUE}│${RESET}"
|
||||
printf "${BG_PANEL}%s${RESET}" "$(repeat_char ' ' "$inner")"
|
||||
printf "${FG_BLUE}│${RESET}"
|
||||
done
|
||||
move $((row+height-1)) "$col"; printf "${FG_BLUE}╰%s╯${RESET}" "$(repeat_char '─' "$inner")"
|
||||
}
|
||||
|
||||
put() {
|
||||
local row="$1" col="$2" width="$3" text="$4"
|
||||
move "$row" "$col"
|
||||
printf "${BG_PANEL}"
|
||||
printf "%b" "$text" | cut -c 1-"$width"
|
||||
printf "${RESET}"
|
||||
}
|
||||
|
||||
bar() {
|
||||
local percent="$1" width="$2"
|
||||
local filled=$(( percent * width / 100 ))
|
||||
local empty=$(( width - filled ))
|
||||
local color="$FG_GREEN"
|
||||
if (( percent >= 85 )); then color="$FG_RED"; elif (( percent >= 65 )); then color="$FG_YELLOW"; fi
|
||||
printf "%b%s%b%s" "$color" "$(repeat_char '█' "$filled")" "$DIM" "$(repeat_char '░' "$empty")"
|
||||
printf "%b" "$RESET"
|
||||
}
|
||||
|
||||
service_state() {
|
||||
local unit="$1"
|
||||
if systemctl list-unit-files "$unit" >/dev/null 2>&1; then
|
||||
systemctl is-active "$unit" 2>/dev/null || echo "inactive"
|
||||
else
|
||||
echo "missing"
|
||||
fi
|
||||
}
|
||||
|
||||
state_badge() {
|
||||
local state="$1"
|
||||
case "$state" in
|
||||
active) printf "${FG_GREEN}● active${RESET}" ;;
|
||||
inactive) printf "${FG_YELLOW}● inactive${RESET}" ;;
|
||||
failed) printf "${FG_RED}● failed${RESET}" ;;
|
||||
missing) printf "${FG_GRAY}● missing${RESET}" ;;
|
||||
*) printf "${FG_YELLOW}● %s${RESET}" "$state" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
get_cpu_usage() {
|
||||
local a b idle_a total_a idle_b total_b diff_idle diff_total usage
|
||||
read -r _ a b c idle_a rest < /proc/stat
|
||||
total_a=$((a+b+c+idle_a))
|
||||
sleep 0.15
|
||||
read -r _ a b c idle_b rest < /proc/stat
|
||||
total_b=$((a+b+c+idle_b))
|
||||
diff_idle=$((idle_b-idle_a))
|
||||
diff_total=$((total_b-total_a))
|
||||
if (( diff_total <= 0 )); then echo 0; else echo $(( (100 * (diff_total - diff_idle)) / diff_total )); fi
|
||||
}
|
||||
|
||||
get_mem_percent() {
|
||||
free | awk '/Mem:/ {printf "%d", ($3/$2)*100}'
|
||||
}
|
||||
|
||||
get_disk_percent() {
|
||||
df / | awk 'NR==2 {gsub("%", "", $5); print $5}'
|
||||
}
|
||||
|
||||
while true; do
|
||||
cols="$(term_cols)"
|
||||
rows="$(term_rows)"
|
||||
[[ "$cols" -lt 100 ]] && cols=100
|
||||
[[ "$rows" -lt 32 ]] && rows=32
|
||||
|
||||
cpu="$(get_cpu_usage)"
|
||||
mem="$(get_mem_percent)"
|
||||
disk="$(get_disk_percent)"
|
||||
load="$(awk '{print $1" "$2" "$3}' /proc/loadavg)"
|
||||
uptime_text="$(uptime -p 2>/dev/null | sed 's/up //' || true)"
|
||||
host="$(hostname)"
|
||||
now="$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
ip_main="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||
gateway="$(ip route | awk '/default/ {print $3" via "$5; exit}')"
|
||||
|
||||
clear_screen
|
||||
hide_cursor
|
||||
|
||||
move 1 1
|
||||
printf "${BG_HEADER}${FG_WHITE}${BOLD} %-20s ${FG_CYAN}%-30s ${FG_GRAY}%s ${RESET}" "VIRTUALITY" "$host" "$now"
|
||||
printf "%s" "$(repeat_char ' ' $((cols-70 > 0 ? cols-70 : 1)))"
|
||||
|
||||
# Layout
|
||||
left_w=48
|
||||
right_w=$((cols - left_w - 5))
|
||||
[[ "$right_w" -lt 48 ]] && right_w=48
|
||||
|
||||
box 3 2 10 "$left_w" "SYSTEM"
|
||||
box 3 $((left_w + 4)) 10 "$right_w" "SERVICES"
|
||||
box 14 2 8 "$left_w" "NETWORK"
|
||||
box 14 $((left_w + 4)) 8 "$right_w" "VIRTUAL MACHINES"
|
||||
box 23 2 8 "$left_w" "STORAGE"
|
||||
box 23 $((left_w + 4)) 8 "$right_w" "LIBVIRT POOLS"
|
||||
|
||||
# System panel
|
||||
put 5 4 $((left_w-4)) "${FG_GRAY}CPU ${FG_WHITE}${cpu}% $(bar "$cpu" 26)"
|
||||
put 6 4 $((left_w-4)) "${FG_GRAY}RAM ${FG_WHITE}${mem}% $(bar "$mem" 26)"
|
||||
put 7 4 $((left_w-4)) "${FG_GRAY}DSK ${FG_WHITE}${disk}% $(bar "$disk" 26)"
|
||||
put 8 4 $((left_w-4)) "${FG_GRAY}Load:${RESET} ${FG_WHITE}${load}${RESET}"
|
||||
put 9 4 $((left_w-4)) "${FG_GRAY}Uptime:${RESET} ${FG_WHITE}${uptime_text}${RESET}"
|
||||
put 10 4 $((left_w-4)) "${FG_GRAY}Kernel:${RESET} ${FG_WHITE}$(uname -r)${RESET}"
|
||||
|
||||
# Services panel
|
||||
svc_col=$((left_w + 6))
|
||||
svc_w=$((right_w - 4))
|
||||
put 5 "$svc_col" "$svc_w" "libvirtd.service $(state_badge "$(service_state libvirtd.service)")"
|
||||
put 6 "$svc_col" "$svc_w" "virtlogd.service $(state_badge "$(service_state virtlogd.service)")"
|
||||
put 7 "$svc_col" "$svc_w" "cockpit.socket $(state_badge "$(service_state cockpit.socket)")"
|
||||
put 8 "$svc_col" "$svc_w" "libvirt-guests.service $(state_badge "$(service_state libvirt-guests.service)")"
|
||||
put 10 "$svc_col" "$svc_w" "${FG_GRAY}Cockpit:${RESET} ${FG_WHITE}https://${ip_main}:9090${RESET}"
|
||||
|
||||
# Network panel
|
||||
put 16 4 $((left_w-4)) "${FG_GRAY}Main IP:${RESET} ${FG_GREEN}${ip_main}${RESET}"
|
||||
put 17 4 $((left_w-4)) "${FG_GRAY}Gateway:${RESET} ${FG_WHITE}${gateway}${RESET}"
|
||||
net_lines="$(ip -br a 2>/dev/null | head -n 3)"
|
||||
n=0
|
||||
while IFS= read -r line; do
|
||||
put $((18+n)) 4 $((left_w-4)) "${FG_WHITE}${line}${RESET}"
|
||||
n=$((n+1))
|
||||
done <<< "$net_lines"
|
||||
|
||||
# VM panel
|
||||
vm_col=$((left_w + 6))
|
||||
vm_lines="$(virsh list --all 2>/dev/null | tail -n +3 | sed '/^$/d' | head -n 4 || true)"
|
||||
if [[ -z "$vm_lines" ]]; then
|
||||
put 16 "$vm_col" "$svc_w" "${FG_GRAY}VM пока нет. Следующий этап — создать тестовую VM.${RESET}"
|
||||
else
|
||||
n=0
|
||||
while IFS= read -r line; do
|
||||
put $((16+n)) "$vm_col" "$svc_w" "${FG_WHITE}${line}${RESET}"
|
||||
n=$((n+1))
|
||||
done <<< "$vm_lines"
|
||||
fi
|
||||
|
||||
# Storage panel
|
||||
srow=25
|
||||
for dir in /var/lib/virtuality/iso /var/lib/virtuality/images /var/lib/virtuality/backups /var/log/virtuality; do
|
||||
if [[ -d "$dir" ]]; then
|
||||
used="$(du -sh "$dir" 2>/dev/null | awk '{print $1}')"
|
||||
put "$srow" 4 $((left_w-4)) "${FG_GRAY}${dir}:${RESET} ${FG_WHITE}${used}${RESET}"
|
||||
else
|
||||
put "$srow" 4 $((left_w-4)) "${FG_GRAY}${dir}:${RESET} ${FG_RED}missing${RESET}"
|
||||
fi
|
||||
srow=$((srow+1))
|
||||
done
|
||||
|
||||
# Pools panel
|
||||
pool_lines="$(virsh pool-list --all 2>/dev/null | tail -n +3 | sed '/^$/d' | head -n 4 || true)"
|
||||
if [[ -z "$pool_lines" ]]; then
|
||||
put 25 "$vm_col" "$svc_w" "${FG_GRAY}Storage pools unavailable${RESET}"
|
||||
else
|
||||
n=0
|
||||
while IFS= read -r line; do
|
||||
put $((25+n)) "$vm_col" "$svc_w" "${FG_WHITE}${line}${RESET}"
|
||||
n=$((n+1))
|
||||
done <<< "$pool_lines"
|
||||
fi
|
||||
|
||||
move $((rows-2)) 2
|
||||
printf "${FG_GRAY}Refresh:${RESET} ${FG_WHITE}${refresh_interval}s${RESET} ${FG_GRAY}|${RESET} ${FG_CYAN}Ctrl+Alt+F2${RESET} login ${FG_GRAY}|${RESET} ${FG_GREEN}bt${RESET}=btop ${FG_GRAY}|${RESET} stop: ${FG_YELLOW}sudo systemctl stop virtuality-console-dashboard${RESET}"
|
||||
move $((rows-1)) 1
|
||||
printf "${RESET}"
|
||||
sleep "$refresh_interval"
|
||||
done
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ==========================================================
|
||||
# Virtuality first boot
|
||||
# Runs once on a system installed from the Virtuality image.
|
||||
# The web panel is installed first (offline, from bundled
|
||||
# wheels) so the setup wizard in the browser can show the
|
||||
# progress of everything that follows: network, KVM/libvirt,
|
||||
# tools. Progress is written to config/setup.json, which the
|
||||
# wizard polls. Every step is idempotent: on failure systemd
|
||||
# restarts the script after a minute and it continues from
|
||||
# the first unfinished step.
|
||||
# ==========================================================
|
||||
|
||||
SOURCE_DIR="${VIRTUALITY_SOURCE_DIR:-/opt/virtuality/source}"
|
||||
IMAGE_ENV="${VIRTUALITY_IMAGE_ENV:-/etc/virtuality/image.env}"
|
||||
STATE_DIR="${VIRTUALITY_STATE_DIR:-/var/lib/virtuality}"
|
||||
CONFIG_DIR="${STATE_DIR}/config"
|
||||
STATE_FILE="${CONFIG_DIR}/setup.json"
|
||||
DONE_FLAG="${STATE_DIR}/.firstboot-done"
|
||||
STEP_FLAG_DIR="${STATE_DIR}/.firstboot-steps"
|
||||
LOG_DIR="${VIRTUALITY_LOG_DIR:-/var/log/virtuality}"
|
||||
LOG_FILE="${LOG_DIR}/firstboot.log"
|
||||
APT_LOCK_CONF="${VIRTUALITY_APT_LOCK_CONF:-/etc/apt/apt.conf.d/90virtuality-lock-timeout}"
|
||||
ISSUE_FILE="${VIRTUALITY_ISSUE_FILE:-/etc/issue.d/virtuality.issue}"
|
||||
CTL="${VIRTUALITY_CTL:-/usr/local/bin/virtuality-ctl}"
|
||||
NETWORK_WAIT_SECONDS="${VIRTUALITY_NETWORK_WAIT_SECONDS:-0}" # 0 = wait forever
|
||||
|
||||
STEP_IDS=(panel network virtualization tools finish)
|
||||
declare -A STEP_TITLE=(
|
||||
[panel]="Панель управления"
|
||||
[network]="Подключение к интернету"
|
||||
[virtualization]="KVM, QEMU и libvirt"
|
||||
[tools]="Инструменты диагностики"
|
||||
[finish]="Завершение"
|
||||
)
|
||||
declare -A STEP_STATUS=()
|
||||
CURRENT_STEP=""
|
||||
WATCHER_PID=""
|
||||
|
||||
mkdir -p "$LOG_DIR" "$STATE_DIR" "$CONFIG_DIR" "$STEP_FLAG_DIR" "$(dirname "$ISSUE_FILE")"
|
||||
exec > >(tee -a "$LOG_FILE") 2>&1
|
||||
|
||||
now() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||
say() { echo "[$(now)] [virtuality-firstboot] $*"; }
|
||||
|
||||
# ---------------------------------------------------------------- progress for the wizard
|
||||
json_escape() {
|
||||
local text="$1"
|
||||
text="${text//\\/\\\\}"
|
||||
text="${text//\"/\\\"}"
|
||||
text="${text//$'\n'/ }"
|
||||
printf '%s' "$text"
|
||||
}
|
||||
|
||||
write_state() {
|
||||
local stage="$1" message="$2" id first=1
|
||||
{
|
||||
printf '{"stage": "%s", "message": "%s", "updated_at": "%s", "steps": [' "$stage" "$(json_escape "$message")" "$(now)"
|
||||
for id in "${STEP_IDS[@]}"; do
|
||||
(( first )) || printf ', '
|
||||
first=0
|
||||
printf '{"id": "%s", "title": "%s", "status": "%s"}' "$id" "${STEP_TITLE[$id]}" "${STEP_STATUS[$id]:-pending}"
|
||||
done
|
||||
printf ']}\n'
|
||||
} > "${STATE_FILE}.tmp"
|
||||
mv -f "${STATE_FILE}.tmp" "$STATE_FILE"
|
||||
chmod 644 "$STATE_FILE"
|
||||
}
|
||||
|
||||
step_done_before() { [[ -f "${STEP_FLAG_DIR}/$1" ]]; }
|
||||
|
||||
step_start() {
|
||||
CURRENT_STEP="$1"
|
||||
STEP_STATUS[$1]="running"
|
||||
say "step ${1}: ${STEP_TITLE[$1]}"
|
||||
write_state installing "$2"
|
||||
}
|
||||
|
||||
step_finish() {
|
||||
STEP_STATUS[$1]="done"
|
||||
touch "${STEP_FLAG_DIR}/$1"
|
||||
CURRENT_STEP=""
|
||||
write_state installing "${2:-}"
|
||||
}
|
||||
|
||||
# apt writes its progress to the installer logs; turn the last line into a friendly message.
|
||||
watch_progress() {
|
||||
local latest line pkg
|
||||
while true; do
|
||||
latest="$(ls -t "${LOG_DIR}"/install_*.log 2>/dev/null | head -n1 || true)"
|
||||
if [[ -n "$latest" ]]; then
|
||||
line="$(grep -aE '^(Get:[0-9]+ |Unpacking |Setting up |Preparing to unpack )' "$latest" 2>/dev/null | tail -n1 || true)"
|
||||
pkg=""
|
||||
case "$line" in
|
||||
Get:*) pkg="$(printf '%s' "$line" | awk '{print $5}')" ; [[ -n "$pkg" ]] && write_state installing "Скачиваем ${pkg}…" ;;
|
||||
"Unpacking "*|"Preparing to unpack "*) pkg="$(printf '%s' "$line" | sed -E 's/^(Preparing to unpack \.\.\.\/|Preparing to unpack |Unpacking )//; s/[ :].*//')"; [[ -n "$pkg" ]] && write_state installing "Распаковываем ${pkg}…" ;;
|
||||
"Setting up "*) pkg="$(printf '%s' "$line" | sed -E 's/^Setting up //; s/[ :].*//')"; [[ -n "$pkg" ]] && write_state installing "Настраиваем ${pkg}…" ;;
|
||||
esac
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
}
|
||||
|
||||
watch_start() { watch_progress & WATCHER_PID=$!; }
|
||||
watch_stop() { [[ -n "$WATCHER_PID" ]] && { kill "$WATCHER_PID" 2>/dev/null || true; wait "$WATCHER_PID" 2>/dev/null || true; }; WATCHER_PID=""; }
|
||||
|
||||
on_error() {
|
||||
local code=$?
|
||||
watch_stop
|
||||
if [[ -n "$CURRENT_STEP" ]]; then
|
||||
STEP_STATUS[$CURRENT_STEP]="error"
|
||||
fi
|
||||
write_state error "Шаг «${STEP_TITLE[${CURRENT_STEP:-finish}]}» завершился с ошибкой (код ${code}). Повторим автоматически через минуту; подробности ниже в журнале."
|
||||
say "ERROR: step ${CURRENT_STEP:-?} failed with code ${code}"
|
||||
exit "$code"
|
||||
}
|
||||
trap on_error ERR
|
||||
|
||||
# ---------------------------------------------------------------- environment
|
||||
if [[ -f "$DONE_FLAG" ]]; then
|
||||
say "already completed, nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -f "$IMAGE_ENV" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$IMAGE_ENV"
|
||||
set +a
|
||||
fi
|
||||
|
||||
first_human_user() {
|
||||
getent passwd | awk -F: '$3 >= 1000 && $3 < 60000 && $7 !~ /(nologin|false)$/ {print $1; exit}'
|
||||
}
|
||||
|
||||
AUTH_USER="${VIRTUALITY_USER:-$(first_human_user)}"
|
||||
AUTH_USER="${AUTH_USER:-root}"
|
||||
export VIRTUALITY_USER="$AUTH_USER"
|
||||
export VIRTUALITY_AUTH_USER="$AUTH_USER"
|
||||
export VIRTUALITY_WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}"
|
||||
export VIRTUALITY_AUTO_UPDATE="${VIRTUALITY_AUTO_UPDATE:-1}"
|
||||
# Disk/RAM limits are reported in the panel; they must not block an image install.
|
||||
export VIRTUALITY_SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-1}"
|
||||
export VIRTUALITY_FIRSTBOOT=1
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export TERM="${TERM:-dumb}"
|
||||
|
||||
for id in "${STEP_IDS[@]}"; do
|
||||
step_done_before "$id" && STEP_STATUS[$id]="done"
|
||||
done
|
||||
|
||||
say "source: ${SOURCE_DIR}, panel user: ${AUTH_USER}, port: ${VIRTUALITY_WEB_PORT}"
|
||||
[[ -f "${SOURCE_DIR}/install_virtuality_node.sh" ]] || { write_state error "Исходники Virtuality не найдены в ${SOURCE_DIR}"; say "ERROR: source not found"; exit 1; }
|
||||
cd "$SOURCE_DIR"
|
||||
|
||||
# unattended-upgrades often holds the dpkg lock right after the first boot.
|
||||
printf 'DPkg::Lock::Timeout "900";\nDPkg::Options { "--force-confdef"; "--force-confold"; };\n' > "$APT_LOCK_CONF"
|
||||
|
||||
cat > "$ISSUE_FILE" <<'EOF'
|
||||
Virtuality настраивается. Ход установки: sudo journalctl -fu virtuality-firstboot
|
||||
|
||||
EOF
|
||||
|
||||
network_ready() {
|
||||
[[ -n "${VIRTUALITY_NETWORK_CHECK:-}" ]] && { "$VIRTUALITY_NETWORK_CHECK"; return; }
|
||||
ip route show default 2>/dev/null | grep -q . && getent hosts archive.ubuntu.com >/dev/null 2>&1
|
||||
}
|
||||
|
||||
wait_for_network() {
|
||||
local started elapsed
|
||||
started="$(date +%s)"
|
||||
until network_ready; do
|
||||
elapsed=$(( $(date +%s) - started ))
|
||||
if (( NETWORK_WAIT_SECONDS > 0 && elapsed >= NETWORK_WAIT_SECONDS )); then
|
||||
say "ERROR: no network with DNS after ${elapsed}s"
|
||||
return 1
|
||||
fi
|
||||
if (( elapsed >= 20 )); then
|
||||
write_state waiting-network "Нет доступа в интернет. Подключите кабель или настройте сеть — установка продолжится сама."
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
write_state installing "Интернет доступен"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- 1. panel (offline)
|
||||
if ! step_done_before panel; then
|
||||
step_start panel "Устанавливаем панель управления…"
|
||||
if ! VIRTUALITY_PANEL_ONLY=1 bash ./scripts/install_web_panel.sh; then
|
||||
# No bundled wheels (image built with --no-wheels): install online instead.
|
||||
say "offline panel install failed, retrying with network"
|
||||
wait_for_network
|
||||
bash ./scripts/install_web_panel.sh
|
||||
fi
|
||||
step_finish panel "Панель управления запущена"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 2. network
|
||||
if ! step_done_before network; then
|
||||
step_start network "Проверяем подключение к интернету…"
|
||||
wait_for_network
|
||||
step_finish network "Интернет доступен"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 3. virtualization
|
||||
if ! step_done_before virtualization; then
|
||||
step_start virtualization "Устанавливаем пакеты виртуализации (это самый долгий шаг)…"
|
||||
watch_start
|
||||
bash ./install_virtuality_node.sh
|
||||
watch_stop
|
||||
step_finish virtualization "Виртуализация установлена"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 4. tools
|
||||
if ! step_done_before tools; then
|
||||
step_start tools "Устанавливаем инструменты диагностики…"
|
||||
bash ./scripts/install_healthcheck_command.sh
|
||||
step_finish tools "Инструменты установлены"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 5. finish
|
||||
step_start finish "Завершаем настройку…"
|
||||
# The panel was installed before libvirt: refresh units/firewall and restart it with everything in place.
|
||||
"$CTL" reconfigure
|
||||
PANEL_URL="$("$CTL" url 2>/dev/null || echo "http://$(hostname -I | awk '{print $1}'):${VIRTUALITY_WEB_PORT}")"
|
||||
cat > "$ISSUE_FILE" <<EOF
|
||||
Virtuality: откройте панель управления в браузере — ${PANEL_URL}
|
||||
Логин: ${AUTH_USER} (пароль как у пользователя Linux)
|
||||
|
||||
EOF
|
||||
touch "$DONE_FLAG"
|
||||
STEP_STATUS[finish]="done"
|
||||
CURRENT_STEP=""
|
||||
write_state done "Сервер готов"
|
||||
systemctl disable virtuality-firstboot.service >/dev/null 2>&1 || true
|
||||
rm -f "$APT_LOCK_CONF"
|
||||
say "completed. Panel: ${PANEL_URL} login: ${AUTH_USER}"
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="/opt/virtuality"
|
||||
DEFAULT_BRANCH="main"
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
echo "Ошибка: запусти от root: sudo bash setup_github_sync.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
apt update
|
||||
apt install -y git openssh-client
|
||||
|
||||
if [[ ! -d "$PROJECT_DIR" ]]; then
|
||||
echo "Ошибка: папка проекта не найдена: $PROJECT_DIR"
|
||||
echo "Сначала запусти install_virtuality_node.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
read -rp "GitHub repo URL [git@github.com:viktor138irk/virtuality.git]: " GITHUB_REPO
|
||||
GITHUB_REPO="${GITHUB_REPO:-git@github.com:viktor138irk/virtuality.git}"
|
||||
|
||||
read -rp "Git author name [Virtuality Node]: " GIT_NAME
|
||||
GIT_NAME="${GIT_NAME:-Virtuality Node}"
|
||||
|
||||
read -rp "Git author email [virtuality@local]: " GIT_EMAIL
|
||||
GIT_EMAIL="${GIT_EMAIL:-virtuality@local}"
|
||||
|
||||
git config --global user.name "$GIT_NAME"
|
||||
git config --global user.email "$GIT_EMAIL"
|
||||
|
||||
cat > "$PROJECT_DIR/git_sync.sh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="/opt/virtuality"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
MESSAGE="${1:-Auto sync $(date '+%Y-%m-%d %H:%M:%S')}"
|
||||
|
||||
git add .
|
||||
git commit -m "$MESSAGE" || {
|
||||
echo "Нет изменений для commit."
|
||||
exit 0
|
||||
}
|
||||
|
||||
git push origin main
|
||||
EOF
|
||||
|
||||
chmod +x "$PROJECT_DIR/git_sync.sh"
|
||||
|
||||
if [[ ! -d "$PROJECT_DIR/.git" ]]; then
|
||||
git init
|
||||
fi
|
||||
|
||||
git branch -M "$DEFAULT_BRANCH"
|
||||
|
||||
if git remote get-url origin >/dev/null 2>&1; then
|
||||
git remote set-url origin "$GITHUB_REPO"
|
||||
else
|
||||
git remote add origin "$GITHUB_REPO"
|
||||
fi
|
||||
|
||||
git add .
|
||||
git commit -m "Initial Virtuality node base" || true
|
||||
git push -u origin "$DEFAULT_BRANCH"
|
||||
|
||||
echo "GitHub sync готов."
|
||||
echo "Ручная синхронизация: sudo /opt/virtuality/git_sync.sh \"Описание изменений\""
|
||||
@@ -0,0 +1,197 @@
|
||||
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)
|
||||
monkeypatch.setattr(app, "images_free_bytes", lambda: 500 * 1024 ** 3) # временная папка тестов бывает маленькой (tmpfs)
|
||||
for module in (app, core, network_core, host_profile, *feature_modules()):
|
||||
monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
|
||||
return dirs
|
||||
|
||||
|
||||
@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
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/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, "auto": 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"])
|
||||
from features import backup_schedule
|
||||
|
||||
backup_schedule.write_json(backup_schedule.schedule_path(), {**backup_schedule.DEFAULT, "enabled": True, "frequency": "weekly", "weekday": 3})
|
||||
results = [{"vm": "db01", "ok": True}, {"vm": "web01", "ok": True, "removed": []}, {"vm": "win11", "ok": True}]
|
||||
backup_schedule.write_json(backup_schedule.state_path(), {"slot": "2026-09-24 03:00", "status": "done", "started_at": "2026-09-24 03:00:04", "finished_at": "2026-09-24 03:41:10", "results": results})
|
||||
for name in DEMO_VMS: # диски машин лежат в хранилище, иначе форма увеличения не появится
|
||||
with (dirs["images"] / f"{name}.qcow2").open("wb") as handle:
|
||||
handle.truncate(4 * 1024 * 1024)
|
||||
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.run_startup_tasks = lambda: None
|
||||
uvicorn.run(app.app, host="127.0.0.1", port=int(sys.argv[1]) if len(sys.argv) > 1 else 8765, log_level="warning")
|
||||
@@ -0,0 +1,3 @@
|
||||
5861314d7fccb39c2192173240eab44fa35ca66426201ca2acd0630a6258dd51 *resolute-server-cloudimg-amd64.img
|
||||
f69162950f235e3cdbbad33f1f912d1a504be90d8a37d002c735d6f3e3882265 *resolute-server-cloudimg-arm64.img
|
||||
e0361263a9f568c21a59e75c3b7810bc5867866e5bd48f499f8b2e6ab723f059 *resolute-server-cloudimg-amd64.vmdk
|
||||
@@ -0,0 +1,3 @@
|
||||
# generated by debian cloud team
|
||||
1412d62c2d55ae2432df6d22c36b9edf4512a59a43bb4eadb1bd3a5ef201154a3052d83ffae704d7079b50388e61992013348e4bb4dee45b3c2a266e2b296135 debian-13-genericcloud-amd64.qcow2
|
||||
27c03aab0b5ee1f5918bfaa94821d2a1500fc171ffeb0ae159e7d18662f49171551f397683004434096e4006380fa607577b3c90fafa1eceb3a92702ebd49a15 debian-13-genericcloud-arm64.qcow2
|
||||
@@ -0,0 +1,3 @@
|
||||
Capacity: 42949672960
|
||||
Allocation: 6442450944
|
||||
Physical: 6443499520
|
||||
@@ -0,0 +1,4 @@
|
||||
Type Device Target Source
|
||||
------------------------------------------------------------
|
||||
file disk vda /var/lib/virtuality/images/web01.qcow2
|
||||
file cdrom sda -
|
||||
@@ -0,0 +1,16 @@
|
||||
Domain: 'web01'
|
||||
cpu.time=48123456789
|
||||
cpu.user=30000000000
|
||||
cpu.system=18123456789
|
||||
balloon.current=2097152
|
||||
balloon.maximum=2097152
|
||||
balloon.rss=1534208
|
||||
vcpu.current=2
|
||||
vcpu.maximum=2
|
||||
vcpu.0.state=1
|
||||
vcpu.0.time=24000000000
|
||||
vcpu.0.wait=0
|
||||
vcpu.1.state=1
|
||||
vcpu.1.time=24123456789
|
||||
vcpu.1.wait=0
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<domain type='kvm' id='1'>
|
||||
<name>web01</name>
|
||||
<uuid>6c1b3c1e-0000-0000-0000-000000000001</uuid>
|
||||
<title>Сайт компании</title>
|
||||
<description>Nginx + база. Бэкап по пятницам.</description>
|
||||
<memory unit='KiB'>2097152</memory>
|
||||
<currentMemory unit='KiB'>2097152</currentMemory>
|
||||
<vcpu placement='static'>2</vcpu>
|
||||
<os>
|
||||
<type arch='x86_64' machine='pc-q35-10.2'>hvm</type>
|
||||
<boot dev='hd'/>
|
||||
</os>
|
||||
<devices>
|
||||
<emulator>/usr/bin/qemu-system-x86_64</emulator>
|
||||
<disk type='file' device='disk'>
|
||||
<driver name='qemu' type='qcow2'/>
|
||||
<source file='/var/lib/virtuality/images/web01.qcow2'/>
|
||||
<target dev='vda' bus='virtio'/>
|
||||
</disk>
|
||||
<disk type='file' device='cdrom'>
|
||||
<driver name='qemu' type='raw'/>
|
||||
<target dev='sda' bus='sata'/>
|
||||
<readonly/>
|
||||
</disk>
|
||||
<interface type='network'>
|
||||
<mac address='52:54:00:aa:bb:cc'/>
|
||||
<source network='virtuality-nat'/>
|
||||
<model type='virtio'/>
|
||||
</interface>
|
||||
<channel type='unix'>
|
||||
<target type='virtio' name='org.qemu.guest_agent.0'/>
|
||||
</channel>
|
||||
<graphics type='vnc' port='5900' autoport='yes' listen='127.0.0.1'>
|
||||
<listen type='address' address='127.0.0.1'/>
|
||||
</graphics>
|
||||
</devices>
|
||||
</domain>
|
||||
@@ -0,0 +1,6 @@
|
||||
Id Name State Title
|
||||
--------------------------------------------
|
||||
1 web01 running Сайт компании
|
||||
- db01 shut off
|
||||
- win11 shut off Бухгалтерия 1С
|
||||
|
||||