Сравнить коммиты
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
73d3f5259f | ||
|
|
ebc9b99991 | ||
|
|
5cf0a6b640 | ||
|
|
02a6d544cf | ||
|
|
52e2033031 | ||
|
|
0b0496f6bb | ||
|
|
374bcbb29f | ||
|
|
d322c1352c | ||
|
|
bb87f52834 | ||
|
|
1da491c9e1 | ||
|
|
71f7ee2ef3 | ||
|
|
a175a6f661 | ||
|
|
9e54614869 | ||
|
|
0c95e8e516 | ||
|
|
77a263ec86 |
@@ -2,39 +2,30 @@ 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"]
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
python-version: "3.12"
|
||||
|
||||
shell:
|
||||
name: Shell scripts
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: make lint-shell
|
||||
- name: Проверка синтаксиса Python
|
||||
run: python -m compileall -q web scripts
|
||||
|
||||
- name: Проверка синтаксиса bash-скриптов
|
||||
run: |
|
||||
bash -n install.sh bootstrap.sh install_virtuality_node.sh setup_github_sync.sh
|
||||
for script in scripts/*.sh; do bash -n "$script"; done
|
||||
|
||||
- name: Установка зависимостей
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r web/requirements.txt pytest httpx
|
||||
|
||||
- name: Тесты
|
||||
run: pytest -q
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
name: Build ISO image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths:
|
||||
- "image/**"
|
||||
- "scripts/virtuality_firstboot.sh"
|
||||
- "web/requirements.txt"
|
||||
- ".github/workflows/image.yml"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
arch:
|
||||
description: "Architecture"
|
||||
type: choice
|
||||
options: [amd64, arm64, both]
|
||||
default: amd64
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: image-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
iso:
|
||||
name: ISO ${{ matrix.arch }}
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: sudo apt-get update && sudo apt-get install -y xorriso python3-yaml
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .cache/iso
|
||||
key: ubuntu-iso-${{ matrix.arch }}-${{ hashFiles('image/build-iso.sh') }}
|
||||
restore-keys: ubuntu-iso-${{ matrix.arch }}-
|
||||
- name: Free disk space
|
||||
run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL && df -h /
|
||||
- run: make iso ARCH=${{ matrix.arch }}
|
||||
- name: Check ISO boot records
|
||||
run: |
|
||||
iso=$(ls dist/*.iso)
|
||||
xorriso -indev "$iso" -report_el_torito plain 2>/dev/null | tee /dev/stderr | grep -q "UEFI"
|
||||
xorriso -osirrox on -indev "$iso" -extract /autoinstall.yaml autoinstall.yaml -extract /boot/grub/grub.cfg grub.cfg
|
||||
python3 -c "import yaml; d = yaml.safe_load(open('autoinstall.yaml')); assert d['autoinstall']['version'] == 1"
|
||||
grep -q "Установить Virtuality" grub.cfg
|
||||
# Ubuntu server ISOs are larger than the 2 GB GitHub release asset limit,
|
||||
# so images are published as workflow artifacts.
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: virtuality-iso-${{ matrix.arch }}
|
||||
path: |
|
||||
dist/*.iso
|
||||
dist/*.sha256
|
||||
compression-level: 0
|
||||
retention-days: 30
|
||||
@@ -91,9 +91,3 @@ composer.lock
|
||||
# System files
|
||||
*.pid
|
||||
*.sock
|
||||
|
||||
# Offline Python wheels bundled into system images
|
||||
wheels/
|
||||
|
||||
# рабочие копии агентов и локальные настройки Claude Code
|
||||
.claude/
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,34 +0,0 @@
|
||||
PYTHON ?= python3
|
||||
ARCH ?= amd64
|
||||
ISO_ARGS ?=
|
||||
SHELL_SCRIPTS := $(wildcard *.sh scripts/*.sh image/*.sh)
|
||||
|
||||
.PHONY: help test lint lint-python lint-shell check iso clean
|
||||
|
||||
help:
|
||||
@echo "make test - run the web panel test suite"
|
||||
@echo "make lint - pyflakes + shellcheck"
|
||||
@echo "make check - lint + test"
|
||||
@echo "make iso - build the installer ISO (ARCH=amd64|arm64, ISO_ARGS='--unattended ...')"
|
||||
@echo "make clean - remove build output"
|
||||
|
||||
test:
|
||||
$(PYTHON) -m pytest -q
|
||||
|
||||
lint-python:
|
||||
$(PYTHON) -m pyflakes web tests
|
||||
|
||||
lint-shell:
|
||||
@for f in $(SHELL_SCRIPTS); do bash -n "$$f" || exit 1; done
|
||||
shellcheck -S error $(SHELL_SCRIPTS)
|
||||
|
||||
lint: lint-python lint-shell
|
||||
|
||||
check: lint test
|
||||
|
||||
iso:
|
||||
./image/build-iso.sh --arch $(ARCH) $(ISO_ARGS)
|
||||
|
||||
clean:
|
||||
rm -rf dist .pytest_cache
|
||||
find . -name __pycache__ -type d -prune -exec rm -rf {} +
|
||||
@@ -1,362 +1,448 @@
|
||||
# Virtuality
|
||||
|
||||
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
|
||||
**Virtuality** — лёгкая серверная платформа виртуализации на базе **KVM**, **QEMU**, **libvirt**, **Cockpit** и собственной web-панели.
|
||||
|
||||
Текущая версия: **0.12.5** (файл `VERSION`, журнал изменений — `updates/versions.json`).
|
||||
Цель проекта — собрать понятную, компактную и расширяемую систему для управления виртуальными машинами, ISO-образами, NAT/bridge-сетью, пробросом портов, диагностикой, web-console/noVNC, backup/snapshot-функциями и будущей кластеризацией.
|
||||
|
||||
Что умеет:
|
||||
|
||||
- ставится с готового ISO-образа, как Proxmox: три экрана установщика — и через несколько минут панель открыта в браузере;
|
||||
- создаёт машины из установочных ISO (в том числе Windows 11) и за минуту — из готовых облачных образов Ubuntu, Debian и Alpine;
|
||||
- снимки, резервные копии, клонирование, увеличение диска, спящий режим;
|
||||
- сеть машин «из коробки»: отдельная сеть NAT с постоянными адресами и пробросом портов или ваша домашняя сеть через мост;
|
||||
- экран машины прямо в браузере, HTTPS для панели, автообновления со стабильным каналом.
|
||||
|
||||

|
||||
|
||||
| Мастер создания машины | Страница машины |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Сеть и доступ | Тёмная тема |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Мастер настройки: установка | Мастер настройки: сеть машин |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Каталог готовых систем | Настройки |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Снимки | Резервные копии |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Вход | На телефоне |
|
||||
|---|---|
|
||||
|  |  |
|
||||
> Текущий статус: ранняя стадия разработки. Уже есть one-command установка, автоопределение профиля хоста, web-панель, авторизация через Linux-пользователя, ISO-менеджер, создание VM из интерфейса, журнал операций, NAT Router для VPS/ARM edge nodes и port forwarding.
|
||||
|
||||
---
|
||||
|
||||
## Содержание
|
||||
## Поддерживаемые профили хоста
|
||||
|
||||
- [Установка](#установка)
|
||||
- [Способ 1: установочный ISO](#способ-1-установочный-iso)
|
||||
- [Способ 2: одна команда на готовый сервер](#способ-2-одна-команда-на-готовый-сервер)
|
||||
- [Способ 3: вручную](#способ-3-вручную)
|
||||
- [Первая загрузка и мастер настройки](#первая-загрузка-и-мастер-настройки)
|
||||
- [Возможности](#возможности)
|
||||
- [Настройки](#настройки)
|
||||
- [Команда virtuality-ctl](#команда-virtuality-ctl)
|
||||
- [Обновления и откат](#обновления-и-откат)
|
||||
- [Удаление](#удаление)
|
||||
- [Если что-то не работает](#если-что-то-не-работает)
|
||||
- [Где что лежит](#где-что-лежит)
|
||||
- [Разработка](#разработка)
|
||||
|
||||
Подробные документы: [требования](docs/REQUIREMENTS.md) · [установочный образ](docs/IMAGE.md) · [первая машина](docs/FIRST_VM.md) · [архитектура](docs/ARCHITECTURE.md) · [планы](docs/ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
## Установка
|
||||
|
||||
Перед установкой проверьте [требования](docs/REQUIREMENTS.md): процессор x86_64 с включённой виртуализацией (Intel VT-x / AMD-V), от 4 ГБ памяти, проводная сеть.
|
||||
|
||||
### Способ 1: установочный ISO
|
||||
|
||||
Самый простой путь для «голого» сервера или старого компьютера. Образ собирается из чистого Ubuntu Server 26.04 LTS; установщик спрашивает только сеть, диск и пользователя.
|
||||
|
||||
Где взять образ:
|
||||
|
||||
- **Собрать самому** на любой 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://git.stackworks.ru/viktor/virtuality/raw/branch/main/install.sh | bash
|
||||
```
|
||||
|
||||
Скрипт проверит требования, поставит пакеты KVM/QEMU/libvirt, команду `vhealth` и панель, а в конце покажет адрес панели. Логин — тот пользователь, от которого запущена установка (через `sudo` — ваш пользователь, под `root` — `root`); пароль — его пароль Linux. Если пароль ещё не задан: `sudo passwd <пользователь>`.
|
||||
|
||||
Переменные окружения, которые понимает `install.sh`:
|
||||
Virtuality автоматически определяет профиль сборки во время установки:
|
||||
|
||||
```text
|
||||
VIRTUALITY_USER=admin # какой Linux-пользователь входит в панель (будет создан, если его нет)
|
||||
VIRTUALITY_WEB_PORT=8089 # порт панели (по умолчанию 8088)
|
||||
VIRTUALITY_AUTO_UPDATE=0 # не включать ночное автообновление
|
||||
VIRTUALITY_BRANCH=main # ветка репозитория
|
||||
VIRTUALITY_INSTALL_VERBOSE=1 # показывать вывод команд вместо спиннера
|
||||
x86_64 # обычный сервер, домашний сервер, VPS
|
||||
raspberry-arm64 # Raspberry Pi ARM64 edge node
|
||||
orangepi5-arm64 # Orange Pi 5 / RK3588 ARM64 edge node
|
||||
generic-arm64 # другая ARM64-плата
|
||||
```
|
||||
|
||||
Профиль сохраняется здесь:
|
||||
|
||||
```text
|
||||
/var/lib/virtuality/config/host_profile.json
|
||||
```
|
||||
|
||||
В web-панели профиль виден на странице:
|
||||
|
||||
```text
|
||||
/host
|
||||
```
|
||||
|
||||
Для ARM64-плат правильный сценарий — **ARM64-гости**, NAT-сеть и позже cloud-image/cloud-init шаблоны. x86_64 ISO на Raspberry/Orange Pi не являются целевым режимом.
|
||||
|
||||
---
|
||||
|
||||
## Быстрая установка одной командой
|
||||
|
||||
Одна команда работает и под `root`, и под обычным пользователем с `sudo`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | bash
|
||||
```
|
||||
|
||||
Как выбирается пользователь для входа в web-панель:
|
||||
|
||||
```text
|
||||
Если запуск под root → login: root
|
||||
Если запуск через sudo → login: текущий sudo-пользователь
|
||||
Если задан VIRTUALITY_USER → login: указанный Linux-пользователь
|
||||
```
|
||||
|
||||
Принудительно выбрать пользователя:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=<linux_user> bash
|
||||
```
|
||||
|
||||
Пример:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.stackworks.ru/viktor/virtuality/raw/branch/main/install.sh | VIRTUALITY_USER=admin bash
|
||||
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=admin bash
|
||||
```
|
||||
|
||||
При первом входе в панель откроется мастер настройки — тот же, что после установки с ISO.
|
||||
|
||||
### Способ 3: вручную
|
||||
|
||||
Если хочется видеть каждый шаг. Всё делается от `root` или через `sudo`:
|
||||
Установка с авторизацией под `root`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=root bash
|
||||
```
|
||||
|
||||
Если пароль выбранного Linux-пользователя не задан:
|
||||
|
||||
```bash
|
||||
sudo passwd <linux_user>
|
||||
```
|
||||
|
||||
Для root:
|
||||
|
||||
```bash
|
||||
passwd root
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Что уже есть
|
||||
|
||||
- универсальная one-command установка через `install.sh`;
|
||||
- автоопределение профиля хоста: x86_64, Raspberry Pi ARM64, Orange Pi 5 ARM64, generic ARM64;
|
||||
- установка разных пакетных наборов под x86 и ARM64;
|
||||
- красивый пошаговый installer с live-status и логами;
|
||||
- preflight-проверка системных требований;
|
||||
- установка KVM/QEMU/libvirt;
|
||||
- установка Cockpit и Cockpit Machines;
|
||||
- рабочая директория `/opt/virtuality/source`;
|
||||
- libvirt storage pools `virtuality-images` и `virtuality-iso`;
|
||||
- firewall-правила для SSH, Cockpit, VNC и web-панели;
|
||||
- безопасная ручная настройка bridge `br0` через `netplan try`;
|
||||
- NAT Router `virtuality-nat` для VPS/ARM edge nodes;
|
||||
- port forwarding через nftables;
|
||||
- автоматическое определение IP VM для проброса портов;
|
||||
- healthcheck одной командой `vhealth`;
|
||||
- консольный dashboard для физического монитора;
|
||||
- web-панель Virtuality на FastAPI;
|
||||
- авторизация web-панели через системного Linux-пользователя;
|
||||
- `/host` — профиль хоста и проверки готовности;
|
||||
- `/network` — NAT, DHCP leases, port forwarding, nftables preview;
|
||||
- `/iso` — ISO-менеджер;
|
||||
- `/operations` — журнал фоновых операций;
|
||||
- список VM;
|
||||
- управление VM: Start, Shutdown, Reboot, Power off, Autostart on/off, Delete VM with disks;
|
||||
- создание VM из web-интерфейса;
|
||||
- выбор сетевого режима при создании VM: `virtuality-nat` или `br0`;
|
||||
- прогресс создания VM и live-лог `virt-install`;
|
||||
- страница деталей VM: `dominfo`, VNC display, IP, диски, сетевые интерфейсы;
|
||||
- web-консоль noVNC прямо в браузере;
|
||||
- cloud-образы Ubuntu/Debian (x86_64 и ARM64) и создание VM из образа с cloud-init за минуту;
|
||||
- клонирование VM и шаблоны (эталонные VM с блокировкой запуска);
|
||||
- снапшоты VM: создание, откат, удаление;
|
||||
- бэкапы дисков VM со сжатием, ротацией и ночным расписанием;
|
||||
- HTTPS из коробки (самоподписанный сертификат), CSRF-защита форм, PAM-аутентификация, защита от перебора пароля;
|
||||
- графики CPU/RAM хоста на дашборде;
|
||||
- быстрый libvirt-backend с fallback на virsh для слабого железа;
|
||||
- тесты pytest и CI на GitHub Actions.
|
||||
|
||||
---
|
||||
|
||||
## Системные требования
|
||||
|
||||
Минимально для x86_64 тестового стенда:
|
||||
|
||||
```text
|
||||
CPU: 2 ядра с Intel VT-x / AMD-V
|
||||
RAM: 4 GB
|
||||
/: минимум 8 GB свободно
|
||||
/var/lib: минимум 20 GB свободно
|
||||
OS: Ubuntu Server 24.04 LTS / Debian-like с apt
|
||||
Network: один проводной интерфейс
|
||||
```
|
||||
|
||||
Рекомендуемо для x86_64:
|
||||
|
||||
```text
|
||||
CPU: 4+ ядра
|
||||
RAM: 16+ GB
|
||||
Storage: 100+ GB SSD/NVMe под /var/lib/virtuality
|
||||
Network: 1 Gbit/s+
|
||||
OS: Ubuntu Server 24.04 LTS
|
||||
```
|
||||
|
||||
ARM64 edge nodes:
|
||||
|
||||
```text
|
||||
Raspberry Pi 4/5 ARM64: желательно 8 GB RAM
|
||||
Orange Pi 5 ARM64: желательно 8/16/32 GB RAM и NVMe
|
||||
Гости: ARM64 Linux VM
|
||||
Сеть: virtuality-nat по умолчанию
|
||||
```
|
||||
|
||||
Подробнее: [`docs/REQUIREMENTS.md`](docs/REQUIREMENTS.md)
|
||||
|
||||
---
|
||||
|
||||
## Порты
|
||||
|
||||
```text
|
||||
Cockpit: https://SERVER_IP:9090
|
||||
Virtuality UI: https://SERVER_IP:8088
|
||||
VNC: 5900-5999/tcp
|
||||
SSH: 22/tcp
|
||||
```
|
||||
|
||||
Панель работает по HTTPS с самоподписанным сертификатом — браузер один раз попросит подтвердить исключение. Отключить TLS: `VIRTUALITY_TLS=0` при установке.
|
||||
|
||||
Изменить порт web-панели:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_WEB_PORT=8089 bash
|
||||
```
|
||||
|
||||
Для VPS/ARM NAT-сценария входящие сервисы VM открываются через port forwarding в разделе `/network`.
|
||||
|
||||
---
|
||||
|
||||
## Директории
|
||||
|
||||
```text
|
||||
/opt/virtuality/source # исходники проекта из GitHub
|
||||
/opt/virtuality/web # установленная web-панель
|
||||
/opt/virtuality/venv # Python virtualenv web-панели
|
||||
/opt/virtuality/virtuality.env # базовый env ноды
|
||||
/var/lib/virtuality/config # конфиги Virtuality
|
||||
/var/lib/virtuality/config/host_profile.json
|
||||
/var/lib/virtuality/iso # ISO-образы
|
||||
/var/lib/virtuality/images # qcow2-диски VM
|
||||
/var/lib/virtuality/network # port_forwards.json
|
||||
/var/lib/virtuality/backups # backups
|
||||
/var/log/virtuality # логи установки и диагностики
|
||||
/var/log/virtuality/operations # JSON/log фоновых операций
|
||||
/etc/virtuality/nftables/virtuality.nft # nftables-правила Virtuality
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Диагностика
|
||||
|
||||
```bash
|
||||
sudo vhealth
|
||||
```
|
||||
|
||||
Дополнительно:
|
||||
|
||||
```bash
|
||||
cat /var/lib/virtuality/config/host_profile.json
|
||||
systemctl status virtuality-web --no-pager
|
||||
journalctl -u virtuality-web -n 120 --no-pager
|
||||
virsh list --all
|
||||
virsh pool-list --all
|
||||
virsh net-list --all
|
||||
virsh net-dhcp-leases virtuality-nat
|
||||
ip -br a
|
||||
ip route
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Web-панель
|
||||
|
||||
URL:
|
||||
|
||||
```text
|
||||
http://SERVER_IP:8088
|
||||
```
|
||||
|
||||
Основные разделы:
|
||||
|
||||
```text
|
||||
/ # дашборд с графиками CPU/RAM
|
||||
/host # профиль хоста и проверки
|
||||
/iso # ISO-менеджер
|
||||
/disk-images # готовые образы дисков
|
||||
/cloud-images # cloud-образы Ubuntu/Debian
|
||||
/vm/create # создание VM из ISO или готового диска
|
||||
/vm/create-cloud # создание VM из cloud-образа с cloud-init
|
||||
/backups # бэкапы VM и ночное расписание
|
||||
/network # NAT Router и проброс портов
|
||||
/operations # журнал операций
|
||||
/logs # центр журналов
|
||||
/update # центр обновлений
|
||||
```
|
||||
|
||||
Переустановка web-панели:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/virtuality
|
||||
sudo git clone https://git.stackworks.ru/viktor/virtuality.git /opt/virtuality/source
|
||||
cd /opt/virtuality/source
|
||||
sudo bash install_virtuality_node.sh # пакеты KVM/QEMU/libvirt, пулы, firewall
|
||||
sudo bash scripts/install_healthcheck_command.sh # команда sudo vhealth
|
||||
sudo VIRTUALITY_AUTH_USER=admin bash scripts/install_web_panel.sh # панель и virtuality-ctl
|
||||
sudo bash scripts/install_web_panel.sh
|
||||
```
|
||||
|
||||
`install_web_panel.sh` копирует панель в `/opt/virtuality/web`, создаёт virtualenv, ставит `virtuality-ctl` и через него — systemd-юниты и правила firewall. Переменные: `VIRTUALITY_AUTH_USER` (пользователь для входа), `VIRTUALITY_WEB_PORT` (порт), `VIRTUALITY_AUTO_UPDATE` (0/1). Повторный запуск скрипта обновляет панель, сохраняя настройки.
|
||||
|
||||
---
|
||||
|
||||
## Первая загрузка и мастер настройки
|
||||
|
||||
### Что происходит после установки с ISO
|
||||
|
||||
После перезагрузки запускается `virtuality-firstboot.service` (`scripts/virtuality_firstboot.sh`). Порядок шагов выбран так, чтобы вы как можно раньше увидели, что происходит:
|
||||
|
||||
1. **Панель управления** — ставится офлайн из Python-колёс, упакованных в образ, и сразу запускается. Открывайте `http://адрес-сервера:8088` — мастер покажет ход остальных шагов прямо в браузере.
|
||||
2. **Подключение к интернету** — ожидание сети. Если кабель не подключён, мастер напишет об этом и продолжит сам, как только интернет появится.
|
||||
3. **KVM, QEMU и libvirt** — самый долгий шаг, обычно 5–15 минут: пакеты ставятся из репозиториев Ubuntu, мастер показывает, какой пакет скачивается или настраивается.
|
||||
4. **Инструменты диагностики** — команда `vhealth`.
|
||||
5. **Завершение** — `virtuality-ctl reconfigure`: юниты, firewall, перезапуск панели.
|
||||
|
||||
Ход установки пишется в `/var/lib/virtuality/config/setup.json` и в `/var/log/virtuality/firstboot.log`. Если какой-то шаг упал (например, отвалилась сеть), systemd перезапускает скрипт через минуту, и он продолжает с первого незавершённого шага. На экране сервера (tty) всё это время показана подсказка, а после завершения — адрес панели и логин.
|
||||
|
||||
Логин в панель — пользователь, созданный в установщике Ubuntu; пароль — его пароль.
|
||||
|
||||
### Мастер настройки
|
||||
|
||||
Мастер (`/setup`) открывается при первом входе в панель — и после ISO, и после `install.sh`. Каждый шаг можно пропустить, а весь мастер — запустить заново из раздела «Настройки».
|
||||
|
||||
| Шаг | Что делает |
|
||||
|---|---|
|
||||
| **Установка** | Показывает ход первой загрузки (только после ISO). |
|
||||
| **Сервер** | Проверяет процессор, память, диск и доступность KVM; задаёт часовой пояс. |
|
||||
| **Администратор** | Смена пароля пользователя панели (он же пароль SSH). |
|
||||
| **Доступ** | HTTPS с сертификатом сервера (панель на порту 8443, с 8088 — перенаправление) или обычный HTTP; порт. |
|
||||
| **Сеть машин** | **Отдельная сеть (NAT)** — машины получают адреса 192.168.100.x, работает везде. **Домашняя сеть (мост)** — машины становятся устройствами вашей сети; сервер сам перестроит сеть в мост `br0`, а если панель не ответит за 2 минуты — вернёт всё как было. |
|
||||
| **Хранилище** | Отдать пустой диск (от 16 ГБ) под машины: он форматируется и монтируется в `/var/lib/virtuality`. Или оставить системный диск. |
|
||||
| **Обновления** | Автоматически ночью (только выпущенные версии) или вручную; канал: стабильный или ранний доступ. |
|
||||
| **Готово** | Сводка; настройки применяются, при смене адреса панель перезапускается и открывается по новому. |
|
||||
|
||||
---
|
||||
|
||||
## Возможности
|
||||
|
||||
Разделы панели: **Обзор**, **Создать машину**, **Образы ОС**, **Сеть и доступ**, **Резервные копии**, **Задачи**, **Журналы**, **Обновления**, **Сервер**, **Настройки**, **Помощь**.
|
||||
|
||||
**Машины**
|
||||
|
||||
- Создание из установочного ISO: три шага — название и система, мощность (готовые профили «Мини», «Стандарт», «Мощная» или свои значения), сеть.
|
||||
- Тип системы для ISO: Linux, **Windows 11 / Server 2025** (UEFI и TPM 2.0 включаются сами), Windows 10 / Server 2019–2022, другая. Для Windows подбираются диск SATA и сетевая карта Intel — драйверы не нужны.
|
||||
- Создание из готового образа за минуту: образ конвертируется в qcow2, диск увеличивается до нужного размера, а cloud-init настраивает первый вход — пользователя, пароль и/или SSH-ключ. Пароль хранится только как хеш. При первой загрузке в машину ставится гостевой агент, поэтому панель видит её адрес в любой сети и выключает её корректно.
|
||||
- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками и пробросами портов (работающая машина сначала выключается).
|
||||
- Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места).
|
||||
- Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ).
|
||||
- **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти.
|
||||
- **Экран машины в браузере** (noVNC). VNC наружу не открыт: консоль доступна только через панель.
|
||||
|
||||
**Снимки и копии**
|
||||
|
||||
- **Снимки** — точки восстановления внутри диска машины, до 8 на машину. У работающей машины в снимок попадает и память. Вернуться или удалить — одной кнопкой.
|
||||
- **Резервные копии** — полная копия настроек и всех дисков в `/var/lib/virtuality/backups/<машина>/<дата>/`. Восстановление под тем же или новым названием.
|
||||
- **Копии по расписанию** — каждый день или раз в неделю в выбранное время, для всех машин или только отмеченных. Сервер хранит последние 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
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Обновления и откат
|
||||
|
||||
- **Из панели**: раздел **Обновления** → «Проверить сейчас» → «Обновить». Машины во время обновления продолжают работать. Что изменилось — видно на той же странице.
|
||||
- **Автоматически**: таймер `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`.
|
||||
|
||||
---
|
||||
|
||||
## Удаление
|
||||
Статус:
|
||||
|
||||
```bash
|
||||
sudo virtuality-ctl uninstall # убирает панель, службы и настройки; машины, образы и копии остаются
|
||||
sudo virtuality-ctl uninstall --purge # удаляет ВСЁ: машины, диски, образы, резервные копии, сеть NAT
|
||||
systemctl status virtuality-web --no-pager
|
||||
```
|
||||
|
||||
Команда спрашивает подтверждение (`YES`); `--yes` пропускает вопрос. Пакеты KVM/QEMU/libvirt остаются в системе.
|
||||
Логи:
|
||||
|
||||
---
|
||||
```bash
|
||||
journalctl -u virtuality-web -f
|
||||
```
|
||||
|
||||
## Если что-то не работает
|
||||
|
||||
1. Раздел **Сервер** в панели показывает, чего не хватает (KVM, пакеты, мост). Раздел **Задачи** — журнал каждой операции. Раздел **Журналы** — панель, обновления, установка, виртуализация.
|
||||
2. Полная диагностика в терминале:
|
||||
|
||||
```bash
|
||||
sudo vhealth # проверка процессора, служб, сети, firewall, libvirt, дисков
|
||||
sudo virtuality-ctl status # версия, адрес панели, HTTPS, автообновление, хранилище
|
||||
```
|
||||
|
||||
3. Отчёт для поддержки — кнопка в «Настройках» или `sudo virtuality-ctl support-bundle`: архив с настройками (без секретов), журналами служб и последними задачами.
|
||||
4. Журналы:
|
||||
|
||||
```bash
|
||||
journalctl -u virtuality-web -f # панель
|
||||
sudo journalctl -fu virtuality-firstboot # первая загрузка после ISO
|
||||
cat /var/log/virtuality/firstboot.log # то же, файлом
|
||||
cat /var/log/virtuality/update.log # обновления
|
||||
ls /var/log/virtuality/operations/ # задачи: JSON + журнал каждой
|
||||
```
|
||||
|
||||
5. Для мониторинга: `GET /healthz` без авторизации отвечает `{"ok": true, "version": "..."}`.
|
||||
|
||||
Полезные команды libvirt: `virsh list --all`, `virsh net-list --all`, `virsh net-dhcp-leases virtuality-nat`, `virsh pool-list --all`.
|
||||
|
||||
---
|
||||
|
||||
## Где что лежит
|
||||
Вход:
|
||||
|
||||
```text
|
||||
/opt/virtuality/source # исходники (git), отсюда обновления
|
||||
/opt/virtuality/web # установленная панель (web.prev — предыдущая версия)
|
||||
/opt/virtuality/venv # Python virtualenv панели
|
||||
/var/lib/virtuality/iso # установочные ISO
|
||||
/var/lib/virtuality/disk-images # готовые образы систем (каталог, ссылки, загрузки)
|
||||
/var/lib/virtuality/images # диски машин (qcow2)
|
||||
/var/lib/virtuality/backups # резервные копии машин и архивы настроек
|
||||
/var/lib/virtuality/config # web.env, setup.json, wizard.json, host_profile.json, 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 # диагностика
|
||||
Login: выбранный Linux-пользователь
|
||||
Pass: пароль этого Linux-пользователя
|
||||
```
|
||||
|
||||
Порты: панель `8088` (HTTP) и `8443` (HTTPS), SSH `22`. VNC-порты машин наружу не открываются.
|
||||
|
||||
---
|
||||
|
||||
## Разработка
|
||||
## Сеть: Bridge и NAT Router
|
||||
|
||||
### Bridge br0
|
||||
|
||||
Подходит для домашнего/офисного сервера, где VM должны получать IP из локальной сети.
|
||||
|
||||
По умолчанию one-command установщик **не включает `br0` автоматически**, чтобы не уронить SSH-сессию.
|
||||
|
||||
Сначала нужно посмотреть сетевой интерфейс:
|
||||
|
||||
```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)
|
||||
ip -br a
|
||||
ip route
|
||||
```
|
||||
|
||||
Демо-панель без сервера виртуализации — с тремя машинами, задачами и загрузками:
|
||||
Затем запустить bridge setup, например для `enp2s0`:
|
||||
|
||||
```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 # настроенный сервер, страница «Настройки»
|
||||
cd /opt/virtuality/source
|
||||
sudo bash scripts/setup_bridge_br0.sh enp2s0 static
|
||||
```
|
||||
|
||||
Устройство кода описано в [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 тоже понимает, они заработают, если раннер появится.
|
||||
```text
|
||||
enp2s0 UP
|
||||
br0 UP SERVER_IP/24
|
||||
default via GATEWAY dev br0
|
||||
```
|
||||
|
||||
### Virtuality NAT Router
|
||||
|
||||
Подходит для VPS, Raspberry Pi, Orange Pi 5 и других ARM64 edge nodes.
|
||||
|
||||
Схема:
|
||||
|
||||
```text
|
||||
Интернет / LAN
|
||||
↓
|
||||
Хост Virtuality
|
||||
↓
|
||||
virtuality-nat / virbr100
|
||||
↓
|
||||
VM 192.168.100.x
|
||||
```
|
||||
|
||||
Создать или починить NAT-сеть можно в web-панели:
|
||||
|
||||
```text
|
||||
/network → Создать / починить NAT-сеть
|
||||
```
|
||||
|
||||
Параметры по умолчанию:
|
||||
|
||||
```text
|
||||
Network: virtuality-nat
|
||||
Bridge: virbr100
|
||||
Subnet: 192.168.100.0/24
|
||||
Gateway: 192.168.100.1
|
||||
DHCP: 192.168.100.50–192.168.100.200
|
||||
```
|
||||
|
||||
Проброс порта добавляется в `/network`. IP VM определяется автоматически через `virsh domifaddr`, а если не получилось — через DHCP leases и MAC-адрес VM.
|
||||
|
||||
Пример SSH-проброса:
|
||||
|
||||
```text
|
||||
VM: ubuntu-test
|
||||
Внешний порт сервера: 2222
|
||||
Порт внутри VM: 22
|
||||
Протокол: tcp
|
||||
```
|
||||
|
||||
Подключение:
|
||||
|
||||
```bash
|
||||
ssh user@SERVER_IP -p 2222
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ISO и создание VM
|
||||
|
||||
Загрузка ISO через web-панель:
|
||||
|
||||
```text
|
||||
/iso
|
||||
```
|
||||
|
||||
Создание VM:
|
||||
|
||||
```text
|
||||
/vm/create
|
||||
```
|
||||
|
||||
При создании VM можно выбрать сеть:
|
||||
|
||||
```text
|
||||
VPS NAT Router — virtuality-nat
|
||||
Bridge — br0 / локальная сеть
|
||||
```
|
||||
|
||||
Для x86_64 можно использовать обычные x86_64 ISO. Для Raspberry Pi / Orange Pi 5 нужны ARM64 ISO или, в будущем, ARM64 cloud images.
|
||||
|
||||
Пример загрузки Alpine x86_64 ISO:
|
||||
|
||||
```bash
|
||||
sudo wget -O /var/lib/virtuality/iso/alpine-standard-x86_64.iso https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-standard-3.20.3-x86_64.iso
|
||||
sudo virsh pool-refresh virtuality-iso
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Обновление
|
||||
|
||||
```bash
|
||||
cd /opt/virtuality/source
|
||||
sudo git pull
|
||||
sudo bash scripts/install_web_panel.sh
|
||||
sudo systemctl restart virtuality-web
|
||||
```
|
||||
|
||||
Полная повторная установка компонентов ноды:
|
||||
|
||||
```bash
|
||||
cd /opt/virtuality/source
|
||||
sudo bash install_virtuality_node.sh
|
||||
sudo bash scripts/install_web_panel.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Полезные команды
|
||||
|
||||
```bash
|
||||
sudo vhealth
|
||||
virsh list --all
|
||||
virsh pool-list --all
|
||||
virsh net-list --all
|
||||
virsh net-dhcp-leases virtuality-nat
|
||||
ip -br a
|
||||
ip route
|
||||
sudo nft list ruleset
|
||||
sudo ufw status
|
||||
systemctl status libvirtd --no-pager
|
||||
systemctl status cockpit.socket --no-pager
|
||||
systemctl status virtuality-web --no-pager
|
||||
journalctl -u virtuality-web -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- управление storage pools;
|
||||
- сетевой менеджер bridge/VLAN;
|
||||
- журнал событий.
|
||||
|
||||
Сделано из прошлого roadmap: cloud-image шаблоны (x86_64 и ARM64), cloud-init,
|
||||
web-console/noVNC, снапшоты и бэкапы, автообновление.
|
||||
|
||||
Сознательно не планируется: кластеризация, HA, Ceph/ZFS и роли пользователей —
|
||||
Virtuality остаётся лёгкой панелью для одиночных нод.
|
||||
|
||||
---
|
||||
|
||||
## Лицензия
|
||||
|
||||
[MIT License](LICENSE).
|
||||
**MIT License** — см. [`LICENSE`](LICENSE).
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.12.5
|
||||
0.9.13
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ==========================================================
|
||||
# Virtuality One-command Bootstrap Installer
|
||||
# Public install entrypoint for clean Ubuntu/Debian servers.
|
||||
# ==========================================================
|
||||
|
||||
REPO_URL="${VIRTUALITY_REPO_URL:-https://github.com/viktor138irk/virtuality.git}"
|
||||
DEFAULT_INSTALL_USER="${SUDO_USER:-root}"
|
||||
INSTALL_USER="${VIRTUALITY_USER:-$DEFAULT_INSTALL_USER}"
|
||||
PROJECT_BASE_DIR="${VIRTUALITY_PROJECT_BASE_DIR:-/opt/virtuality}"
|
||||
PROJECT_DIR="${VIRTUALITY_PROJECT_DIR:-${PROJECT_BASE_DIR}/source}"
|
||||
RUN_BRIDGE="${VIRTUALITY_SETUP_BRIDGE:-0}"
|
||||
BRIDGE_IFACE="${VIRTUALITY_BRIDGE_IFACE:-}"
|
||||
RUN_TEST_VM="${VIRTUALITY_CREATE_TEST_VM:-0}"
|
||||
WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}"
|
||||
LOG_DIR="/var/log/virtuality"
|
||||
LOG_FILE="${LOG_DIR}/bootstrap_$(date +%Y%m%d_%H%M%S).log"
|
||||
MIN_ROOT_FREE_MB="${VIRTUALITY_MIN_ROOT_FREE_MB:-8192}"
|
||||
MIN_VAR_FREE_MB="${VIRTUALITY_MIN_VAR_FREE_MB:-20480}"
|
||||
MIN_RAM_MB="${VIRTUALITY_MIN_RAM_MB:-4096}"
|
||||
MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}"
|
||||
SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}"
|
||||
|
||||
ESC="\033"
|
||||
RESET="${ESC}[0m"
|
||||
BOLD="${ESC}[1m"
|
||||
GREEN="${ESC}[32m"
|
||||
YELLOW="${ESC}[33m"
|
||||
RED="${ESC}[31m"
|
||||
CYAN="${ESC}[36m"
|
||||
BLUE="${ESC}[34m"
|
||||
GRAY="${ESC}[90m"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
timestamp() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||
log() { echo "[$(timestamp)] $*" >> "$LOG_FILE"; }
|
||||
ok() { echo -e " ${GREEN}✓${RESET} $*"; log "OK: $*"; }
|
||||
warn() { echo -e " ${YELLOW}!${RESET} $*"; log "WARN: $*"; }
|
||||
fail() { echo -e " ${RED}✗${RESET} $*"; log "ERROR: $*"; echo; echo -e "${RED}${BOLD}Bootstrap остановлен.${RESET} Лог: ${LOG_FILE}"; exit 1; }
|
||||
step() { echo; echo -e "${BLUE}${BOLD}▶${RESET} ${BOLD}$*${RESET}"; log "STEP: $*"; }
|
||||
|
||||
run_logged() {
|
||||
local desc="$1"
|
||||
shift
|
||||
local start_ts pid code spinner elapsed symbol i
|
||||
log "RUN: $*"
|
||||
start_ts="$(date +%s)"
|
||||
"$@" >> "$LOG_FILE" 2>&1 &
|
||||
pid=$!
|
||||
spinner=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
|
||||
i=0
|
||||
while kill -0 "$pid" 2>/dev/null; do
|
||||
elapsed=$(( $(date +%s) - start_ts ))
|
||||
symbol="${spinner[$((i % ${#spinner[@]}))]}"
|
||||
printf "\r ${CYAN}%s${RESET} %s... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "$symbol" "$desc" "$elapsed" "$LOG_FILE"
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
done
|
||||
wait "$pid"
|
||||
code=$?
|
||||
printf "\r%*s\r" 120 ""
|
||||
if [[ "$code" -eq 0 ]]; then
|
||||
ok "$desc"
|
||||
else
|
||||
fail "$desc"
|
||||
fi
|
||||
}
|
||||
|
||||
header() {
|
||||
clear 2>/dev/null || true
|
||||
echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
|
||||
echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Bootstrap Installer${RESET} ${CYAN}${BOLD}│${RESET}"
|
||||
echo -e "${CYAN}${BOLD}│${RESET} One-command KVM / libvirt / Cockpit / Web Panel setup ${CYAN}${BOLD}│${RESET}"
|
||||
echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||
echo
|
||||
echo -e "${GRAY}Repository:${RESET} ${REPO_URL}"
|
||||
echo -e "${GRAY}Auth/install user:${RESET} ${INSTALL_USER}"
|
||||
echo -e "${GRAY}Project dir:${RESET} ${PROJECT_DIR}"
|
||||
echo -e "${GRAY}Log:${RESET} ${LOG_FILE}"
|
||||
echo
|
||||
}
|
||||
|
||||
require_root() {
|
||||
[[ "$EUID" -eq 0 ]] || fail "Запусти от root: curl ... | sudo bash"
|
||||
}
|
||||
|
||||
free_mb_for_path() {
|
||||
local path="$1"
|
||||
mkdir -p "$path" 2>/dev/null || true
|
||||
df -Pm "$path" | awk 'NR==2 {print $4}'
|
||||
}
|
||||
|
||||
check_password_state() {
|
||||
local user="$1"
|
||||
local hash
|
||||
hash="$(getent shadow "$user" | cut -d: -f2 || true)"
|
||||
if [[ -z "$hash" || "$hash" == "!" || "$hash" == "*" || "$hash" == "!!" ]]; then
|
||||
warn "У пользователя ${user} пароль не задан или заблокирован. Для входа в панель выполни: passwd ${user}"
|
||||
else
|
||||
ok "У пользователя ${user} есть пароль для входа в панель"
|
||||
fi
|
||||
}
|
||||
|
||||
check_requirements() {
|
||||
step "Проверяем минимальные системные требования"
|
||||
if [[ "$SKIP_REQUIREMENTS" == "1" ]]; then
|
||||
warn "Проверка требований отключена через VIRTUALITY_SKIP_REQUIREMENTS=1"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local root_free var_free ram_mb cpu_cores
|
||||
root_free="$(free_mb_for_path /)"
|
||||
var_free="$(free_mb_for_path /var/lib 2>/dev/null || free_mb_for_path /)"
|
||||
ram_mb="$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo)"
|
||||
cpu_cores="$(nproc 2>/dev/null || echo 1)"
|
||||
|
||||
if (( root_free >= MIN_ROOT_FREE_MB )); then
|
||||
ok "Свободно на /: ${root_free} MB минимум ${MIN_ROOT_FREE_MB} MB"
|
||||
else
|
||||
fail "Недостаточно места на /: ${root_free} MB. Нужно минимум ${MIN_ROOT_FREE_MB} MB. Очисти диск: apt clean; journalctl --vacuum-time=3d"
|
||||
fi
|
||||
|
||||
if (( var_free >= MIN_VAR_FREE_MB )); then
|
||||
ok "Свободно для /var/lib: ${var_free} MB минимум ${MIN_VAR_FREE_MB} MB"
|
||||
else
|
||||
fail "Недостаточно места для VM-хранилища /var/lib: ${var_free} MB. Нужно минимум ${MIN_VAR_FREE_MB} MB"
|
||||
fi
|
||||
|
||||
if (( ram_mb >= MIN_RAM_MB )); then
|
||||
ok "RAM: ${ram_mb} MB минимум ${MIN_RAM_MB} MB"
|
||||
else
|
||||
warn "RAM: ${ram_mb} MB меньше рекомендуемых ${MIN_RAM_MB} MB. Установка возможна, но VM будут ограничены"
|
||||
fi
|
||||
|
||||
if (( cpu_cores >= MIN_CPU_CORES )); then
|
||||
ok "CPU cores: ${cpu_cores} минимум ${MIN_CPU_CORES}"
|
||||
else
|
||||
warn "CPU cores: ${cpu_cores} меньше рекомендуемых ${MIN_CPU_CORES}"
|
||||
fi
|
||||
|
||||
if grep -E -q '(vmx|svm)' /proc/cpuinfo; then
|
||||
ok "CPU virtualization vmx/svm найдена"
|
||||
else
|
||||
warn "CPU virtualization vmx/svm не найдена. Проверь BIOS/UEFI или настройки VPS"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_user() {
|
||||
if [[ "$INSTALL_USER" == "root" ]]; then
|
||||
ok "Пользователь авторизации: root"
|
||||
check_password_state root
|
||||
return 0
|
||||
fi
|
||||
|
||||
if id "$INSTALL_USER" >/dev/null 2>&1; then
|
||||
ok "Пользователь уже существует: ${INSTALL_USER}"
|
||||
else
|
||||
run_logged "Создан пользователь ${INSTALL_USER}" adduser --disabled-password --gecos "" "$INSTALL_USER"
|
||||
warn "Пароль для ${INSTALL_USER} не задан. После установки выполни: sudo passwd ${INSTALL_USER}"
|
||||
fi
|
||||
usermod -aG sudo "$INSTALL_USER" >> "$LOG_FILE" 2>&1 || true
|
||||
ok "Пользователь ${INSTALL_USER} добавлен в sudo"
|
||||
check_password_state "$INSTALL_USER"
|
||||
}
|
||||
|
||||
run_as_user() {
|
||||
if [[ "$INSTALL_USER" == "root" ]]; then
|
||||
bash -lc "$*"
|
||||
else
|
||||
sudo -H -u "$INSTALL_USER" bash -lc "$*"
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_project_dir() {
|
||||
mkdir -p "$PROJECT_BASE_DIR"
|
||||
if [[ "$INSTALL_USER" == "root" ]]; then
|
||||
chown root:root "$PROJECT_BASE_DIR"
|
||||
else
|
||||
chown "$INSTALL_USER:$INSTALL_USER" "$PROJECT_BASE_DIR"
|
||||
fi
|
||||
chmod 755 "$PROJECT_BASE_DIR"
|
||||
ok "Рабочая директория подготовлена: ${PROJECT_BASE_DIR}"
|
||||
}
|
||||
|
||||
header
|
||||
|
||||
step "Проверяем root и apt"
|
||||
require_root
|
||||
command -v apt >/dev/null 2>&1 || fail "apt не найден. Нужен Debian/Ubuntu"
|
||||
ok "root подтверждён"
|
||||
ok "apt найден"
|
||||
|
||||
check_requirements
|
||||
|
||||
step "Устанавливаем базовые пакеты"
|
||||
run_logged "apt update выполнен" apt update
|
||||
run_logged "Установлены sudo/git/curl/ca-certificates" apt install -y sudo git curl ca-certificates openssh-client nano htop
|
||||
|
||||
step "Создаём или проверяем пользователя"
|
||||
ensure_user
|
||||
|
||||
step "Готовим рабочую директорию в /opt"
|
||||
prepare_project_dir
|
||||
|
||||
step "Проверяем место после подготовки пользователя"
|
||||
root_free_after="$(free_mb_for_path /)"
|
||||
if (( root_free_after < MIN_ROOT_FREE_MB )); then
|
||||
fail "После подготовки пользователя свободного места стало мало: ${root_free_after} MB на /. Очисти диск и повтори установку"
|
||||
fi
|
||||
ok "Место после подготовки пользователя: ${root_free_after} MB на /"
|
||||
|
||||
step "Клонируем или обновляем репозиторий"
|
||||
if [[ -d "$PROJECT_DIR/.git" ]]; then
|
||||
run_logged "Репозиторий обновлён" run_as_user "cd '$PROJECT_DIR' && git reset --hard origin/main && git pull"
|
||||
else
|
||||
run_logged "Репозиторий склонирован" run_as_user "cd '$PROJECT_BASE_DIR' && git clone '$REPO_URL' source"
|
||||
fi
|
||||
|
||||
step "Запускаем основной установщик ноды"
|
||||
run_logged "Virtuality Node установлен" bash "$PROJECT_DIR/install_virtuality_node.sh"
|
||||
|
||||
step "Устанавливаем healthcheck-команду"
|
||||
run_logged "Healthcheck установлен" bash "$PROJECT_DIR/scripts/install_healthcheck_command.sh"
|
||||
|
||||
step "Устанавливаем консольный dashboard"
|
||||
run_logged "Console dashboard установлен" bash "$PROJECT_DIR/scripts/install_console_dashboard.sh"
|
||||
|
||||
step "Устанавливаем web-панель"
|
||||
VIRTUALITY_AUTH_USER="$INSTALL_USER" VIRTUALITY_WEB_PORT="$WEB_PORT" bash "$PROJECT_DIR/scripts/install_web_panel.sh" >> "$LOG_FILE" 2>&1 &
|
||||
web_pid=$!
|
||||
web_start="$(date +%s)"
|
||||
web_i=0
|
||||
web_spinner=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
|
||||
while kill -0 "$web_pid" 2>/dev/null; do
|
||||
web_elapsed=$(( $(date +%s) - web_start ))
|
||||
printf "\r ${CYAN}%s${RESET} Web-панель устанавливается... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "${web_spinner[$((web_i % ${#web_spinner[@]}))]}" "$web_elapsed" "$LOG_FILE"
|
||||
sleep 1
|
||||
web_i=$((web_i + 1))
|
||||
done
|
||||
wait "$web_pid"
|
||||
web_code=$?
|
||||
printf "\r%*s\r" 120 ""
|
||||
[[ "$web_code" -eq 0 ]] && ok "Web-панель установлена" || fail "Web-панель не установлена"
|
||||
|
||||
if [[ "$RUN_BRIDGE" == "1" ]]; then
|
||||
step "Настраиваем bridge br0"
|
||||
if [[ -z "$BRIDGE_IFACE" ]]; then
|
||||
BRIDGE_IFACE="$(ip route | awk '/default/ {print $5; exit}')"
|
||||
fi
|
||||
if [[ -z "$BRIDGE_IFACE" ]]; then
|
||||
warn "Не удалось определить интерфейс для br0. Пропускаю bridge setup"
|
||||
else
|
||||
warn "Bridge меняет сеть. Если SSH зависнет, нужен физический доступ или консоль провайдера."
|
||||
bash "$PROJECT_DIR/scripts/setup_bridge_br0.sh" "$BRIDGE_IFACE" static >> "$LOG_FILE" 2>&1 && ok "Bridge br0 настроен на ${BRIDGE_IFACE}" || warn "Bridge setup завершился с предупреждением. Проверь лог"
|
||||
fi
|
||||
else
|
||||
warn "Bridge br0 не настраивался автоматически. Запуск вручную: sudo bash scripts/setup_bridge_br0.sh INTERFACE static"
|
||||
fi
|
||||
|
||||
if [[ "$RUN_TEST_VM" == "1" ]]; then
|
||||
step "Создаём тестовую VM"
|
||||
run_logged "Тестовая VM создана" bash "$PROJECT_DIR/scripts/create_test_vm.sh" || warn "Тестовая VM не создана. Проверь ISO/bridge/log"
|
||||
fi
|
||||
|
||||
step "Финальная диагностика"
|
||||
if command -v vhealth >/dev/null 2>&1; then
|
||||
vhealth >> "$LOG_FILE" 2>&1 && ok "vhealth: OK" || warn "vhealth вернул warnings/errors. Проверь: sudo vhealth"
|
||||
else
|
||||
warn "Команда vhealth не найдена"
|
||||
fi
|
||||
|
||||
SERVER_IP="$(hostname -I | awk '{print $1}')"
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
|
||||
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Virtuality Bootstrap завершён${RESET} ${GREEN}${BOLD}│${RESET}"
|
||||
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||
echo
|
||||
echo -e "${BOLD}Cockpit:${RESET} https://${SERVER_IP}:9090"
|
||||
echo -e "${BOLD}Web Panel:${RESET} http://${SERVER_IP}:${WEB_PORT}"
|
||||
echo -e "${BOLD}Login:${RESET} ${INSTALL_USER} / пароль Linux-пользователя"
|
||||
echo -e "${BOLD}Project:${RESET} ${PROJECT_DIR}"
|
||||
echo -e "${BOLD}Log:${RESET} ${LOG_FILE}"
|
||||
echo
|
||||
echo -e "${BOLD}После установки:${RESET}"
|
||||
echo " passwd ${INSTALL_USER} # если пароль ещё не задан или заблокирован"
|
||||
echo " sudo vhealth"
|
||||
echo " cd ${PROJECT_DIR}"
|
||||
echo " ip -br a"
|
||||
echo
|
||||
echo -e "${YELLOW}Важно:${RESET} bridge br0 по умолчанию не включается в one-command режиме, чтобы не уронить SSH."
|
||||
echo "Для bridge: cd ${PROJECT_DIR} && sudo bash scripts/setup_bridge_br0.sh INTERFACE static"
|
||||
echo
|
||||
@@ -1,176 +1,122 @@
|
||||
# Архитектура Virtuality
|
||||
|
||||
Virtuality — одна нода: сервер с KVM/QEMU/libvirt, поверх которого работает панель на FastAPI. Панель не хранит собственную базу данных: источник правды о машинах — libvirt (`virsh`), о ноде — файлы в `/var/lib/virtuality/config`, о задачах — файлы в `/var/log/virtuality/operations`. Всё, что меняет сам сервер (службы, firewall, сеть, диски), делает один скрипт — `virtuality-ctl`, и панель вызывает его же.
|
||||
Virtuality — это платформа виртуализации в стиле Proxmox, но с более компактной и контролируемой архитектурой.
|
||||
|
||||
## Цель
|
||||
|
||||
Создать собственную систему управления виртуальными машинами на базе стабильных Linux-компонентов:
|
||||
|
||||
- KVM;
|
||||
- QEMU;
|
||||
- libvirt;
|
||||
- Cockpit на раннем этапе;
|
||||
- собственная веб-панель на следующих этапах.
|
||||
|
||||
## Базовая схема
|
||||
|
||||
```text
|
||||
Браузер
|
||||
│ 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
|
||||
Веб-интерфейс
|
||||
↓
|
||||
Backend API
|
||||
↓
|
||||
Node services
|
||||
↓
|
||||
libvirt / QEMU / KVM
|
||||
↓
|
||||
Host OS / Storage / Network
|
||||
```
|
||||
|
||||
---
|
||||
## Слои системы
|
||||
|
||||
## Код панели: `web/`
|
||||
### 1. Web UI
|
||||
|
||||
| Модуль | Назначение |
|
||||
|---|---|
|
||||
| `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`, шрифт и иконки. Панель не ходит в интернет. |
|
||||
Будущая пользовательская панель Virtuality.
|
||||
|
||||
Правило для роутов модулей: POST-маршруты вида `/vm/{name}/<модуль>/<действие>` — минимум четыре сегмента, иначе их перехватит общий `@app.post("/vm/{name}/{action}")`.
|
||||
Задачи:
|
||||
|
||||
### Фоновые задачи
|
||||
- список виртуальных машин;
|
||||
- создание VM;
|
||||
- управление питанием;
|
||||
- консоль;
|
||||
- хранилища;
|
||||
- сеть;
|
||||
- бэкапы;
|
||||
- пользователи и роли.
|
||||
|
||||
Долгие операции (создание машины, конвертация образа, скачивание, снимок, копия, клон, спящий режим) выполняются в потоках и записываются в `/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.
|
||||
### 2. Backend API
|
||||
|
||||
---
|
||||
Единая точка управления нодой.
|
||||
|
||||
## Управление нодой: `scripts/virtuality-ctl`
|
||||
Задачи:
|
||||
|
||||
Один bash-скрипт, устанавливается в `/usr/local/bin`. Панель (через `nodectl.py`) и администратор в терминале делают одно и то же:
|
||||
- принимать команды от Web UI;
|
||||
- валидировать действия;
|
||||
- работать с libvirt;
|
||||
- вести журнал событий;
|
||||
- отдавать состояние VM, storage и сети.
|
||||
|
||||
- **`reconfigure`** — читает `/var/lib/virtuality/config/web.env` и пересоздаёт systemd-юниты, logrotate, `/etc/default/libvirt-guests`, правила ufw; переводит VNC старых машин на `127.0.0.1`; перезапускает панель.
|
||||
- **`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.
|
||||
### 3. Node services
|
||||
|
||||
Журнал действий: `/var/log/virtuality/virtuality-ctl.log`.
|
||||
Системные сервисы Virtuality.
|
||||
|
||||
---
|
||||
Задачи:
|
||||
|
||||
## Службы systemd
|
||||
- диагностика ноды;
|
||||
- обновления;
|
||||
- резервное копирование;
|
||||
- мониторинг;
|
||||
- фоновые задачи.
|
||||
|
||||
Создаются `virtuality-ctl reconfigure`:
|
||||
### 4. libvirt / QEMU / KVM
|
||||
|
||||
| Юнит | Что делает |
|
||||
|---|---|
|
||||
| `virtuality-web.service` | Панель: `uvicorn app:app` от root из `/opt/virtuality/web` с venv `/opt/virtuality/venv`; `Restart=always`. |
|
||||
| `virtuality-redirect.service` | Только при TLS: HTTP → HTTPS. |
|
||||
| `virtuality-network.service` | При загрузке заново применяет пробросы портов (`restore_network.py`). |
|
||||
| `virtuality-auto-update.timer` / `.service` | Ночная проверка обновлений (`scripts/auto_update_check.sh`), 04:00 ± 30 минут; включён при `VIRTUALITY_AUTO_UPDATE=1`. |
|
||||
| `virtuality-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
|
||||
/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 панели
|
||||
/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` целиком. Память «спящих» машин libvirt хранит в `/var/lib/libvirt/qemu/save`.
|
||||
## Принципы
|
||||
|
||||
Код и служебные файлы:
|
||||
- исходный код отдельно от runtime-данных;
|
||||
- диски VM и ISO не хранятся в Git;
|
||||
- конфиги должны быть прозрачными;
|
||||
- установщик должен быть повторяемым;
|
||||
- сетевые изменения делаются осторожно, чтобы не уронить SSH;
|
||||
- архитектура документируется до активной разработки.
|
||||
|
||||
```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
|
||||
|
||||
---
|
||||
На этапе v0.1 создаётся фундамент:
|
||||
|
||||
## Обновления
|
||||
|
||||
Ноды обновляются из `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, когда будет подключён раннер.
|
||||
- установщик ноды;
|
||||
- установка KVM/QEMU/libvirt;
|
||||
- установка Cockpit;
|
||||
- storage pools;
|
||||
- структура проекта;
|
||||
- GitHub-синхронизация;
|
||||
- базовая диагностика.
|
||||
|
||||
@@ -1,140 +1,104 @@
|
||||
# Первая виртуальная машина
|
||||
# Первая тестовая виртуальная машина
|
||||
|
||||
Всё в этом документе делается через панель в браузере — терминал не понадобится. Считаем, что Virtuality установлена и мастер настройки пройден (см. README).
|
||||
Этот документ фиксирует проверку первого рабочего сценария Virtuality: сеть `br0`, libvirt storage pools и запуск тестовой VM.
|
||||
|
||||
Сначала — самый быстрый путь: готовая Ubuntu за минуту. Потом — установка Windows 11 с ISO, экран машины, доступ снаружи, снимки и резервные копии.
|
||||
## Требования
|
||||
|
||||
---
|
||||
Перед созданием VM должны быть готовы:
|
||||
|
||||
## 1. Быстрая машина из каталога: Ubuntu Server 26.04
|
||||
- KVM/QEMU;
|
||||
- libvirt;
|
||||
- Cockpit;
|
||||
- storage pools `virtuality-images` и `virtuality-iso`;
|
||||
- bridge `br0`;
|
||||
- default route через `br0`.
|
||||
|
||||
Готовые (облачные) образы — это уже установленная система без пароля. Панель скачивает образ, делает из него диск нужного размера и через cloud-init настраивает первый вход: пользователя, пароль, SSH-ключ.
|
||||
|
||||
### Скачать образ
|
||||
|
||||
1. Откройте **Образы ОС** → вкладка **Готовые системы**.
|
||||
2. В карточке **Каталог готовых систем** найдите **Ubuntu Server 26.04 LTS** (помечена «Рекомендуем») и нажмите **Скачать**. Сервер скачает образ с сайта Ubuntu сам и проверит контрольную сумму; ход виден в **Задачах** и на этой же странице.
|
||||
3. Когда образ отмечен «Загружен», рядом появится кнопка **Создать машину** — она открывает мастер с уже выбранным образом.
|
||||
|
||||
В каталоге также Ubuntu Server 24.04 LTS, Debian 13 и Alpine Linux 3.23. Свой образ (`.qcow2`, `.img`, `.raw`, `.img.xz`) можно загрузить с компьютера перетаскиванием или скачать по ссылке в карточке **Скачать по ссылке**.
|
||||
|
||||
### Создать машину
|
||||
|
||||
Шаг **1. Название и система**:
|
||||
|
||||
- **Название машины** — латиница, цифры, точка, дефис, подчёркивание (например, `ubuntu-1`). Это же имя станет именем компьютера внутри системы.
|
||||
- Источник **Готовая система** уже выбран, образ подставлен.
|
||||
- Переключатель **Настроить первый вход** должен быть включён: у готовых образов нет пароля, без этого шага в машину не войти.
|
||||
- **Пользователь** — панель подставляет стандартного пользователя образа (`ubuntu`, у Debian — `debian`, у Alpine — `alpine`); можно указать свой. Он получит права администратора (`sudo`).
|
||||
- **Пароль** — не короче 6 символов.
|
||||
- **SSH-ключ** — необязательно; вставьте содержимое `~/.ssh/id_ed25519.pub`. Если указать только ключ без пароля, вход по SSH будет только по ключу.
|
||||
|
||||
Шаг **2. Мощность**: выберите профиль — **Мини** (1 ядро, 1 ГБ, 10 ГБ), **Стандарт** (2 ядра, 4 ГБ, 40 ГБ), **Мощная** (4 ядра, 8 ГБ, 80 ГБ) — или задайте ядра, память и диск сами. Диск образа увеличивается до выбранного размера, а система при первой загрузке сама расширит файловую систему.
|
||||
|
||||
Шаг **3. Сеть**: **Автоматически** — машина получит постоянный адрес `192.168.100.x` в отдельной сети и выйдет в интернет через сервер; снаружи её не видно, пока вы не откроете доступ. **В локальной сети** (если в мастере настройки выбран мост) — машина станет устройством вашей домашней сети и получит адрес от роутера.
|
||||
|
||||
Справа в **Итого** проверьте сводку и нажмите **Создать машину**. Панель откроет страницу задачи с живым журналом. Обычно в пределах минуты появится «Машина создана. Вход: ubuntu по паролю. Адрес в сети машин: 192.168.100.x».
|
||||
|
||||
### Войти
|
||||
|
||||
Откройте страницу машины (из **Обзора**) → **Открыть экран** и войдите с указанными пользователем и паролем. В машину сразу ставится гостевой агент, поэтому её адрес виден на странице машины и в **Сети и доступе** — по нему можно зайти по SSH из локальной сети (если выбран мост) или после проброса порта (см. ниже).
|
||||
|
||||
---
|
||||
|
||||
## 2. Windows 11 с установочного ISO
|
||||
|
||||
### Загрузить ISO
|
||||
|
||||
1. Скачайте ISO Windows 11 с сайта Microsoft на свой компьютер.
|
||||
2. **Образы ОС** → вкладка **Установочные образы** → перетащите файл в окно загрузки. Или вставьте прямую ссылку на `.iso` в карточку **Скачать по ссылке** — сервер скачает его сам.
|
||||
|
||||
### Создать машину
|
||||
|
||||
1. **Создать машину** → шаг 1: название (например, `win11`), источник **Установить с диска**, выберите ISO.
|
||||
2. **Что будете устанавливать** → **Windows 11 / Server 2025**. Панель сама включит UEFI и виртуальный TPM 2.0 (без него Windows 11 отказывается ставиться), подберёт диск SATA и сетевую карту Intel — драйверы во время установки не нужны.
|
||||
3. Шаг 2: профиль **Мощная** (4 ядра, 8 ГБ, 80 ГБ) — Windows 11 требует минимум 4 ГБ памяти и 64 ГБ диска.
|
||||
4. Шаг 3: сеть — как и для Linux.
|
||||
5. **Создать машину**. Задача завершится за несколько секунд — машина создана и запущена с установочного диска.
|
||||
|
||||
### Установить систему
|
||||
|
||||
1. На странице машины нажмите **Открыть экран**. Сразу после запуска Windows показывает «Press any key to boot from CD or DVD» — нажмите любую клавишу в окне экрана, иначе загрузка с диска пропустится. Если пропустили — **Перезагрузить** на странице машины и попробовать снова.
|
||||
2. Дальше установка идёт как на обычном компьютере. Диск в установщике будет пустым — выберите его целиком.
|
||||
3. После установки машина перезагрузится уже с диска: для машин из 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 /opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py web01 "ночная копия"
|
||||
sudo vhealth
|
||||
ip -br a
|
||||
ip route
|
||||
virsh pool-list --all
|
||||
```
|
||||
|
||||
---
|
||||
Нормальная сеть:
|
||||
|
||||
## Что дальше
|
||||
```text
|
||||
enp2s0 UP
|
||||
br0 UP 10.0.0.200/24
|
||||
default via 10.0.0.1 dev br0
|
||||
```
|
||||
|
||||
- **Клонировать** (меню на странице машины) — точная копия с новым именем и своим адресом, удобно для тестов.
|
||||
- **Спящий режим** — сохранить память на диск и выключить; после запуска машина продолжит с того же места.
|
||||
- **Автозапуск** — переключатель на странице машины: машина включится сама после перезагрузки сервера.
|
||||
- **Ресурсы** — процессор и память можно поменять, когда машина выключена; диск — увеличить в любой момент во вкладке **Диски и загрузка**.
|
||||
- Раздел **Помощь** в панели повторяет главное из этого документа.
|
||||
## Создание тестовой VM
|
||||
|
||||
```bash
|
||||
cd ~/virtuality
|
||||
git pull
|
||||
sudo bash scripts/create_test_vm.sh
|
||||
```
|
||||
|
||||
По умолчанию создаётся VM:
|
||||
|
||||
```text
|
||||
Name: test-alpine
|
||||
RAM: 1024 MB
|
||||
CPU: 1 vCPU
|
||||
Disk: 8 GB qcow2
|
||||
ISO: Alpine Linux
|
||||
Network: br0
|
||||
Graphics: VNC
|
||||
```
|
||||
|
||||
Можно задать имя:
|
||||
|
||||
```bash
|
||||
sudo bash scripts/create_test_vm.sh test-alpine-2
|
||||
```
|
||||
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
virsh list --all
|
||||
sudo virsh vncdisplay test-alpine
|
||||
sudo vhealth
|
||||
```
|
||||
|
||||
Если `vncdisplay` показывает `:0`, значит порт VNC — `5900`.
|
||||
|
||||
Если показывает `:1`, значит порт VNC — `5901`.
|
||||
|
||||
## Доступ через Cockpit
|
||||
|
||||
Открыть:
|
||||
|
||||
```text
|
||||
https://SERVER_IP:9090
|
||||
```
|
||||
|
||||
Дальше:
|
||||
|
||||
```text
|
||||
Virtual machines → test-alpine → Console
|
||||
```
|
||||
|
||||
## Что считается успешным результатом
|
||||
|
||||
Этап считается пройденным, если:
|
||||
|
||||
- VM появилась в `virsh list --all`;
|
||||
- VM запущена;
|
||||
- консоль открывается в Cockpit или через VNC;
|
||||
- VM подключена к bridge `br0`;
|
||||
- `sudo vhealth` не показывает критических ошибок по Virtuality.
|
||||
|
||||
## Следующий этап
|
||||
|
||||
После первой тестовой VM можно переходить к разработке собственной панели управления Virtuality:
|
||||
|
||||
- список VM;
|
||||
- статусы VM;
|
||||
- запуск/остановка;
|
||||
- просмотр storage pools;
|
||||
- создание VM через UI;
|
||||
- базовый журнал событий.
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
# Установочный образ Virtuality
|
||||
|
||||
Загрузочный ISO ставит Virtuality на чистый сервер «с нуля», как Proxmox: записали на флешку, загрузились, ответили на три экрана — после перезагрузки панель уже открыта в браузере, а мастер настройки показывает, как ставятся остальные компоненты.
|
||||
|
||||
Образ собирается из официального, ничем не изменённого **Ubuntu Server 26.04 LTS live-server ISO** (amd64 или arm64) и дополняется:
|
||||
|
||||
```text
|
||||
/autoinstall.yaml # сценарий установщика Ubuntu (subiquity autoinstall)
|
||||
/virtuality/source # исходники Virtuality нужной версии (git-репозиторий)
|
||||
/virtuality/source/wheels # Python-колёса панели и pip — для установки без интернета
|
||||
/virtuality/image.env # параметры сборки: версия, порт панели, автообновление
|
||||
/virtuality/virtuality-firstboot.service # служба первой загрузки
|
||||
/boot/grub/grub.cfg # меню «Установить Virtuality X.Y.Z» с фирменной темой
|
||||
/boot/grub/themes/virtuality/ # тема GRUB
|
||||
```
|
||||
|
||||
Загрузочные записи BIOS и UEFI берутся из оригинального ISO без изменений.
|
||||
|
||||
---
|
||||
|
||||
## Где взять готовый образ
|
||||
|
||||
Образ собирается вручную командой `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,77 +1,95 @@
|
||||
# Требования
|
||||
# Системные требования Virtuality
|
||||
|
||||
## Коротко
|
||||
Virtuality — это серверная система управления виртуализацией на базе KVM/QEMU/libvirt/Cockpit и собственной web-панели.
|
||||
|
||||
Подойдёт почти любой компьютер или мини-ПК не старше 10 лет с проводной сетью и включённой в BIOS/UEFI виртуализацией. Для одной-двух небольших машин достаточно 4 ГБ памяти; для Windows и нескольких машин сразу — 16 ГБ и SSD.
|
||||
## Минимальные требования
|
||||
|
||||
## Операционная система
|
||||
Подходят только для тестового стенда и одной-двух лёгких VM.
|
||||
|
||||
Virtuality работает на:
|
||||
```text
|
||||
CPU: 2 ядра с аппаратной виртуализацией Intel VT-x / AMD-V
|
||||
RAM: 4 GB
|
||||
Диск /: минимум 8 GB свободно
|
||||
Диск под /var/lib/virtuality: минимум 20 GB свободно
|
||||
OS: Ubuntu Server 24.04 LTS / Debian-like с apt
|
||||
Network: один проводной интерфейс
|
||||
```
|
||||
|
||||
- **Ubuntu Server 26.04 LTS** — основа установочного ISO, рекомендуется;
|
||||
- **Ubuntu Server 24.04 LTS**;
|
||||
- **Debian 13**.
|
||||
## Рекомендуемые требования
|
||||
|
||||
Другие системы на базе Debian/Ubuntu с `apt` скорее всего заработают, но не проверяются. Нужен systemd и netplan (для режима «домашняя сеть»). Установка с ISO делает всё сама; на готовую систему Virtuality ставится скриптом `install.sh` — ставьте её на чистый сервер, а не на рабочий компьютер.
|
||||
Для нормальной работы нескольких VM.
|
||||
|
||||
## Процессор и память
|
||||
```text
|
||||
CPU: 4+ ядра с Intel VT-x / AMD-V
|
||||
RAM: 16+ GB
|
||||
Диск: 100+ GB SSD/NVMe под /var/lib/virtuality
|
||||
Network: проводной интерфейс 1 Gbit/s+
|
||||
OS: Ubuntu Server 24.04 LTS
|
||||
```
|
||||
|
||||
| | Минимум | Рекомендуется |
|
||||
|---|---|---|
|
||||
| Процессор | 2 ядра x86_64 с Intel VT-x / AMD-V | 4+ ядер |
|
||||
| Память | 4 ГБ | 16 ГБ и больше |
|
||||
| Системный диск | 8 ГБ свободно на `/` | SSD |
|
||||
| Диск под машины | 20 ГБ свободно в `/var/lib` | 100+ ГБ SSD/NVMe, лучше отдельный диск |
|
||||
| Сеть | один проводной интерфейс | 1 Гбит/с |
|
||||
## Проверки установщика
|
||||
|
||||
Как считать память: сервер и панель занимают около 1 ГБ, остальное делят машины. Ubuntu Server внутри машины — от 1 ГБ, Windows 11 — от 4 ГБ (лучше 8). Диск машины занимает место постепенно, по мере заполнения, но резервные копии и снимки требуют запаса: планируйте хотя бы вдвое больше, чем суммарный размер дисков машин.
|
||||
`bootstrap.sh` и `install_virtuality_node.sh` проверяют:
|
||||
|
||||
**Виртуализация должна быть включена в BIOS/UEFI** (Intel VT-x / Virtualization Technology, AMD-V / SVM Mode). Без неё машины будут работать через медленную программную эмуляцию; панель предупредит об этом в разделе «Сервер», а `sudo vhealth` покажет ошибку `/dev/kvm missing`.
|
||||
- наличие root-прав;
|
||||
- наличие `apt`;
|
||||
- свободное место на `/`;
|
||||
- свободное место для `/var/lib`;
|
||||
- объём RAM;
|
||||
- количество CPU cores;
|
||||
- наличие флага аппаратной виртуализации `vmx` или `svm`.
|
||||
|
||||
Внутри другой виртуальной машины или на VPS Virtuality работает, только если включена вложенная виртуализация (nested virtualization).
|
||||
|
||||
## ARM64
|
||||
|
||||
Платы и серверы на ARM64 (Raspberry Pi 4/5, Orange Pi 5, серверы с UEFI) поддерживаются: панель определяет профиль, ставит `qemu-system-arm` и прошивку UEFI для ARM. Гости на таких серверах — **только ARM64** (в каталоге готовых систем есть образы aarch64 для Ubuntu, Debian и Alpine); образы x86_64 на них не запускаются. Установка Windows на ARM64 не проверялась.
|
||||
|
||||
Для Raspberry Pi и Orange Pi желательно 8 ГБ памяти и загрузка с SSD/NVMe. Установочный ISO для них не подходит (они грузятся иначе) — используйте штатный Ubuntu Server для платы и `install.sh`. ARM64-ISO предназначен для серверов с UEFI.
|
||||
|
||||
## Сеть
|
||||
|
||||
- Проводное подключение к роутеру. По Wi-Fi сервер работать может, но режим «домашняя сеть» (мост) недоступен — только NAT.
|
||||
- Доступ в интернет при установке: первая загрузка после ISO и `install.sh` скачивают пакеты из репозиториев Ubuntu/Debian (сотни мегабайт). Панель после установки в интернет не ходит, кроме проверки обновлений и скачивания образов по вашей команде.
|
||||
- Порты на сервере: `8088` (панель по HTTP), `8443` (панель по HTTPS), `22` (SSH). Открывать их на роутере наружу не обязательно и без HTTPS не рекомендуется.
|
||||
- Для сети NAT машинам выделяется подсеть `192.168.100.0/24` — она не должна совпадать с вашей домашней сетью. Если роутер раздаёт именно `192.168.100.x`, выбирайте режим «домашняя сеть» или смените подсеть роутера.
|
||||
- Если хотите, чтобы сервисы машин были доступны из интернета, потребуется проброс портов на роутере на адрес сервера (и «белый» адрес от провайдера).
|
||||
|
||||
## Что проверяет установщик
|
||||
|
||||
`install.sh` и `install_virtuality_node.sh` перед установкой проверяют права root, наличие `apt`, свободное место на `/` и в `/var/lib`, память, количество ядер и флаг виртуализации `vmx`/`svm`. Пороги можно изменить переменными окружения:
|
||||
## Значения по умолчанию
|
||||
|
||||
```text
|
||||
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://git.stackworks.ru/viktor/virtuality/raw/branch/main/install.sh | VIRTUALITY_MIN_VAR_FREE_MB=8192 bash
|
||||
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/bootstrap.sh | sudo VIRTUALITY_MIN_VAR_FREE_MB=8192 bash
|
||||
```
|
||||
|
||||
Установка с ISO эти пороги не применяет: диск и память проверяются мастером настройки, который покажет предупреждение, но не остановит установку.
|
||||
Полностью отключить preflight-проверки можно так:
|
||||
|
||||
Освободить место перед установкой на существующей системе:
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/bootstrap.sh | sudo VIRTUALITY_SKIP_REQUIREMENTS=1 bash
|
||||
```
|
||||
|
||||
Использовать отключение проверок стоит только для тестов. Для реального сервера это плохая идея: VM быстро съедают диск.
|
||||
|
||||
## Как быстро проверить место
|
||||
|
||||
```bash
|
||||
df -h
|
||||
sudo apt clean && sudo apt autoremove -y
|
||||
df -i
|
||||
du -xh / --max-depth=1 2>/dev/null | sort -h
|
||||
```
|
||||
|
||||
## Очистка перед установкой
|
||||
|
||||
```bash
|
||||
sudo apt clean
|
||||
sudo apt autoclean
|
||||
sudo apt autoremove -y
|
||||
sudo rm -rf /var/lib/apt/lists/*
|
||||
sudo rm -rf /tmp/* /var/tmp/*
|
||||
sudo journalctl --vacuum-time=3d
|
||||
```
|
||||
|
||||
## Браузер
|
||||
## Где Virtuality хранит данные
|
||||
|
||||
Панель работает в актуальных Chrome, Firefox, Safari и Edge, в том числе на телефоне. Экран машины (noVNC) требует поддержки WebSocket — она есть во всех перечисленных. При включённом HTTPS сертификат выпускается самим сервером, поэтому браузер один раз спросит, доверять ли ему.
|
||||
```text
|
||||
/opt/virtuality # системные конфиги и установленная web-панель
|
||||
/home/<user>/virtuality # GitHub-репозиторий проекта
|
||||
/var/lib/virtuality/iso # ISO-образы
|
||||
/var/lib/virtuality/images # qcow2-диски VM
|
||||
/var/lib/virtuality/backups # будущие backup VM
|
||||
/var/log/virtuality # логи установки и диагностики
|
||||
```
|
||||
|
||||
@@ -1,49 +1,115 @@
|
||||
# Планы
|
||||
# Roadmap Virtuality
|
||||
|
||||
Virtuality — панель для одного домашнего сервера. Этот документ честно фиксирует, что уже есть в версии 0.11 и что планируется дальше. Порядок в списке планов — примерный приоритет, сроков нет. Подробный журнал изменений по версиям — в `updates/versions.json` и в разделе «Обновления» панели.
|
||||
Документ фиксирует направление разработки, чтобы проект не превратился в хаотичный комбайн.
|
||||
|
||||
## Сделано в 0.11
|
||||
## v0.1 — Фундамент ноды
|
||||
|
||||
**Установка и настройка**
|
||||
Цель: получить рабочую базу для запуска виртуализации.
|
||||
|
||||
- Установочный 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-синхронизации;
|
||||
- документация проекта.
|
||||
|
||||
- Создание с ISO с выбором типа системы: Linux, Windows 11 / Server 2025 (UEFI + TPM), Windows 10, другая.
|
||||
- Машина из готового образа за минуту: каталог (Ubuntu 26.04/24.04, Debian 13, Alpine), скачивание по ссылке, cloud-init с пользователем, паролем и SSH-ключом, гостевой агент.
|
||||
- Снимки (до 8 на машину), резервные копии с восстановлением, клонирование, увеличение диска, пауза и спящий режим, заметки, живая нагрузка.
|
||||
- Экран машины в браузере только через панель (VNC закрыт снаружи).
|
||||
## v0.2 — Сеть и диагностика
|
||||
|
||||
**Сеть и сервер**
|
||||
Цель: безопасно подготовить сетевую основу.
|
||||
|
||||
- Сеть NAT с постоянными адресами, пробросы портов с проверкой и восстановлением после перезагрузки, домашняя сеть через мост `br0`.
|
||||
- HTTPS для панели, стабильный канал обновлений и ранний доступ, откат, ежедневное обслуживание, отчёт для поддержки, архив настроек.
|
||||
- Убрано: Cockpit, консольный dashboard, эмуляция ARM64 на x86, загрузка по PXE, старые вспомогательные скрипты.
|
||||
Задачи:
|
||||
|
||||
## Сделано в 0.12
|
||||
- диагностический скрипт ноды;
|
||||
- определение активного сетевого интерфейса;
|
||||
- безопасная настройка bridge `br0`;
|
||||
- резервная копия сетевых конфигов перед изменением;
|
||||
- откат сетевой конфигурации;
|
||||
- улучшение логов установщика.
|
||||
|
||||
- Резервные копии по расписанию: каждый день или раз в неделю, все машины или отмеченные, хранение последних N автокопий. Копии делает отдельная служба сервера, перезапуск панели их не прерывает.
|
||||
## v0.3 — Прототип панели
|
||||
|
||||
## Планируется
|
||||
Цель: начать собственную панель управления.
|
||||
|
||||
- **Копии на внешний диск или сетевую папку** — сейчас копии лежат только в `/var/lib/virtuality/backups`.
|
||||
- **Контейнеры LXC** — лёгкие Linux-контейнеры рядом с машинами, как в Proxmox.
|
||||
- **Проброс устройств (PCI/USB passthrough)** — видеокарты, USB-ключи, диски целиком в машину.
|
||||
- **Второй сервер (кластер из двух нод)** — общий список машин, перенос машины между серверами, копии на соседний сервер.
|
||||
- **Пользователи и роли** — сейчас в панель входит один администратор; нужны дополнительные пользователи с ограниченными правами.
|
||||
- **Уведомления** — о завершении задач, ошибках, нехватке места и доступных обновлениях (почта, Telegram).
|
||||
- **Шаблоны машин** — сохранить настроенную машину как шаблон и создавать из него новые.
|
||||
- **Дополнительные диски и сети у машины** — второй диск, вторая сетевая карта, изменение сети существующей машины.
|
||||
- **Сертификат Let's Encrypt** — вместо самоподписанного, для доступа к панели по доменному имени.
|
||||
- **История нагрузки** — графики процессора, памяти и диска за день и неделю по серверу и машинам.
|
||||
- **Проверка обновлений образов каталога** — предлагать обновить скачанный облачный образ, когда вышел новый.
|
||||
Задачи:
|
||||
|
||||
## Не планируется
|
||||
- лёгкая веб-панель;
|
||||
- авторизация;
|
||||
- список виртуальных машин;
|
||||
- статус VM;
|
||||
- старт/стоп/перезагрузка VM через libvirt;
|
||||
- отображение storage pools;
|
||||
- базовый журнал событий.
|
||||
|
||||
- Многосерверные кластеры с общим хранилищем, Ceph, живая миграция — это уровень Proxmox для дата-центров, не для дома.
|
||||
- Поддержка других гипервизоров, кроме KVM/QEMU/libvirt.
|
||||
- Гости x86_64 на ARM64-серверах через эмуляцию — слишком медленно, чтобы этим пользоваться.
|
||||
## 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;
|
||||
- базовая безопасность;
|
||||
- документация;
|
||||
- обновлятор.
|
||||
|
||||
|
До Ширина: | Высота: | Размер: 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 |
@@ -1,31 +0,0 @@
|
||||
#cloud-config
|
||||
# Virtuality installer configuration for Ubuntu Server 24.04 (subiquity autoinstall).
|
||||
# build-iso.sh puts this file at /autoinstall.yaml on the ISO.
|
||||
# @@VARIABLES@@ are substituted at build time.
|
||||
autoinstall:
|
||||
version: 1
|
||||
# The installer asks only for these screens; everything else is automated.
|
||||
interactive-sections:
|
||||
@@INTERACTIVE_SECTIONS@@
|
||||
locale: @@LOCALE@@
|
||||
keyboard:
|
||||
layout: @@KEYBOARD@@
|
||||
timezone: @@TIMEZONE@@
|
||||
source:
|
||||
id: ubuntu-server
|
||||
ssh:
|
||||
install-server: true
|
||||
allow-pw: true
|
||||
@@IDENTITY@@
|
||||
storage:
|
||||
layout:
|
||||
name: lvm
|
||||
sizing-policy: all
|
||||
late-commands:
|
||||
- mkdir -p /target/opt/virtuality /target/etc/virtuality
|
||||
- cp -a /cdrom/virtuality/source /target/opt/virtuality/source
|
||||
- chown -R root:root /target/opt/virtuality
|
||||
- chmod -R u+w /target/opt/virtuality/source
|
||||
- cp /cdrom/virtuality/image.env /target/etc/virtuality/image.env
|
||||
- cp /cdrom/virtuality/virtuality-firstboot.service /target/etc/systemd/system/virtuality-firstboot.service
|
||||
- curtin in-target --target=/target -- systemctl enable virtuality-firstboot.service
|
||||
@@ -1,275 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ==========================================================
|
||||
# Virtuality ISO builder
|
||||
# Remasters the official Ubuntu Server LTS live ISO (26.04 by default) into a
|
||||
# Virtuality installer: Ubuntu autoinstall + bundled Virtuality
|
||||
# source and Python wheels + first boot setup service.
|
||||
#
|
||||
# Usage: image/build-iso.sh [options] (see --help)
|
||||
# Needs: xorriso, git, curl, python3 with pip, sha256sum.
|
||||
# Does not need root.
|
||||
# ==========================================================
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
IMAGE_DIR="${REPO_ROOT}/image"
|
||||
|
||||
ARCH="amd64"
|
||||
UBUNTU_SERIES="26.04"
|
||||
UBUNTU_ISO=""
|
||||
CACHE_DIR="${VIRTUALITY_ISO_CACHE:-${REPO_ROOT}/.cache/iso}"
|
||||
OUTPUT_DIR="${REPO_ROOT}/dist"
|
||||
GIT_REF="HEAD"
|
||||
REPO_URL="https://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")"
|
||||
@@ -1,19 +0,0 @@
|
||||
[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
|
||||
@@ -1,12 +0,0 @@
|
||||
# Тема загрузочного меню
|
||||
|
||||
`theme.txt` — тема GRUB (gfxmenu) для установочного ISO, `background.png` — фон 1024×768,
|
||||
`select_*.png` — подсветка выбранного пункта.
|
||||
|
||||
Фон рендерится из `background.html` (тот же стиль, что у страницы входа панели):
|
||||
|
||||
```bash
|
||||
NODE_PATH=$(npm root -g) node tests/render_html.mjs image/grub/background.html image/grub/background.png 1024 768
|
||||
```
|
||||
|
||||
Пиксмапы подсветки — однотонные RGBA 6×6 (110, 96, 255, 150).
|
||||
@@ -1,19 +0,0 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
html,body{margin:0;width:1024px;height:768px;overflow:hidden;font-family:Inter,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
|
||||
body{background:radial-gradient(900px 520px at 8% 0%, rgba(123,107,255,.55), transparent 60%), radial-gradient(760px 520px at 100% 100%, rgba(47,115,240,.45), transparent 55%), #0b0a1f;color:#fff;position:relative}
|
||||
.grid{position:absolute;inset:0;background-image:linear-gradient(rgba(255,255,255,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.045) 1px,transparent 1px);background-size:48px 48px;-webkit-mask-image:radial-gradient(ellipse at 30% 30%,#000 15%,transparent 72%)}
|
||||
.brand{position:absolute;left:72px;top:64px;display:flex;align-items:center;gap:18px}
|
||||
.mark{width:64px;height:64px;border-radius:18px;background:linear-gradient(135deg,#7b6bff 0%,#5a48f5 45%,#2f73f0 100%);box-shadow:0 14px 40px -10px rgba(90,72,245,.9), inset 0 1px 0 rgba(255,255,255,.3);display:grid;place-items:center}
|
||||
.mark svg{width:38px;height:38px}
|
||||
.name{font-size:38px;font-weight:700;letter-spacing:-.02em;line-height:1}
|
||||
.name small{display:block;margin-top:8px;font-size:16px;font-weight:500;color:rgba(255,255,255,.62);letter-spacing:0}
|
||||
.tag{position:absolute;left:72px;top:176px;font-size:30px;font-weight:600;letter-spacing:-.02em;line-height:1.25;max-width:620px}
|
||||
.tag span{background:linear-gradient(90deg,#c4bcff,#8fb8ff);-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.foot{position:absolute;left:72px;bottom:56px;color:rgba(255,255,255,.5);font-size:15px}
|
||||
.hint{position:absolute;right:72px;bottom:56px;color:rgba(255,255,255,.5);font-size:15px;text-align:right}
|
||||
</style></head><body><div class="grid"></div>
|
||||
<div class="brand"><div class="mark"><svg viewBox="0 0 24 24" fill="none"><path d="M4.5 5.5 12 19l7.5-13.5" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M9 5.5h6" stroke="#fff" stroke-opacity=".55" stroke-width="2.6" stroke-linecap="round"/></svg></div><div class="name">Virtuality<small>Платформа виртуализации</small></div></div>
|
||||
<div class="tag">Ваши серверы.<br><span>Под полным контролем.</span></div>
|
||||
<div class="foot">Установщик · KVM · QEMU · libvirt</div>
|
||||
<div class="hint">↑ ↓ — выбор пункта, Enter — запуск</div>
|
||||
</body></html>
|
||||
|
До Ширина: | Высота: | Размер: 151 KiB |
|
До Ширина: | Высота: | Размер: 75 B |
|
До Ширина: | Высота: | Размер: 75 B |
|
До Ширина: | Высота: | Размер: 75 B |
|
До Ширина: | Высота: | Размер: 75 B |
|
До Ширина: | Высота: | Размер: 75 B |
|
До Ширина: | Высота: | Размер: 75 B |
|
До Ширина: | Высота: | Размер: 75 B |
|
До Ширина: | Высота: | Размер: 75 B |
|
До Ширина: | Высота: | Размер: 75 B |
@@ -1,41 +0,0 @@
|
||||
desktop-image: "background.png"
|
||||
desktop-image-scale-method: "stretch"
|
||||
desktop-color: "#0b0a1f"
|
||||
title-text: ""
|
||||
terminal-font: "Unicode Regular"
|
||||
terminal-left: "7%"
|
||||
terminal-top: "36%"
|
||||
terminal-width: "86%"
|
||||
terminal-height: "50%"
|
||||
terminal-border: "0"
|
||||
|
||||
+ boot_menu {
|
||||
left = 7%
|
||||
top = 36%
|
||||
width = 60%
|
||||
height = 42%
|
||||
item_font = "Unicode Regular"
|
||||
item_color = "#b9b8cf"
|
||||
selected_item_color = "#ffffff"
|
||||
item_height = 36
|
||||
item_padding = 10
|
||||
item_spacing = 6
|
||||
icon_width = 0
|
||||
icon_height = 0
|
||||
scrollbar = false
|
||||
selected_item_pixmap_style = "select_*.png"
|
||||
}
|
||||
|
||||
+ progress_bar {
|
||||
id = "__timeout__"
|
||||
left = 7%
|
||||
top = 86%
|
||||
width = 60%
|
||||
height = 24
|
||||
font = "Unicode Regular"
|
||||
text_color = "#ffffff"
|
||||
fg_color = "#7b6bff"
|
||||
bg_color = "#1c1a36"
|
||||
border_color = "#2a2748"
|
||||
text = "Автоматический запуск через %d с"
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
# Имена, которые установщик 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://git.stackworks.ru/viktor/virtuality/raw/branch/main/install.sh}"
|
||||
SELF_INSTALL_URL="${VIRTUALITY_INSTALL_URL:-https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh}"
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
@@ -21,18 +21,20 @@ 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_REPO_URL,VIRTUALITY_PROJECT_BASE_DIR,VIRTUALITY_PROJECT_DIR,VIRTUALITY_INSTALL_VERBOSE,VIRTUALITY_AUTO_UPDATE,VIRTUALITY_BRANCH bash "$tmp_installer" "$@"
|
||||
exec sudo --preserve-env=VIRTUALITY_INSTALL_URL,VIRTUALITY_USER,VIRTUALITY_WEB_PORT,VIRTUALITY_MIN_ROOT_FREE_MB,VIRTUALITY_MIN_VAR_FREE_MB,VIRTUALITY_MIN_RAM_MB,VIRTUALITY_MIN_CPU_CORES,VIRTUALITY_SKIP_REQUIREMENTS,VIRTUALITY_SETUP_BRIDGE,VIRTUALITY_BRIDGE_IFACE,VIRTUALITY_CREATE_TEST_VM,VIRTUALITY_REPO_URL,VIRTUALITY_PROJECT_BASE_DIR,VIRTUALITY_PROJECT_DIR,VIRTUALITY_INSTALL_VERBOSE,VIRTUALITY_CLEAN_BEFORE_INSTALL bash "$tmp_installer" "$@"
|
||||
fi
|
||||
echo "Root or sudo is required. Run: curl -fsSL $SELF_INSTALL_URL | sudo bash" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_URL="${VIRTUALITY_REPO_URL:-https://git.stackworks.ru/viktor/virtuality.git}"
|
||||
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}"
|
||||
BRANCH="${VIRTUALITY_BRANCH:-main}"
|
||||
RUN_BRIDGE="${VIRTUALITY_SETUP_BRIDGE:-0}"
|
||||
BRIDGE_IFACE="${VIRTUALITY_BRIDGE_IFACE:-}"
|
||||
RUN_TEST_VM="${VIRTUALITY_CREATE_TEST_VM:-0}"
|
||||
WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}"
|
||||
LOG_DIR="/var/log/virtuality"
|
||||
LOG_FILE="${LOG_DIR}/install_$(date +%Y%m%d_%H%M%S).log"
|
||||
@@ -42,7 +44,7 @@ MIN_RAM_MB="${VIRTUALITY_MIN_RAM_MB:-4096}"
|
||||
MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}"
|
||||
SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}"
|
||||
INSTALL_VERBOSE="${VIRTUALITY_INSTALL_VERBOSE:-0}"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
CLEAN_BEFORE_INSTALL="${VIRTUALITY_CLEAN_BEFORE_INSTALL:-0}"
|
||||
|
||||
ESC="\033"
|
||||
RESET="${ESC}[0m"
|
||||
@@ -115,7 +117,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 / web panel setup ${CYAN}${BOLD}│${RESET}"
|
||||
echo -e "${CYAN}${BOLD}│${RESET} One-command KVM / libvirt / Cockpit / Web Panel setup ${CYAN}${BOLD}│${RESET}"
|
||||
echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||
echo
|
||||
echo -e "${GRAY}Repository:${RESET} ${REPO_URL}"
|
||||
@@ -248,6 +250,13 @@ 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
|
||||
@@ -257,17 +266,25 @@ ok "Место после подготовки пользователя: ${root_
|
||||
|
||||
step "Клонируем или обновляем репозиторий"
|
||||
if [[ -d "$PROJECT_DIR/.git" ]]; then
|
||||
run_logged "Репозиторий обновлён (${BRANCH})" run_as_user "cd '$PROJECT_DIR' && git fetch origin '$BRANCH' && git checkout -q -B '$BRANCH' 'origin/$BRANCH'"
|
||||
run_logged "Репозиторий обновлён" run_as_user "cd '$PROJECT_DIR' && git fetch origin main && git reset --hard origin/main && git pull --ff-only"
|
||||
else
|
||||
run_logged "Репозиторий склонирован (${BRANCH})" run_as_user "cd '$PROJECT_BASE_DIR' && git clone --branch '$BRANCH' '$REPO_URL' source"
|
||||
run_logged "Репозиторий склонирован" run_as_user "cd '$PROJECT_BASE_DIR' && git clone '$REPO_URL' source"
|
||||
fi
|
||||
|
||||
if [[ "$CLEAN_BEFORE_INSTALL" == "1" && -x "$PROJECT_DIR/scripts/clean_install.sh" ]]; then
|
||||
step "Очищаем старую кривую установку после обновления исходников"
|
||||
run_logged "Старая установка очищена" bash "$PROJECT_DIR/scripts/clean_install.sh" --yes
|
||||
fi
|
||||
|
||||
step "Запускаем основной установщик ноды"
|
||||
run_logged "Virtuality Node установлен" env VIRTUALITY_USER="$INSTALL_USER" bash "$PROJECT_DIR/install_virtuality_node.sh"
|
||||
run_logged "Virtuality Node установлен" bash "$PROJECT_DIR/install_virtuality_node.sh"
|
||||
|
||||
step "Устанавливаем healthcheck-команду"
|
||||
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"
|
||||
@@ -295,6 +312,25 @@ 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
|
||||
@@ -310,12 +346,18 @@ echo -e "${GREEN}${BOLD}╭─────────────────
|
||||
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Virtuality установка завершена${RESET} ${GREEN}${BOLD}│${RESET}"
|
||||
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||
echo
|
||||
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 -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} откройте панель в браузере — мастер настройки поможет включить HTTPS,"
|
||||
echo "выбрать сеть для машин (NAT или ваша локальная сеть) и хранилище."
|
||||
echo "Если пароль ещё не задан: passwd ${INSTALL_USER}"
|
||||
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
|
||||
|
||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
|
||||
# ==========================================================
|
||||
# Virtuality Node Installer
|
||||
# Clean step-by-step installer for KVM/QEMU/libvirt
|
||||
# Clean step-by-step installer for KVM/QEMU/libvirt/Cockpit
|
||||
# ==========================================================
|
||||
|
||||
PROJECT_NAME="Virtuality"
|
||||
@@ -13,17 +13,15 @@ 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=12
|
||||
TOTAL_STEPS=13
|
||||
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"
|
||||
@@ -46,11 +44,12 @@ 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 virtualization node ${CYAN}${BOLD}│${RESET}"
|
||||
echo -e "${CYAN}${BOLD}│${RESET} KVM / QEMU / libvirt / Cockpit virtualization node ${CYAN}${BOLD}│${RESET}"
|
||||
echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||
echo
|
||||
echo -e "${GRAY}Лог установки:${RESET} ${LOG_FILE}"
|
||||
echo -e "${GRAY}Storage:${RESET} ${STORAGE_DIR}"
|
||||
echo -e "${GRAY}Cockpit:${RESET} ${COCKPIT_PORT}/tcp"
|
||||
echo
|
||||
}
|
||||
|
||||
@@ -76,18 +75,15 @@ run_logged() {
|
||||
spinner=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
|
||||
i=0
|
||||
while kill -0 "$pid" 2>/dev/null; do
|
||||
# The spinner only makes sense on a terminal; first boot writes this output to a log.
|
||||
if [[ -t 1 ]]; then
|
||||
elapsed=$(( $(date +%s) - start_ts ))
|
||||
symbol="${spinner[$((i % ${#spinner[@]}))]}"
|
||||
printf "\r ${CYAN}%s${RESET} %s... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "$symbol" "$description" "$elapsed" "$LOG_FILE"
|
||||
fi
|
||||
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"
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
done
|
||||
code=0
|
||||
wait "$pid" || code=$?
|
||||
[[ -t 1 ]] && printf "\r%*s\r" 120 ""
|
||||
wait "$pid"
|
||||
code=$?
|
||||
printf "\r%*s\r" 120 ""
|
||||
if [[ "$code" -eq 0 ]]; then
|
||||
ok "$description"
|
||||
else
|
||||
@@ -95,22 +91,6 @@ 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}'; }
|
||||
@@ -169,27 +149,21 @@ else
|
||||
fi
|
||||
|
||||
step "Обновляем apt cache"
|
||||
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
|
||||
run_logged "apt update выполнен" apt update
|
||||
|
||||
step "Устанавливаем базовые утилиты и зависимости диагностики"
|
||||
install_available "Базовые пакеты и диагностические утилиты установлены" \
|
||||
run_logged "Базовые пакеты и диагностические утилиты установлены" apt install -y \
|
||||
curl wget git nano htop btop tree ncdu jq unzip xz-utils tar gzip ca-certificates gnupg lsb-release \
|
||||
software-properties-common apt-transport-https ufw rsync iproute2 iptables nftables bind9-dnsutils net-tools openssh-client
|
||||
software-properties-common apt-transport-https ufw rsync iproute2 iptables nftables dnsutils net-tools openssh-client
|
||||
|
||||
step "Устанавливаем KVM/QEMU/libvirt"
|
||||
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
|
||||
run_logged "Пакеты виртуализации установлены" apt install -y \
|
||||
qemu-kvm qemu-utils libvirt-daemon-system libvirt-clients virtinst \
|
||||
bridge-utils dnsmasq-base ovmf swtpm cloud-image-utils
|
||||
|
||||
step "Устанавливаем зависимости панели управления"
|
||||
install_available "Пакеты панели установлены" \
|
||||
python3 python3-venv rsync openssl nftables novnc python3-websockify
|
||||
step "Устанавливаем Cockpit и модули"
|
||||
run_logged "Cockpit установлен" apt install -y \
|
||||
cockpit cockpit-machines cockpit-networkmanager cockpit-storaged cockpit-packagekit
|
||||
|
||||
step "Создаём структуру Virtuality"
|
||||
run_logged "Директории Virtuality созданы" mkdir -p "$PROJECT_DIR" "$STORAGE_DIR" "$ISO_DIR" "$IMAGES_DIR" "$BACKUP_DIR" "$LOG_DIR"
|
||||
@@ -200,26 +174,28 @@ ok "Images: ${IMAGES_DIR}"
|
||||
ok "Backups: ${BACKUP_DIR}"
|
||||
|
||||
step "Добавляем пользователя в группы libvirt/kvm"
|
||||
REAL_USER="${VIRTUALITY_USER:-${SUDO_USER:-root}}"
|
||||
REAL_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/VIRTUALITY_USER; пользователь не добавлен в группы"
|
||||
warn "Запуск под root без SUDO_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
|
||||
# Consoles are proxied by the panel; VNC ports stay closed from outside.
|
||||
ufw delete allow 5900:5999/tcp >> "$LOG_FILE" 2>&1 || true
|
||||
run_logged "Разрешён Cockpit ${COCKPIT_PORT}/tcp" ufw allow "${COCKPIT_PORT}/tcp"
|
||||
run_logged "Разрешены VNC-порты 5900:5999/tcp" ufw allow 5900:5999/tcp
|
||||
if ufw status | grep -qi inactive; then
|
||||
echo "y" | ufw enable >> "$LOG_FILE" 2>&1 && ok "UFW включён" || warn "Не удалось включить UFW"
|
||||
else
|
||||
@@ -246,6 +222,7 @@ 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"
|
||||
|
||||
@@ -256,6 +233,11 @@ 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
|
||||
@@ -269,6 +251,7 @@ echo -e "${GREEN}${BOLD}╭─────────────────
|
||||
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Установка Virtuality Node завершена${RESET} ${GREEN}${BOLD}│${RESET}"
|
||||
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||
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}"
|
||||
@@ -279,7 +262,9 @@ 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/install_web_panel.sh — панель управления и мастер настройки${RESET}"
|
||||
echo -e "${DIM}Следующий шаг: sudo bash scripts/setup_bridge_br0.sh enp2s0 static${RESET}"
|
||||
echo -e "${DIM}После bridge: sudo bash scripts/create_test_vm.sh${RESET}"
|
||||
line
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
addopts = -p no:cacheprovider
|
||||
@@ -1,4 +0,0 @@
|
||||
# Development and CI tools
|
||||
pytest==8.3.4
|
||||
httpx==0.28.1
|
||||
pyflakes==3.2.0
|
||||
@@ -7,13 +7,10 @@ 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])'; }
|
||||
@@ -51,14 +48,14 @@ try_run() {
|
||||
|
||||
fetch_zip_update() {
|
||||
local zip_file extract_dir extracted_root
|
||||
zip_file="${TMP_DIR}/virtuality-${TARGET_LABEL}.zip"
|
||||
zip_file="${TMP_DIR}/virtuality-${BRANCH}.zip"
|
||||
extract_dir="${TMP_DIR}/zip-update"
|
||||
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" "git fetch не сработал, пробуем скачать ZIP-архив"
|
||||
write_state "running" "GitHub git fetch не сработал, пробуем ZIP fallback"
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if ! try_run curl -L --connect-timeout 20 --max-time 240 --retry 3 --retry-delay 3 -o "$zip_file" "$REPO_ZIP_URL"; then
|
||||
@@ -111,8 +108,7 @@ fetch_zip_update() {
|
||||
STARTED_AT="$(now)"
|
||||
write_state "running" "Обновление запущено"
|
||||
log "============================================================"
|
||||
log "Virtuality update started"
|
||||
[ -n "$MOVED_REMOTE" ] && log "$MOVED_REMOTE"
|
||||
log "Virtuality GitHub update started"
|
||||
log "source: $SOURCE_DIR"
|
||||
log "remote: $REMOTE"
|
||||
log "branch: $BRANCH"
|
||||
@@ -130,20 +126,19 @@ log "before version: $BEFORE_VERSION"
|
||||
GIT_UPDATED=0
|
||||
if [ -d "$SOURCE_DIR/.git" ]; then
|
||||
cd "$SOURCE_DIR" || exit 1
|
||||
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)"
|
||||
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)"
|
||||
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" "Синхронизируем исходники через 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
|
||||
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
|
||||
GIT_UPDATED=1
|
||||
else
|
||||
log "git reset/clean failed, ZIP fallback will be used"
|
||||
@@ -160,7 +155,7 @@ fi
|
||||
|
||||
if [ "$GIT_UPDATED" != "1" ]; then
|
||||
if ! fetch_zip_update; then
|
||||
write_state "error" "Не удалось обновиться ни через git, ни через ZIP fallback. Проверьте доступ сервера к ${REPO_ZIP_URL%%/archive/*}, curl/wget/unzip и DNS."
|
||||
write_state "error" "Не удалось обновиться ни через git, ни через ZIP fallback. Проверь доступ к github.com, curl/wget/unzip и DNS."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
@@ -181,6 +176,6 @@ run_step bash scripts/install_web_panel.sh
|
||||
write_state "running" "Перезапускаем virtuality-web"
|
||||
run_step systemctl restart virtuality-web
|
||||
|
||||
log "Virtuality update finished successfully"
|
||||
log "Virtuality GitHub update finished successfully"
|
||||
write_state "success" "Обновление завершено: ${BEFORE_VERSION} → ${AFTER_VERSION}"
|
||||
exit 0
|
||||
|
||||
@@ -9,8 +9,7 @@ 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"
|
||||
# shellcheck source=scripts/update_target.sh
|
||||
. "${SOURCE_DIR}/scripts/update_target.sh"
|
||||
ZIP_URL="${VIRTUALITY_UPDATE_ZIP_URL:-https://github.com/viktor138irk/virtuality/archive/refs/heads/${BRANCH}.zip}"
|
||||
|
||||
mkdir -p "$STATE_DIR" "$(dirname "$LOG_FILE")"
|
||||
|
||||
@@ -35,14 +34,10 @@ 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 "$repo_host" >> "$LOG_FILE" 2>&1 || log "diagnostics: DNS $repo_host failed"
|
||||
getent hosts github.com >> "$LOG_FILE" 2>&1 || log "diagnostics: DNS github.com failed"
|
||||
fi
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
git --version >> "$LOG_FILE" 2>&1 || true
|
||||
@@ -51,7 +46,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 "$web" >> "$LOG_FILE" 2>&1 || log "diagnostics: curl $repo_host failed"
|
||||
curl -I -L --connect-timeout 10 --max-time 25 https://github.com/ >> "$LOG_FILE" 2>&1 || log "diagnostics: curl github.com failed"
|
||||
else
|
||||
log "diagnostics: curl not found"
|
||||
fi
|
||||
@@ -93,24 +88,14 @@ diag_network() {
|
||||
current="unknown"
|
||||
fi
|
||||
|
||||
moved="$(migrate_update_remote)"
|
||||
[ -n "$moved" ] && log "$moved"
|
||||
|
||||
if [ -d "$SOURCE_DIR/.git" ]; then
|
||||
if git fetch --quiet --tags --force "$REMOTE" "$BRANCH" >> "$LOG_FILE" 2>&1; then
|
||||
resolve_update_target
|
||||
log "channel: ${UPDATE_CHANNEL}, target: ${TARGET_LABEL}"
|
||||
latest="$(git rev-parse "${TARGET_REF}^{commit}" 2>/dev/null || true)"
|
||||
if git fetch --quiet "$REMOTE" "$BRANCH" >> "$LOG_FILE" 2>&1; then
|
||||
latest="$(git rev-parse "${REMOTE}/${BRANCH}" 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"
|
||||
@@ -127,6 +112,6 @@ diag_network() {
|
||||
exit 0
|
||||
fi
|
||||
|
||||
write_state "running" "Автообновление: устанавливаем обновление"
|
||||
VIRTUALITY_SOURCE_DIR="$SOURCE_DIR" VIRTUALITY_UPDATE_REMOTE="$REMOTE" VIRTUALITY_UPDATE_BRANCH="$BRANCH" bash "$APPLY_SCRIPT"
|
||||
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"
|
||||
) 9>"$LOCK_FILE"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "=================================================="
|
||||
echo " Virtuality Node Diagnostics"
|
||||
echo "=================================================="
|
||||
|
||||
echo
|
||||
echo "[System]"
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
. /etc/os-release
|
||||
echo "OS: ${PRETTY_NAME}"
|
||||
fi
|
||||
uname -a
|
||||
|
||||
echo
|
||||
echo "[CPU virtualization]"
|
||||
if grep -E -q '(vmx|svm)' /proc/cpuinfo; then
|
||||
echo "OK: hardware virtualization flag found"
|
||||
else
|
||||
echo "WARNING: vmx/svm flag not found"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "[Services]"
|
||||
for service in libvirtd virtlogd cockpit.socket; do
|
||||
if systemctl list-unit-files | grep -q "^${service}"; then
|
||||
echo -n "${service}: "
|
||||
systemctl is-active "$service" || true
|
||||
else
|
||||
echo "${service}: not installed"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "[Packages]"
|
||||
for bin in virsh qemu-system-x86_64 virt-install cockpit-bridge; do
|
||||
if command -v "$bin" >/dev/null 2>&1; then
|
||||
echo "OK: $bin -> $(command -v "$bin")"
|
||||
else
|
||||
echo "MISSING: $bin"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "[libvirt VMs]"
|
||||
if command -v virsh >/dev/null 2>&1; then
|
||||
virsh list --all || true
|
||||
else
|
||||
echo "virsh not found"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "[libvirt storage pools]"
|
||||
if command -v virsh >/dev/null 2>&1; then
|
||||
virsh pool-list --all || true
|
||||
else
|
||||
echo "virsh not found"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "[Network interfaces]"
|
||||
ip -br a || true
|
||||
|
||||
echo
|
||||
echo "[Routes]"
|
||||
ip route || true
|
||||
|
||||
echo
|
||||
echo "[Virtuality paths]"
|
||||
for dir in /opt/virtuality /var/lib/virtuality /var/lib/virtuality/iso /var/lib/virtuality/images /var/lib/virtuality/backups /var/log/virtuality; do
|
||||
if [[ -d "$dir" ]]; then
|
||||
echo "OK: $dir"
|
||||
else
|
||||
echo "MISSING: $dir"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "[Firewall]"
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
ufw status || true
|
||||
else
|
||||
echo "ufw not installed"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=================================================="
|
||||
echo " Diagnostics completed"
|
||||
echo "=================================================="
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Virtuality clean install helper.
|
||||
# Default mode preserves user data: VM disks, ISO files, uploaded disk images and backups.
|
||||
# Use --purge-data only when you intentionally want to remove Virtuality data directories too.
|
||||
|
||||
PURGE_DATA=0
|
||||
YES=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--purge-data) PURGE_DATA=1 ;;
|
||||
--yes|-y) YES=1 ;;
|
||||
*) echo "Unknown argument: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
exec sudo bash "$0" "$@"
|
||||
fi
|
||||
|
||||
RED="\033[31m"
|
||||
GREEN="\033[32m"
|
||||
YELLOW="\033[33m"
|
||||
BOLD="\033[1m"
|
||||
RESET="\033[0m"
|
||||
|
||||
say() { echo -e "${GREEN}✓${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}!${RESET} $*"; }
|
||||
remove_path() {
|
||||
local path="$1"
|
||||
if [[ -e "$path" || -L "$path" ]]; then
|
||||
rm -rf "$path"
|
||||
say "removed: $path"
|
||||
else
|
||||
warn "not found: $path"
|
||||
fi
|
||||
}
|
||||
|
||||
cat <<EOF
|
||||
${BOLD}Virtuality clean install${RESET}
|
||||
|
||||
Will remove:
|
||||
- /opt/virtuality/web
|
||||
- /opt/virtuality/venv
|
||||
- /etc/systemd/system/virtuality-web.service
|
||||
- /var/lib/virtuality/config
|
||||
- /var/lib/virtuality/update
|
||||
- /var/log/virtuality/update.log and install logs
|
||||
|
||||
Will preserve by default:
|
||||
- /opt/virtuality/source
|
||||
- /var/lib/virtuality/iso
|
||||
- /var/lib/virtuality/images
|
||||
- /var/lib/virtuality/disk-images
|
||||
- /var/lib/virtuality/backups
|
||||
- existing libvirt VMs and storage pools
|
||||
EOF
|
||||
|
||||
if [[ "$PURGE_DATA" -eq 1 ]]; then
|
||||
cat <<EOF
|
||||
|
||||
${RED}${BOLD}PURGE DATA MODE ENABLED${RESET}
|
||||
Will also remove:
|
||||
- /var/lib/virtuality/iso
|
||||
- /var/lib/virtuality/images
|
||||
- /var/lib/virtuality/disk-images
|
||||
- /var/lib/virtuality/backups
|
||||
- /var/lib/virtuality/network
|
||||
|
||||
Existing libvirt VM definitions are not deleted automatically.
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [[ "$YES" -ne 1 ]]; then
|
||||
echo
|
||||
read -r -p "Continue? Type YES: " answer
|
||||
if [[ "$answer" != "YES" ]]; then
|
||||
echo "Cancelled."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
systemctl stop virtuality-web.service 2>/dev/null || true
|
||||
systemctl disable virtuality-web.service 2>/dev/null || true
|
||||
|
||||
remove_path /etc/systemd/system/virtuality-web.service
|
||||
systemctl daemon-reload || true
|
||||
|
||||
remove_path /opt/virtuality/web
|
||||
remove_path /opt/virtuality/venv
|
||||
remove_path /var/lib/virtuality/config
|
||||
remove_path /var/lib/virtuality/update
|
||||
remove_path /var/log/virtuality/update.log
|
||||
find /var/log/virtuality -maxdepth 1 -type f -name 'install_web_panel_*.log' -delete 2>/dev/null || true
|
||||
say "old installer logs removed"
|
||||
|
||||
if [[ "$PURGE_DATA" -eq 1 ]]; then
|
||||
remove_path /var/lib/virtuality/iso
|
||||
remove_path /var/lib/virtuality/images
|
||||
remove_path /var/lib/virtuality/disk-images
|
||||
remove_path /var/lib/virtuality/backups
|
||||
remove_path /var/lib/virtuality/network
|
||||
fi
|
||||
|
||||
mkdir -p /opt/virtuality /var/log/virtuality /var/lib/virtuality
|
||||
say "base directories recreated"
|
||||
|
||||
echo
|
||||
say "Clean install cleanup finished. Run: sudo bash scripts/install_web_panel.sh"
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VM_NAME="${1:-test-alpine}"
|
||||
ISO_URL="${VIRTUALITY_TEST_ISO_URL:-https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-standard-3.20.3-x86_64.iso}"
|
||||
ISO_PATH="/var/lib/virtuality/iso/alpine-standard.iso"
|
||||
DISK_PATH="/var/lib/virtuality/images/${VM_NAME}.qcow2"
|
||||
BRIDGE="${VIRTUALITY_BRIDGE:-br0}"
|
||||
MEMORY="${VIRTUALITY_TEST_VM_MEMORY:-1024}"
|
||||
VCPUS="${VIRTUALITY_TEST_VM_VCPUS:-1}"
|
||||
DISK_SIZE="${VIRTUALITY_TEST_VM_DISK_SIZE:-8}"
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
echo "Ошибка: запусти через sudo: sudo bash scripts/create_test_vm.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v virt-install >/dev/null 2>&1; then
|
||||
echo "Ошибка: virt-install не найден. Запусти install_virtuality_node.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v virsh >/dev/null 2>&1; then
|
||||
echo "Ошибка: virsh не найден. Запусти install_virtuality_node.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! ip link show "$BRIDGE" >/dev/null 2>&1; then
|
||||
echo "Ошибка: bridge $BRIDGE не найден. Сначала настрой br0."
|
||||
ip -br a
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p /var/lib/virtuality/iso /var/lib/virtuality/images
|
||||
|
||||
if virsh dominfo "$VM_NAME" >/dev/null 2>&1; then
|
||||
echo "Виртуальная машина $VM_NAME уже существует."
|
||||
virsh list --all
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ISO_PATH" ]]; then
|
||||
echo "Скачиваю тестовый ISO: $ISO_URL"
|
||||
wget -O "$ISO_PATH" "$ISO_URL"
|
||||
fi
|
||||
|
||||
virsh pool-refresh virtuality-iso >/dev/null 2>&1 || true
|
||||
virsh pool-refresh virtuality-images >/dev/null 2>&1 || true
|
||||
|
||||
echo "Создаю тестовую VM: $VM_NAME"
|
||||
virt-install \
|
||||
--name "$VM_NAME" \
|
||||
--memory "$MEMORY" \
|
||||
--vcpus "$VCPUS" \
|
||||
--disk "path=${DISK_PATH},size=${DISK_SIZE},format=qcow2,bus=virtio" \
|
||||
--cdrom "$ISO_PATH" \
|
||||
--os-variant generic \
|
||||
--network "bridge=${BRIDGE},model=virtio" \
|
||||
--graphics vnc,listen=0.0.0.0 \
|
||||
--noautoconsole
|
||||
|
||||
echo
|
||||
echo "VM создана."
|
||||
virsh list --all
|
||||
|
||||
echo
|
||||
echo "VNC display:"
|
||||
virsh vncdisplay "$VM_NAME" || true
|
||||
|
||||
echo
|
||||
echo "Открыть в Cockpit: https://SERVER_IP:9090 → Virtual machines → ${VM_NAME}"
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Virtuality Cockpit auth helper
|
||||
# Checks Linux user password state, Cockpit disallowed-users and required groups.
|
||||
|
||||
USER_NAME="${1:-${VIRTUALITY_USER:-${SUDO_USER:-root}}}"
|
||||
DISALLOWED_FILE="/etc/cockpit/disallowed-users"
|
||||
|
||||
ESC="\033"
|
||||
RESET="${ESC}[0m"
|
||||
BOLD="${ESC}[1m"
|
||||
GREEN="${ESC}[32m"
|
||||
YELLOW="${ESC}[33m"
|
||||
RED="${ESC}[31m"
|
||||
CYAN="${ESC}[36m"
|
||||
GRAY="${ESC}[90m"
|
||||
|
||||
ok() { echo -e "${GREEN}✓${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}!${RESET} $*"; }
|
||||
fail() { echo -e "${RED}✗${RESET} $*"; exit 1; }
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
fail "Запусти от root: sudo bash scripts/fix_cockpit_auth.sh [user]"
|
||||
fi
|
||||
|
||||
clear 2>/dev/null || true
|
||||
cat <<EOF
|
||||
${CYAN}${BOLD}Virtuality Cockpit auth check${RESET}
|
||||
${GRAY}User:${RESET} ${USER_NAME}
|
||||
EOF
|
||||
|
||||
echo
|
||||
|
||||
if ! id "$USER_NAME" >/dev/null 2>&1; then
|
||||
fail "Пользователь ${USER_NAME} не найден"
|
||||
fi
|
||||
ok "Пользователь найден: ${USER_NAME}"
|
||||
|
||||
HASH="$(getent shadow "$USER_NAME" | cut -d: -f2 || true)"
|
||||
if [[ -z "$HASH" || "$HASH" == "!" || "$HASH" == "*" || "$HASH" == "!!" ]]; then
|
||||
warn "У пользователя ${USER_NAME} пароль не задан или заблокирован"
|
||||
echo
|
||||
echo -e "${BOLD}Для входа в Cockpit задай пароль:${RESET}"
|
||||
if [[ "$USER_NAME" == "root" ]]; then
|
||||
echo " passwd root"
|
||||
else
|
||||
echo " sudo passwd ${USER_NAME}"
|
||||
fi
|
||||
else
|
||||
ok "У пользователя ${USER_NAME} есть пароль"
|
||||
fi
|
||||
|
||||
if [[ -f "$DISALLOWED_FILE" ]]; then
|
||||
if grep -qx "$USER_NAME" "$DISALLOWED_FILE"; then
|
||||
cp "$DISALLOWED_FILE" "${DISALLOWED_FILE}.bak.$(date +%Y%m%d_%H%M%S)"
|
||||
sed -i "/^${USER_NAME}$/d" "$DISALLOWED_FILE"
|
||||
ok "Пользователь ${USER_NAME} удалён из ${DISALLOWED_FILE}"
|
||||
else
|
||||
ok "Пользователь ${USER_NAME} не запрещён в ${DISALLOWED_FILE}"
|
||||
fi
|
||||
else
|
||||
warn "Файл ${DISALLOWED_FILE} не найден, пропускаю проверку запрета пользователей"
|
||||
fi
|
||||
|
||||
if [[ "$USER_NAME" != "root" ]]; then
|
||||
usermod -aG sudo "$USER_NAME" || true
|
||||
usermod -aG libvirt "$USER_NAME" || true
|
||||
usermod -aG kvm "$USER_NAME" || true
|
||||
ok "Пользователь ${USER_NAME} добавлен в группы sudo/libvirt/kvm"
|
||||
warn "Для применения групп нужно выйти из SSH и войти снова"
|
||||
else
|
||||
ok "root не требует добавления в группы"
|
||||
fi
|
||||
|
||||
if command -v apt >/dev/null 2>&1; then
|
||||
apt install -y cockpit cockpit-machines sscg >/dev/null 2>&1 || true
|
||||
ok "Пакеты cockpit/cockpit-machines/sscg проверены"
|
||||
fi
|
||||
|
||||
systemctl enable --now cockpit.socket >/dev/null 2>&1 || fail "Не удалось запустить cockpit.socket"
|
||||
ok "cockpit.socket включён и запущен"
|
||||
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
ufw allow 9090/tcp >/dev/null 2>&1 || true
|
||||
ok "UFW правило 9090/tcp добавлено/проверено"
|
||||
fi
|
||||
|
||||
systemctl restart cockpit.socket >/dev/null 2>&1 || true
|
||||
systemctl restart cockpit.service >/dev/null 2>&1 || true
|
||||
ok "Cockpit перезапущен"
|
||||
|
||||
SERVER_IP="$(hostname -I | awk '{print $1}')"
|
||||
PUBLIC_IP="$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") {print $(i+1); exit}}')"
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}${BOLD}Готово.${RESET}"
|
||||
echo -e "${BOLD}Cockpit local/private:${RESET} https://${SERVER_IP}:9090"
|
||||
if [[ -n "$PUBLIC_IP" && "$PUBLIC_IP" != "$SERVER_IP" ]]; then
|
||||
echo -e "${BOLD}Cockpit route IP:${RESET} https://${PUBLIC_IP}:9090"
|
||||
fi
|
||||
echo -e "${BOLD}Login:${RESET} ${USER_NAME}"
|
||||
echo -e "${BOLD}Password:${RESET} пароль Linux-пользователя ${USER_NAME}"
|
||||
echo
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DASH_SRC="${REPO_DIR}/scripts/virtuality_console_dashboard.sh"
|
||||
DASH_DST="/usr/local/bin/virtuality-console-dashboard"
|
||||
SERVICE_FILE="/etc/systemd/system/virtuality-console-dashboard.service"
|
||||
TARGET_USER="${SUDO_USER:-${USER:-root}}"
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
echo "Ошибка: запусти через sudo: sudo bash scripts/install_console_dashboard.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$DASH_SRC" ]]; then
|
||||
echo "Ошибка: не найден файл $DASH_SRC"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
apt update
|
||||
apt install -y btop procps iproute2 util-linux coreutils
|
||||
|
||||
install -m 0755 "$DASH_SRC" "$DASH_DST"
|
||||
|
||||
cat > "$SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Virtuality console dashboard on tty1
|
||||
After=multi-user.target network-online.target libvirtd.service cockpit.socket
|
||||
Wants=network-online.target
|
||||
Conflicts=getty@tty1.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/virtuality-console-dashboard
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
StandardInput=tty
|
||||
StandardOutput=tty
|
||||
TTYPath=/dev/tty1
|
||||
TTYReset=yes
|
||||
TTYVHangup=yes
|
||||
TTYVTDisallocate=yes
|
||||
Environment=TERM=linux
|
||||
Environment=VIRTUALITY_DASHBOARD_INTERVAL=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl disable --now getty@tty1.service >/dev/null 2>&1 || true
|
||||
systemctl enable --now virtuality-console-dashboard.service
|
||||
|
||||
USER_HOME="$(getent passwd "$TARGET_USER" | cut -d: -f6 || true)"
|
||||
if [[ -n "$USER_HOME" && -d "$USER_HOME" ]]; then
|
||||
BASHRC="$USER_HOME/.bashrc"
|
||||
touch "$BASHRC"
|
||||
grep -q "alias bt=" "$BASHRC" || cat >> "$BASHRC" <<'EOF'
|
||||
|
||||
# Virtuality quick aliases
|
||||
alias bt='btop'
|
||||
alias dash='sudo systemctl restart virtuality-console-dashboard && sudo chvt 1'
|
||||
alias dash-stop='sudo systemctl stop virtuality-console-dashboard'
|
||||
alias dash-start='sudo systemctl start virtuality-console-dashboard'
|
||||
alias dash-status='systemctl status virtuality-console-dashboard --no-pager'
|
||||
alias vcheck='cd ~/virtuality && sudo bash scripts/check_node.sh'
|
||||
EOF
|
||||
chown "$TARGET_USER:$TARGET_USER" "$BASHRC" || true
|
||||
fi
|
||||
|
||||
echo "============================================================"
|
||||
echo "Virtuality console dashboard installed"
|
||||
echo "============================================================"
|
||||
echo "Физический монитор: tty1"
|
||||
echo "Перейти на дашборд: sudo chvt 1"
|
||||
echo "Команда btop: bt"
|
||||
echo "Перезапустить дашборд: dash"
|
||||
echo "Остановить дашборд: dash-stop"
|
||||
echo "Запустить дашборд: dash-start"
|
||||
echo "Статус: dash-status"
|
||||
echo "============================================================"
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DISK_IMAGES_DIR="/var/lib/virtuality/disk-images"
|
||||
|
||||
mkdir -p "$DISK_IMAGES_DIR"
|
||||
chmod 755 "$DISK_IMAGES_DIR"
|
||||
|
||||
systemctl restart virtuality-web.service
|
||||
|
||||
echo "Disk image support installed. Open: /disk-images"
|
||||
@@ -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="${VIRTUALITY_USER:-${SUDO_USER:-${USER:-root}}}"
|
||||
TARGET_USER="${SUDO_USER:-${USER:-root}}"
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
echo "Ошибка: запусти через sudo: sudo bash scripts/install_healthcheck_command.sh"
|
||||
|
||||
@@ -1,65 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ==========================================================
|
||||
# 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)
|
||||
# ==========================================================
|
||||
# Virtuality Web Panel Installer
|
||||
# Auto-detects x86_64 / Raspberry Pi ARM64 / Orange Pi 5 ARM64 / generic ARM64 profile.
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WEB_DIR="${REPO_DIR}/web"
|
||||
APP_DIR="/opt/virtuality/web"
|
||||
VENV_DIR="/opt/virtuality/venv"
|
||||
CTL_SRC="${REPO_DIR}/scripts/virtuality-ctl"
|
||||
CTL_DST="/usr/local/bin/virtuality-ctl"
|
||||
SERVICE_FILE="/etc/systemd/system/virtuality-web.service"
|
||||
AUTO_UPDATE_SERVICE_FILE="/etc/systemd/system/virtuality-auto-update.service"
|
||||
AUTO_UPDATE_TIMER_FILE="/etc/systemd/system/virtuality-auto-update.timer"
|
||||
BACKUP_SERVICE_FILE="/etc/systemd/system/virtuality-backup.service"
|
||||
BACKUP_TIMER_FILE="/etc/systemd/system/virtuality-backup.timer"
|
||||
PORT="${VIRTUALITY_WEB_PORT:-8088}"
|
||||
AUTH_USER="${VIRTUALITY_AUTH_USER:-${SUDO_USER:-viktor}}"
|
||||
LOG_DIR="/var/log/virtuality"
|
||||
LOG_FILE="${LOG_DIR}/install_web_panel_$(date +%Y%m%d_%H%M%S).log"
|
||||
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"
|
||||
PROFILE_DIR="/var/lib/virtuality/config"
|
||||
PROFILE_FILE="${PROFILE_DIR}/host_profile.json"
|
||||
SESSION_SECRET_FILE="${PROFILE_DIR}/session_secret"
|
||||
TLS_DIR="${PROFILE_DIR}/tls"
|
||||
TLS_CERT="${TLS_DIR}/virtuality.crt"
|
||||
TLS_KEY="${TLS_DIR}/virtuality.key"
|
||||
TLS_ENABLED="${VIRTUALITY_TLS:-1}"
|
||||
UPLOAD_TMP_DIR="/var/lib/virtuality/tmp"
|
||||
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}"
|
||||
TOTAL_STEPS=13
|
||||
CURRENT_STEP=0
|
||||
TOTAL_STEPS=9
|
||||
|
||||
ESC="\033"
|
||||
RESET="${ESC}[0m"
|
||||
@@ -72,22 +39,36 @@ CYAN="${ESC}[36m"
|
||||
BLUE="${ESC}[34m"
|
||||
GRAY="${ESC}[90m"
|
||||
|
||||
mkdir -p "$LOG_DIR" "$CONFIG_DIR" "$UPLOAD_TMP_DIR"
|
||||
mkdir -p "$LOG_DIR" "$PROFILE_DIR" "$UPLOAD_TMP_DIR"
|
||||
chmod 1777 "$UPLOAD_TMP_DIR"
|
||||
|
||||
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}"; tail -n 20 "$LOG_FILE" 2>/dev/null || true; exit 1; }
|
||||
fail() { echo -e " ${RED}✗${RESET} $*"; log "ERROR: $*"; echo; echo -e "${RED}${BOLD}Установка остановлена.${RESET} Подробности в логе: ${LOG_FILE}"; exit 1; }
|
||||
|
||||
run_logged() {
|
||||
local description="$1"
|
||||
@@ -100,149 +81,289 @@ run_logged() {
|
||||
fi
|
||||
}
|
||||
|
||||
service_state() { systemctl is-active "$1" 2>/dev/null || echo "inactive"; }
|
||||
require_root() { [[ "$EUID" -eq 0 ]] || fail "Запусти через sudo: sudo bash scripts/install_web_panel.sh"; }
|
||||
json_value() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], ""))' "$PROFILE_FILE" "$1" 2>/dev/null || true; }
|
||||
|
||||
echo -e "${CYAN}${BOLD}Virtuality — установка панели управления${RESET}"
|
||||
echo -e "${GRAY}Лог: ${LOG_FILE}${RESET}"
|
||||
print_header
|
||||
|
||||
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 "Проверяем права и структуру проекта"
|
||||
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 "Определяем профиль сервера"
|
||||
step "Автоматически определяем профиль сборки"
|
||||
if [[ -x "${REPO_DIR}/scripts/detect_host_profile.sh" ]]; then
|
||||
PROFILE="$("${REPO_DIR}/scripts/detect_host_profile.sh" 2>>"$LOG_FILE" | tail -n1 || true)"
|
||||
PROFILE="$(${REPO_DIR}/scripts/detect_host_profile.sh 2>>"$LOG_FILE" | tail -n1)"
|
||||
else
|
||||
bash "${REPO_DIR}/scripts/detect_host_profile.sh" >>"$LOG_FILE" 2>&1 || true
|
||||
PROFILE="$(json_value profile)"
|
||||
fi
|
||||
ok "Профиль: $(json_value label)${PROFILE:+ (${PROFILE})}"
|
||||
[[ -n "$PROFILE" ]] || PROFILE="x86_64"
|
||||
ARCH="$(json_value arch)"
|
||||
MODEL="$(json_value model)"
|
||||
LABEL="$(json_value label)"
|
||||
PACKAGE_PROFILE="$(json_value package_profile)"
|
||||
ok "Профиль: ${LABEL:-$PROFILE}"
|
||||
ok "Архитектура: ${ARCH:-unknown}"
|
||||
ok "Модель: ${MODEL:-unknown}"
|
||||
ok "Конфиг профиля: $PROFILE_FILE"
|
||||
|
||||
step "Системные пакеты"
|
||||
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
|
||||
step "Проверяем Linux-пользователя для входа"
|
||||
if id "$AUTH_USER" >/dev/null 2>&1; then
|
||||
ok "Пользователь найден: $AUTH_USER"
|
||||
else
|
||||
warn "apt не найден — пакеты не устанавливались"
|
||||
fail "Пользователь $AUTH_USER не найден. Создай его или передай VIRTUALITY_AUTH_USER=username"
|
||||
fi
|
||||
if getent shadow "$AUTH_USER" | cut -d: -f2 | grep -Eq '^(!|\*|!!)?$'; then
|
||||
warn "У пользователя $AUTH_USER может быть заблокирован пароль. Установи пароль: sudo passwd $AUTH_USER"
|
||||
else
|
||||
ok "У пользователя $AUTH_USER есть пароль для Linux-auth"
|
||||
fi
|
||||
|
||||
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 "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"
|
||||
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
|
||||
# Ubuntu Server without python3-venv: pip is bootstrapped from the bundled wheel.
|
||||
run_logged "Virtualenv создан (без pip)" python3 -m venv --without-pip "$VENV_DIR"
|
||||
fail "$cmd не найден"
|
||||
fi
|
||||
done
|
||||
|
||||
step "Обновляем apt cache"
|
||||
run_logged "apt update выполнен" apt update
|
||||
|
||||
step "Устанавливаем системные зависимости под выбранную сборку"
|
||||
COMMON_PACKAGES=(python3 python3-venv python3-pip python3-dev rsync openssl curl wget unzip nftables novnc python3-websockify libvirt-dev pkg-config gcc)
|
||||
X86_PACKAGES=(qemu-system-x86 qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils)
|
||||
ARM_PACKAGES=(qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils)
|
||||
PACKAGES=("${COMMON_PACKAGES[@]}")
|
||||
case "$PACKAGE_PROFILE" in
|
||||
raspberry|orangepi5|arm64)
|
||||
PACKAGES+=("${ARM_PACKAGES[@]}")
|
||||
;;
|
||||
*)
|
||||
PACKAGES+=("${X86_PACKAGES[@]}")
|
||||
;;
|
||||
esac
|
||||
run_logged "Системные пакеты установлены для профиля ${PACKAGE_PROFILE:-x86}" apt install -y "${PACKAGES[@]}"
|
||||
if [[ "${PACKAGE_PROFILE:-x86}" != "raspberry" && "${PACKAGE_PROFILE:-x86}" != "orangepi5" && "${PACKAGE_PROFILE:-x86}" != "arm64" ]]; then
|
||||
if command -v qemu-system-aarch64 >/dev/null 2>&1; then
|
||||
ok "ARM64 QEMU эмулятор найден: $(command -v qemu-system-aarch64)"
|
||||
else
|
||||
fail "Не найден qemu-system-aarch64. Установи пакет: sudo apt install -y qemu-system-arm qemu-efi-aarch64"
|
||||
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 "Настройки ноды"
|
||||
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}"
|
||||
step "Копируем web-панель в /opt/virtuality"
|
||||
run_logged "Создана директория /opt/virtuality" mkdir -p /opt/virtuality
|
||||
run_logged "Файлы панели синхронизированы в $APP_DIR" rsync -a --delete "$WEB_DIR/" "$APP_DIR/"
|
||||
run_logged "Конфиг профиля доступен web-панели" mkdir -p "$PROFILE_DIR"
|
||||
if [[ -f "$PROFILE_FILE" ]]; then
|
||||
ok "Профиль уже сохранён: $PROFILE_FILE"
|
||||
else
|
||||
warn "Профиль не найден после детекта, будет создан web-панелью при старте"
|
||||
fi
|
||||
|
||||
step "Создаём конфиг авторизации"
|
||||
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_FILE}"
|
||||
ok "Создан постоянный session secret: $SESSION_SECRET_FILE"
|
||||
fi
|
||||
cat > "${APP_DIR}/.env" <<EOF
|
||||
VIRTUALITY_AUTH_USER=${AUTH_USER}
|
||||
VIRTUALITY_SESSION_SECRET=${SESSION_SECRET}
|
||||
VIRTUALITY_COOKIE_SECURE=$(saved_setting VIRTUALITY_COOKIE_SECURE)
|
||||
TMPDIR=${UPLOAD_TMP_DIR}
|
||||
TEMP=${UPLOAD_TMP_DIR}
|
||||
TMP=${UPLOAD_TMP_DIR}
|
||||
EOF
|
||||
chmod 600 "${APP_DIR}/.env"
|
||||
ok "Создан ${APP_DIR}/.env"
|
||||
if [[ "$TLS_ENABLED" == "1" ]]; then
|
||||
if [[ -s "$TLS_CERT" && -s "$TLS_KEY" ]]; then
|
||||
ok "Используем существующий TLS-сертификат: $TLS_CERT"
|
||||
else
|
||||
mkdir -p "$TLS_DIR"
|
||||
run_logged "Самоподписанный TLS-сертификат создан на 10 лет" openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes -keyout "$TLS_KEY" -out "$TLS_CERT" -subj "/CN=virtuality" -addext "subjectAltName=DNS:virtuality,IP:127.0.0.1"
|
||||
chmod 600 "$TLS_KEY"
|
||||
fi
|
||||
ok "Панель будет работать по HTTPS (отключить: VIRTUALITY_TLS=0)"
|
||||
else
|
||||
warn "TLS отключён через VIRTUALITY_TLS=0 — панель будет работать по HTTP"
|
||||
fi
|
||||
ok "Временный каталог загрузок: ${UPLOAD_TMP_DIR}"
|
||||
ok "Вход будет по Linux-пользователю: ${AUTH_USER}"
|
||||
|
||||
step "Устанавливаем virtuality-ctl"
|
||||
install -m 0755 "$CTL_SRC" "$CTL_DST"
|
||||
ok "${CTL_DST}"
|
||||
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 "Службы, firewall и запуск"
|
||||
run_logged "Юниты созданы, панель запущена" env VIRTUALITY_SOURCE_DIR="$REPO_DIR" "$CTL_DST" reconfigure
|
||||
step "Устанавливаем Python-зависимости"
|
||||
run_logged "pip обновлён" "$VENV_DIR/bin/pip" install --upgrade pip
|
||||
run_logged "Python-зависимости установлены" "$VENV_DIR/bin/pip" install -r "$APP_DIR/requirements.txt"
|
||||
if "$VENV_DIR/bin/pip" install libvirt-python >> "$LOG_FILE" 2>&1; then
|
||||
ok "libvirt-python установлен — панель работает через быстрый libvirt-backend"
|
||||
else
|
||||
warn "libvirt-python не установился — панель будет работать через virsh (медленнее, но полностью функционально)"
|
||||
fi
|
||||
|
||||
step "Создаём systemd service"
|
||||
UVICORN_TLS_ARGS=""
|
||||
if [[ "$TLS_ENABLED" == "1" ]]; then
|
||||
UVICORN_TLS_ARGS=" --ssl-certfile ${TLS_CERT} --ssl-keyfile ${TLS_KEY}"
|
||||
fi
|
||||
cat > "$SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Virtuality Web Panel
|
||||
After=network-online.target libvirtd.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${APP_DIR}
|
||||
ExecStart=${VENV_DIR}/bin/uvicorn app:app --host 0.0.0.0 --port ${PORT}${UVICORN_TLS_ARGS}
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
User=root
|
||||
Group=root
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=VIRTUALITY_AUTH_USER=${AUTH_USER}
|
||||
Environment=VIRTUALITY_SOURCE_DIR=${REPO_DIR}
|
||||
Environment=TMPDIR=${UPLOAD_TMP_DIR}
|
||||
Environment=TEMP=${UPLOAD_TMP_DIR}
|
||||
Environment=TMP=${UPLOAD_TMP_DIR}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
ok "Создан service: $SERVICE_FILE"
|
||||
run_logged "systemd daemon-reload выполнен" systemctl daemon-reload
|
||||
|
||||
step "Настраиваем автообновление"
|
||||
if [[ -f "${REPO_DIR}/scripts/auto_update_check.sh" ]]; then
|
||||
chmod +x "${REPO_DIR}/scripts/auto_update_check.sh" || true
|
||||
cat > "$AUTO_UPDATE_SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Virtuality automatic GitHub update check
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Group=root
|
||||
Environment=VIRTUALITY_SOURCE_DIR=${REPO_DIR}
|
||||
Environment=TMPDIR=${UPLOAD_TMP_DIR}
|
||||
ExecStart=/bin/bash ${REPO_DIR}/scripts/auto_update_check.sh
|
||||
EOF
|
||||
cat > "$AUTO_UPDATE_TIMER_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Run Virtuality automatic update check daily at 00:00 Moscow time
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 21:00:00 UTC
|
||||
AccuracySec=1min
|
||||
Persistent=true
|
||||
Unit=virtuality-auto-update.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
run_logged "systemd daemon-reload выполнен для автообновлений" systemctl daemon-reload
|
||||
run_logged "virtuality-auto-update.timer включён" systemctl enable --now virtuality-auto-update.timer
|
||||
ok "Автообновление будет проверять GitHub 1 раз в сутки в 00:00 по Москве"
|
||||
else
|
||||
warn "auto_update_check.sh не найден, автообновление пропущено"
|
||||
fi
|
||||
cat > "$BACKUP_SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Virtuality scheduled VM backups
|
||||
After=libvirtd.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Group=root
|
||||
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/backup_core.py --scheduled
|
||||
EOF
|
||||
cat > "$BACKUP_TIMER_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Run Virtuality VM backups nightly at 04:30 Moscow time
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 01:30:00 UTC
|
||||
AccuracySec=5min
|
||||
Persistent=true
|
||||
Unit=virtuality-backup.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
run_logged "systemd daemon-reload выполнен для бэкапов" systemctl daemon-reload
|
||||
run_logged "virtuality-backup.timer включён" systemctl enable --now virtuality-backup.timer
|
||||
ok "Ночные бэкапы VM: расписание настраивается на странице /backups"
|
||||
|
||||
step "Запускаем Virtuality Web Panel"
|
||||
run_logged "virtuality-web.service включён и запущен" systemctl enable --now virtuality-web.service
|
||||
sleep 2
|
||||
if systemctl is-active --quiet virtuality-web.service; then
|
||||
ok "virtuality-web.service работает"
|
||||
STATE="$(service_state virtuality-web.service)"
|
||||
if [[ "$STATE" == "active" ]]; then
|
||||
ok "virtuality-web.service active"
|
||||
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 "Мастер настройки"
|
||||
if [[ -f "$SETUP_STATE_FILE" ]]; then
|
||||
ok "Состояние установки уже ведётся"
|
||||
elif [[ "${VIRTUALITY_FIRSTBOOT:-0}" == "1" ]]; then
|
||||
ok "Прогресс пишет первая загрузка"
|
||||
elif [[ "$FRESH_INSTALL" != "1" ]]; then
|
||||
ok "Обновление уже настроенного сервера — мастер не нужен (его можно запустить в Настройках)"
|
||||
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"
|
||||
else
|
||||
# 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 "Мастер настройки откроется при первом входе в панель"
|
||||
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"
|
||||
fi
|
||||
|
||||
PANEL_URL="$("$CTL_DST" url 2>/dev/null || echo "http://$(hostname -I | awk '{print $1}'):${PORT}")"
|
||||
SERVER_IP="$(hostname -I | awk '{print $1}')"
|
||||
PANEL_SCHEME="http"
|
||||
[[ "$TLS_ENABLED" == "1" ]] && PANEL_SCHEME="https"
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
|
||||
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Установка Virtuality Web Panel завершена${RESET} ${GREEN}${BOLD}│${RESET}"
|
||||
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||
echo
|
||||
echo -e "${BOLD}URL:${RESET} ${PANEL_SCHEME}://${SERVER_IP}:${PORT}"
|
||||
if [[ "$TLS_ENABLED" == "1" ]]; then
|
||||
echo -e "${DIM}Сертификат самоподписанный — браузер один раз спросит подтверждение.${RESET}"
|
||||
fi
|
||||
echo -e "${BOLD}Login:${RESET} ${AUTH_USER} / пароль Linux-пользователя"
|
||||
echo -e "${BOLD}Profile:${RESET} ${LABEL:-$PROFILE}"
|
||||
echo -e "${BOLD}Arch:${RESET} ${ARCH:-unknown}"
|
||||
echo -e "${BOLD}Service:${RESET} virtuality-web.service"
|
||||
echo -e "${BOLD}Auto update:${RESET} virtuality-auto-update.timer / ежедневно в 00:00 по Москве"
|
||||
echo -e "${BOLD}Upload tmp:${RESET} ${UPLOAD_TMP_DIR}"
|
||||
echo -e "${BOLD}Session key:${RESET} ${SESSION_SECRET_FILE}"
|
||||
echo -e "${BOLD}Status:${RESET} systemctl status virtuality-web --no-pager"
|
||||
echo -e "${BOLD}Logs:${RESET} journalctl -u virtuality-web -f"
|
||||
echo -e "${BOLD}Install log:${RESET} ${LOG_FILE}"
|
||||
echo
|
||||
echo -e "${GREEN}${BOLD}Панель управления установлена${RESET}"
|
||||
line
|
||||
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"
|
||||
echo -e "${DIM}Следующий шаг: /host, /network, /logs, /update, /vm/create, /vm/NAME/console.${RESET}"
|
||||
line
|
||||
echo -e "${DIM}Автообновление: $([[ "$AUTO_UPDATE" == "1" ]] && echo "включено (ночью)" || echo "отключено")${RESET}"
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
IFACE="${1:-enp2s0}"
|
||||
BRIDGE="br0"
|
||||
NETPLAN_DIR="/etc/netplan"
|
||||
BACKUP_DIR="/root/virtuality-netplan-backups/$(date +%Y%m%d_%H%M%S)"
|
||||
BRIDGE_FILE="${NETPLAN_DIR}/60-virtuality-br0.yaml"
|
||||
ROLLBACK_SCRIPT="/root/virtuality-rollback-netplan.sh"
|
||||
MODE="${2:-dhcp}"
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
echo "Ошибка: запусти через sudo: sudo bash scripts/setup_bridge_br0.sh enp2s0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v netplan >/dev/null 2>&1; then
|
||||
echo "Ошибка: netplan не найден."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! ip link show "$IFACE" >/dev/null 2>&1; then
|
||||
echo "Ошибка: интерфейс $IFACE не найден."
|
||||
ip -br a
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT_IP="$(ip -4 addr show "$IFACE" | awk '/inet / {print $2; exit}')"
|
||||
CURRENT_GW="$(ip route | awk '/default/ {print $3; exit}')"
|
||||
|
||||
# Use stable IPv4 DNS by default. Link-local IPv6 DNS can break YAML/routes without scope id.
|
||||
DNS_1="${VIRTUALITY_DNS_1:-10.0.0.1}"
|
||||
DNS_2="${VIRTUALITY_DNS_2:-1.1.1.1}"
|
||||
|
||||
echo "============================================================"
|
||||
echo "Virtuality safe bridge setup"
|
||||
echo "============================================================"
|
||||
echo "Interface: $IFACE"
|
||||
echo "Bridge: $BRIDGE"
|
||||
echo "Mode: $MODE"
|
||||
echo "Current IP: ${CURRENT_IP:-unknown}"
|
||||
echo "Current gateway: ${CURRENT_GW:-unknown}"
|
||||
echo "DNS: ${DNS_1}, ${DNS_2}"
|
||||
echo "Backup: $BACKUP_DIR"
|
||||
echo
|
||||
|
||||
echo "ВАЖНО: скрипт использует netplan try."
|
||||
echo "Если сеть пропадёт, netplan должен автоматически откатить изменения."
|
||||
echo "Если видишь подтверждение — нажми Enter, только если SSH/сеть не отвалилась."
|
||||
echo
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
cp -a "$NETPLAN_DIR"/*.yaml "$BACKUP_DIR"/ 2>/dev/null || true
|
||||
|
||||
cat > "$ROLLBACK_SCRIPT" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
rm -f "$BRIDGE_FILE"
|
||||
cp -a "$BACKUP_DIR"/*.yaml "$NETPLAN_DIR"/
|
||||
chmod 600 "$NETPLAN_DIR"/*.yaml 2>/dev/null || true
|
||||
netplan generate
|
||||
netplan apply
|
||||
echo "Netplan rollback applied from $BACKUP_DIR"
|
||||
EOF
|
||||
chmod +x "$ROLLBACK_SCRIPT"
|
||||
|
||||
# Keep existing netplan files neutral for this interface to avoid duplicate DHCP/IP.
|
||||
for file in "$NETPLAN_DIR"/*.yaml; do
|
||||
[[ "$file" == "$BRIDGE_FILE" ]] && continue
|
||||
if grep -q "${IFACE}:" "$file"; then
|
||||
cp -a "$file" "${file}.virtuality-before-br0"
|
||||
cat > "$file" <<EOF
|
||||
network:
|
||||
version: 2
|
||||
ethernets:
|
||||
${IFACE}:
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
EOF
|
||||
chmod 600 "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$MODE" == "static" ]]; then
|
||||
if [[ -z "${CURRENT_IP:-}" || -z "${CURRENT_GW:-}" ]]; then
|
||||
echo "Ошибка: не удалось определить текущий IP/gateway для static mode."
|
||||
echo "Откат: sudo $ROLLBACK_SCRIPT"
|
||||
exit 1
|
||||
fi
|
||||
cat > "$BRIDGE_FILE" <<EOF
|
||||
network:
|
||||
version: 2
|
||||
ethernets:
|
||||
${IFACE}:
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
bridges:
|
||||
${BRIDGE}:
|
||||
interfaces:
|
||||
- ${IFACE}
|
||||
addresses:
|
||||
- ${CURRENT_IP}
|
||||
routes:
|
||||
- to: default
|
||||
via: ${CURRENT_GW}
|
||||
nameservers:
|
||||
addresses:
|
||||
- ${DNS_1}
|
||||
- ${DNS_2}
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
parameters:
|
||||
stp: false
|
||||
forward-delay: 0
|
||||
EOF
|
||||
else
|
||||
cat > "$BRIDGE_FILE" <<EOF
|
||||
network:
|
||||
version: 2
|
||||
ethernets:
|
||||
${IFACE}:
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
bridges:
|
||||
${BRIDGE}:
|
||||
interfaces:
|
||||
- ${IFACE}
|
||||
dhcp4: true
|
||||
dhcp6: false
|
||||
parameters:
|
||||
stp: false
|
||||
forward-delay: 0
|
||||
EOF
|
||||
fi
|
||||
|
||||
chmod 600 "$BRIDGE_FILE"
|
||||
chmod 600 "$NETPLAN_DIR"/*.yaml 2>/dev/null || true
|
||||
|
||||
echo "[1/5] Generated config:"
|
||||
cat "$BRIDGE_FILE"
|
||||
|
||||
echo
|
||||
echo "[2/5] Validating netplan..."
|
||||
netplan generate
|
||||
|
||||
echo
|
||||
echo "[3/5] Testing config with netplan try..."
|
||||
echo "Если после применения сеть работает — подтверди netplan try клавишей Enter."
|
||||
echo "Если SSH зависнет — жди автооткат или используй физический монитор: sudo $ROLLBACK_SCRIPT"
|
||||
netplan try --timeout 30
|
||||
|
||||
echo
|
||||
echo "[4/5] Applying final netplan..."
|
||||
netplan apply
|
||||
sleep 3
|
||||
|
||||
echo
|
||||
echo "[5/5] Result:"
|
||||
ip -br a
|
||||
ip route
|
||||
|
||||
echo
|
||||
echo "============================================================"
|
||||
echo "Bridge setup completed"
|
||||
echo "============================================================"
|
||||
echo "Rollback command: sudo $ROLLBACK_SCRIPT"
|
||||
echo "If DHCP mode failed, try static mode:"
|
||||
echo "sudo bash scripts/setup_bridge_br0.sh ${IFACE} static"
|
||||
echo "============================================================"
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sourced by auto_update_check.sh and apply_github_update.sh: which git ref a node updates to.
|
||||
# stable (default) — the latest release tag vX.Y.Z; main until the first release is tagged
|
||||
# main — every change on the main branch
|
||||
# Needs SOURCE_DIR, REMOTE and BRANCH; sets UPDATE_CHANNEL, TARGET_REF, TARGET_LABEL, TARGET_ZIP_URL.
|
||||
|
||||
# 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
|
||||
}
|
||||
@@ -1,776 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# virtuality-ctl — node management for Virtuality.
|
||||
# Installed to /usr/local/bin by scripts/install_web_panel.sh. Run as root.
|
||||
set -euo pipefail
|
||||
|
||||
CONFIG_DIR="/var/lib/virtuality/config"
|
||||
NODE_ENV="${CONFIG_DIR}/web.env"
|
||||
TLS_DIR="${CONFIG_DIR}/tls"
|
||||
STATE_FILE="${CONFIG_DIR}/ports.state"
|
||||
DATA_DIR="/var/lib/virtuality"
|
||||
LOG_DIR="/var/log/virtuality"
|
||||
APP_DIR="/opt/virtuality/web"
|
||||
VENV_DIR="/opt/virtuality/venv"
|
||||
UNIT_DIR="/etc/systemd/system"
|
||||
NETPLAN_DIR="/etc/netplan"
|
||||
NETPLAN_BACKUP="${CONFIG_DIR}/netplan-backup"
|
||||
BRIDGE_FILE="${NETPLAN_DIR}/60-virtuality-br0.yaml"
|
||||
REVERT_UNIT="virtuality-netplan-revert"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: virtuality-ctl <command> [options]
|
||||
|
||||
status Что установлено, порты, адреса панели
|
||||
url Адрес панели управления
|
||||
reconfigure Пересоздать systemd-юниты из ${NODE_ENV} и перезапустить панель
|
||||
set KEY=VALUE ... Изменить настройку в web.env (например set VIRTUALITY_TLS=1) и применить
|
||||
passwd [USER] Сменить пароль пользователя панели (пароль читается со стандартного ввода)
|
||||
timezone <Area/City> Установить часовой пояс сервера
|
||||
power reboot|poweroff Перезагрузить или выключить сервер (машины выключаются корректно)
|
||||
backup-config [FILE] Сохранить настройки ноды и описания машин в архив
|
||||
restore-config FILE Восстановить настройки ноды из архива
|
||||
rollback Вернуть предыдущую версию панели после неудачного обновления
|
||||
maintenance Ежедневное обслуживание: старые задачи, временные файлы, свободное место
|
||||
bridge <iface> [dhcp|static] Подключить машины к локальной сети через мост br0 с автооткатом
|
||||
bridge-confirm Подтвердить новую сеть (отменяет автооткат)
|
||||
bridge-revert Вернуть прежнюю сеть
|
||||
storage-use /dev/sdX Отдать пустой диск под хранилище машин (${DATA_DIR})
|
||||
support-bundle [FILE] Собрать отчёт для поддержки
|
||||
uninstall [--purge] [--yes] Удалить Virtuality (по умолчанию машины и образы остаются)
|
||||
EOF
|
||||
}
|
||||
|
||||
die() { echo "virtuality-ctl: $*" >&2; exit 1; }
|
||||
need_root() { [[ "$EUID" -eq 0 ]] || die "нужны права root: sudo virtuality-ctl $*"; }
|
||||
now() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||
log() { mkdir -p "$LOG_DIR"; echo "[$(now)] [virtuality-ctl] $*" >> "${LOG_DIR}/virtuality-ctl.log"; }
|
||||
|
||||
# ---------------------------------------------------------------- settings
|
||||
env_get() {
|
||||
local key="$1" default="${2:-}"
|
||||
local value=""
|
||||
if [[ -f "$NODE_ENV" ]]; then
|
||||
value="$(sed -n "s/^${key}=//p" "$NODE_ENV" | tail -n1 | tr -d '"' | tr -d "'")"
|
||||
fi
|
||||
printf '%s' "${value:-$default}"
|
||||
}
|
||||
|
||||
env_set() {
|
||||
local key="$1" value="$2"
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
touch "$NODE_ENV"
|
||||
if grep -q "^${key}=" "$NODE_ENV"; then
|
||||
sed -i "s|^${key}=.*|${key}=${value}|" "$NODE_ENV"
|
||||
else
|
||||
printf '%s=%s\n' "$key" "$value" >> "$NODE_ENV"
|
||||
fi
|
||||
chmod 644 "$NODE_ENV"
|
||||
}
|
||||
|
||||
primary_ip() { hostname -I 2>/dev/null | awk '{print $1}'; }
|
||||
# Source checkout used by updates; the installer records it in web.env.
|
||||
SOURCE_DIR="${VIRTUALITY_SOURCE_DIR:-$(env_get VIRTUALITY_SOURCE_DIR /opt/virtuality/source)}"
|
||||
|
||||
panel_url() {
|
||||
local tls port
|
||||
tls="$(env_get VIRTUALITY_TLS 0)"
|
||||
if [[ "$tls" == "1" ]]; then
|
||||
port="$(env_get VIRTUALITY_TLS_PORT 8443)"
|
||||
printf 'https://%s:%s' "$(primary_ip)" "$port"
|
||||
else
|
||||
port="$(env_get VIRTUALITY_WEB_PORT 8088)"
|
||||
printf 'http://%s:%s' "$(primary_ip)" "$port"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- tls
|
||||
ensure_cert() {
|
||||
local ip host
|
||||
mkdir -p "$TLS_DIR"
|
||||
chmod 700 "$TLS_DIR"
|
||||
if [[ -s "${TLS_DIR}/panel.key" && -s "${TLS_DIR}/panel.crt" ]]; then
|
||||
return 0
|
||||
fi
|
||||
ip="$(primary_ip)"
|
||||
host="$(hostname)"
|
||||
local san="DNS:${host},DNS:localhost,IP:127.0.0.1"
|
||||
[[ -n "$ip" ]] && san="${san},IP:${ip}"
|
||||
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 3650 \
|
||||
-subj "/CN=Virtuality on ${host}" -addext "subjectAltName=${san}" \
|
||||
-keyout "${TLS_DIR}/panel.key" -out "${TLS_DIR}/panel.crt" >/dev/null 2>&1 \
|
||||
|| die "не удалось создать TLS-сертификат (нужен openssl)"
|
||||
chmod 600 "${TLS_DIR}/panel.key"
|
||||
log "TLS certificate created for ${host} ${ip}"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- units
|
||||
write_units() {
|
||||
local port host tls tls_port auth_user auto_update
|
||||
port="$(env_get VIRTUALITY_WEB_PORT 8088)"
|
||||
host="$(env_get VIRTUALITY_WEB_HOST 0.0.0.0)"
|
||||
tls="$(env_get VIRTUALITY_TLS 0)"
|
||||
tls_port="$(env_get VIRTUALITY_TLS_PORT 8443)"
|
||||
auth_user="$(env_get VIRTUALITY_AUTH_USER root)"
|
||||
auto_update="$(env_get VIRTUALITY_AUTO_UPDATE 1)"
|
||||
local listen_port="$port" ssl_args=""
|
||||
if [[ "$tls" == "1" ]]; then
|
||||
ensure_cert
|
||||
listen_port="$tls_port"
|
||||
ssl_args="--ssl-keyfile ${TLS_DIR}/panel.key --ssl-certfile ${TLS_DIR}/panel.crt"
|
||||
env_set VIRTUALITY_COOKIE_SECURE 1
|
||||
else
|
||||
env_set VIRTUALITY_COOKIE_SECURE 0
|
||||
fi
|
||||
|
||||
cat > "${UNIT_DIR}/virtuality-web.service" <<EOF
|
||||
[Unit]
|
||||
Description=Virtuality Web Panel
|
||||
After=network-online.target libvirtd.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${APP_DIR}
|
||||
ExecStart=${VENV_DIR}/bin/uvicorn app:app --host ${host} --port ${listen_port} --workers 1 --no-server-header ${ssl_args}
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
TimeoutStopSec=20
|
||||
LimitNOFILE=65536
|
||||
User=root
|
||||
Group=root
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=LC_ALL=C.UTF-8
|
||||
Environment=VIRTUALITY_AUTH_USER=${auth_user}
|
||||
Environment=VIRTUALITY_SOURCE_DIR=${SOURCE_DIR}
|
||||
Environment=VIRTUALITY_NODE_ENV=${NODE_ENV}
|
||||
Environment=TMPDIR=${DATA_DIR}/tmp
|
||||
Environment=TEMP=${DATA_DIR}/tmp
|
||||
Environment=TMP=${DATA_DIR}/tmp
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
if [[ "$tls" == "1" ]]; then
|
||||
cat > "${UNIT_DIR}/virtuality-redirect.service" <<EOF
|
||||
[Unit]
|
||||
Description=Virtuality HTTP to HTTPS redirect
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/redirect_http.py --port ${port} --target-port ${tls_port}
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
User=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
else
|
||||
rm -f "${UNIT_DIR}/virtuality-redirect.service"
|
||||
fi
|
||||
|
||||
cat > "${UNIT_DIR}/virtuality-network.service" <<EOF
|
||||
[Unit]
|
||||
Description=Virtuality NAT port forwarding restore
|
||||
After=network-online.target libvirtd.service nftables.service ufw.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=${APP_DIR}
|
||||
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/restore_network.py
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
cat > "${UNIT_DIR}/virtuality-auto-update.service" <<EOF
|
||||
[Unit]
|
||||
Description=Virtuality automatic update check
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Environment=VIRTUALITY_SOURCE_DIR=${SOURCE_DIR}
|
||||
Environment=TMPDIR=${DATA_DIR}/tmp
|
||||
ExecStart=/bin/bash ${SOURCE_DIR}/scripts/auto_update_check.sh
|
||||
EOF
|
||||
cat > "${UNIT_DIR}/virtuality-auto-update.timer" <<EOF
|
||||
[Unit]
|
||||
Description=Virtuality nightly update check
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 04:00:00
|
||||
RandomizedDelaySec=30min
|
||||
AccuracySec=1min
|
||||
Persistent=true
|
||||
Unit=virtuality-auto-update.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
cat > "${UNIT_DIR}/virtuality-maintenance.service" <<EOF
|
||||
[Unit]
|
||||
Description=Virtuality daily maintenance
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
ExecStart=/usr/local/bin/virtuality-ctl maintenance
|
||||
EOF
|
||||
cat > "${UNIT_DIR}/virtuality-maintenance.timer" <<EOF
|
||||
[Unit]
|
||||
Description=Virtuality daily maintenance
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
RandomizedDelaySec=1h
|
||||
Persistent=true
|
||||
Unit=virtuality-maintenance.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Virtuality Console Dashboard
|
||||
# btop-like compact TUI for physical server console tty1
|
||||
|
||||
refresh_interval="${VIRTUALITY_DASHBOARD_INTERVAL:-2}"
|
||||
|
||||
has_cmd() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
ESC="\033"
|
||||
RESET="${ESC}[0m"
|
||||
BOLD="${ESC}[1m"
|
||||
DIM="${ESC}[2m"
|
||||
FG_CYAN="${ESC}[38;5;51m"
|
||||
FG_BLUE="${ESC}[38;5;39m"
|
||||
FG_GREEN="${ESC}[38;5;46m"
|
||||
FG_YELLOW="${ESC}[38;5;226m"
|
||||
FG_RED="${ESC}[38;5;196m"
|
||||
FG_MAGENTA="${ESC}[38;5;201m"
|
||||
FG_GRAY="${ESC}[38;5;245m"
|
||||
FG_WHITE="${ESC}[38;5;255m"
|
||||
BG_PANEL="${ESC}[48;5;235m"
|
||||
BG_HEADER="${ESC}[48;5;24m"
|
||||
|
||||
move() { printf "${ESC}[%s;%sH" "$1" "$2"; }
|
||||
clear_screen() { printf "${ESC}[2J${ESC}[H"; }
|
||||
hide_cursor() { printf "${ESC}[?25l"; }
|
||||
show_cursor() { printf "${ESC}[?25h${RESET}"; }
|
||||
trap show_cursor EXIT
|
||||
|
||||
term_cols() { tput cols 2>/dev/null || echo 120; }
|
||||
term_rows() { tput lines 2>/dev/null || echo 40; }
|
||||
|
||||
repeat_char() {
|
||||
local char="$1" count="$2"
|
||||
printf '%*s' "$count" '' | tr ' ' "$char"
|
||||
}
|
||||
|
||||
strip_ansi() {
|
||||
sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g'
|
||||
}
|
||||
|
||||
print_fit() {
|
||||
local text="$1" width="$2"
|
||||
printf "%-${width}.${width}s" "$text"
|
||||
}
|
||||
|
||||
box() {
|
||||
local row="$1" col="$2" height="$3" width="$4" title="$5"
|
||||
local inner=$((width - 2))
|
||||
move "$row" "$col"; printf "${FG_BLUE}╭%s╮${RESET}" "$(repeat_char '─' "$inner")"
|
||||
move "$row" $((col + 2)); printf "${BG_PANEL}${FG_CYAN}${BOLD} %s ${RESET}" "$title"
|
||||
for ((i=1; i<height-1; i++)); do
|
||||
move $((row+i)) "$col"; printf "${FG_BLUE}│${RESET}"
|
||||
printf "${BG_PANEL}%s${RESET}" "$(repeat_char ' ' "$inner")"
|
||||
printf "${FG_BLUE}│${RESET}"
|
||||
done
|
||||
move $((row+height-1)) "$col"; printf "${FG_BLUE}╰%s╯${RESET}" "$(repeat_char '─' "$inner")"
|
||||
}
|
||||
|
||||
put() {
|
||||
local row="$1" col="$2" width="$3" text="$4"
|
||||
move "$row" "$col"
|
||||
printf "${BG_PANEL}"
|
||||
printf "%b" "$text" | cut -c 1-"$width"
|
||||
printf "${RESET}"
|
||||
}
|
||||
|
||||
bar() {
|
||||
local percent="$1" width="$2"
|
||||
local filled=$(( percent * width / 100 ))
|
||||
local empty=$(( width - filled ))
|
||||
local color="$FG_GREEN"
|
||||
if (( percent >= 85 )); then color="$FG_RED"; elif (( percent >= 65 )); then color="$FG_YELLOW"; fi
|
||||
printf "%b%s%b%s" "$color" "$(repeat_char '█' "$filled")" "$DIM" "$(repeat_char '░' "$empty")"
|
||||
printf "%b" "$RESET"
|
||||
}
|
||||
|
||||
service_state() {
|
||||
local unit="$1"
|
||||
if systemctl list-unit-files "$unit" >/dev/null 2>&1; then
|
||||
systemctl is-active "$unit" 2>/dev/null || echo "inactive"
|
||||
else
|
||||
echo "missing"
|
||||
fi
|
||||
}
|
||||
|
||||
state_badge() {
|
||||
local state="$1"
|
||||
case "$state" in
|
||||
active) printf "${FG_GREEN}● active${RESET}" ;;
|
||||
inactive) printf "${FG_YELLOW}● inactive${RESET}" ;;
|
||||
failed) printf "${FG_RED}● failed${RESET}" ;;
|
||||
missing) printf "${FG_GRAY}● missing${RESET}" ;;
|
||||
*) printf "${FG_YELLOW}● %s${RESET}" "$state" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
get_cpu_usage() {
|
||||
local a b idle_a total_a idle_b total_b diff_idle diff_total usage
|
||||
read -r _ a b c idle_a rest < /proc/stat
|
||||
total_a=$((a+b+c+idle_a))
|
||||
sleep 0.15
|
||||
read -r _ a b c idle_b rest < /proc/stat
|
||||
total_b=$((a+b+c+idle_b))
|
||||
diff_idle=$((idle_b-idle_a))
|
||||
diff_total=$((total_b-total_a))
|
||||
if (( diff_total <= 0 )); then echo 0; else echo $(( (100 * (diff_total - diff_idle)) / diff_total )); fi
|
||||
}
|
||||
|
||||
get_mem_percent() {
|
||||
free | awk '/Mem:/ {printf "%d", ($3/$2)*100}'
|
||||
}
|
||||
|
||||
get_disk_percent() {
|
||||
df / | awk 'NR==2 {gsub("%", "", $5); print $5}'
|
||||
}
|
||||
|
||||
while true; do
|
||||
cols="$(term_cols)"
|
||||
rows="$(term_rows)"
|
||||
[[ "$cols" -lt 100 ]] && cols=100
|
||||
[[ "$rows" -lt 32 ]] && rows=32
|
||||
|
||||
cpu="$(get_cpu_usage)"
|
||||
mem="$(get_mem_percent)"
|
||||
disk="$(get_disk_percent)"
|
||||
load="$(awk '{print $1" "$2" "$3}' /proc/loadavg)"
|
||||
uptime_text="$(uptime -p 2>/dev/null | sed 's/up //' || true)"
|
||||
host="$(hostname)"
|
||||
now="$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
ip_main="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||
gateway="$(ip route | awk '/default/ {print $3" via "$5; exit}')"
|
||||
|
||||
clear_screen
|
||||
hide_cursor
|
||||
|
||||
move 1 1
|
||||
printf "${BG_HEADER}${FG_WHITE}${BOLD} %-20s ${FG_CYAN}%-30s ${FG_GRAY}%s ${RESET}" "VIRTUALITY" "$host" "$now"
|
||||
printf "%s" "$(repeat_char ' ' $((cols-70 > 0 ? cols-70 : 1)))"
|
||||
|
||||
# Layout
|
||||
left_w=48
|
||||
right_w=$((cols - left_w - 5))
|
||||
[[ "$right_w" -lt 48 ]] && right_w=48
|
||||
|
||||
box 3 2 10 "$left_w" "SYSTEM"
|
||||
box 3 $((left_w + 4)) 10 "$right_w" "SERVICES"
|
||||
box 14 2 8 "$left_w" "NETWORK"
|
||||
box 14 $((left_w + 4)) 8 "$right_w" "VIRTUAL MACHINES"
|
||||
box 23 2 8 "$left_w" "STORAGE"
|
||||
box 23 $((left_w + 4)) 8 "$right_w" "LIBVIRT POOLS"
|
||||
|
||||
# System panel
|
||||
put 5 4 $((left_w-4)) "${FG_GRAY}CPU ${FG_WHITE}${cpu}% $(bar "$cpu" 26)"
|
||||
put 6 4 $((left_w-4)) "${FG_GRAY}RAM ${FG_WHITE}${mem}% $(bar "$mem" 26)"
|
||||
put 7 4 $((left_w-4)) "${FG_GRAY}DSK ${FG_WHITE}${disk}% $(bar "$disk" 26)"
|
||||
put 8 4 $((left_w-4)) "${FG_GRAY}Load:${RESET} ${FG_WHITE}${load}${RESET}"
|
||||
put 9 4 $((left_w-4)) "${FG_GRAY}Uptime:${RESET} ${FG_WHITE}${uptime_text}${RESET}"
|
||||
put 10 4 $((left_w-4)) "${FG_GRAY}Kernel:${RESET} ${FG_WHITE}$(uname -r)${RESET}"
|
||||
|
||||
# Services panel
|
||||
svc_col=$((left_w + 6))
|
||||
svc_w=$((right_w - 4))
|
||||
put 5 "$svc_col" "$svc_w" "libvirtd.service $(state_badge "$(service_state libvirtd.service)")"
|
||||
put 6 "$svc_col" "$svc_w" "virtlogd.service $(state_badge "$(service_state virtlogd.service)")"
|
||||
put 7 "$svc_col" "$svc_w" "cockpit.socket $(state_badge "$(service_state cockpit.socket)")"
|
||||
put 8 "$svc_col" "$svc_w" "libvirt-guests.service $(state_badge "$(service_state libvirt-guests.service)")"
|
||||
put 10 "$svc_col" "$svc_w" "${FG_GRAY}Cockpit:${RESET} ${FG_WHITE}https://${ip_main}:9090${RESET}"
|
||||
|
||||
# Network panel
|
||||
put 16 4 $((left_w-4)) "${FG_GRAY}Main IP:${RESET} ${FG_GREEN}${ip_main}${RESET}"
|
||||
put 17 4 $((left_w-4)) "${FG_GRAY}Gateway:${RESET} ${FG_WHITE}${gateway}${RESET}"
|
||||
net_lines="$(ip -br a 2>/dev/null | head -n 3)"
|
||||
n=0
|
||||
while IFS= read -r line; do
|
||||
put $((18+n)) 4 $((left_w-4)) "${FG_WHITE}${line}${RESET}"
|
||||
n=$((n+1))
|
||||
done <<< "$net_lines"
|
||||
|
||||
# VM panel
|
||||
vm_col=$((left_w + 6))
|
||||
vm_lines="$(virsh list --all 2>/dev/null | tail -n +3 | sed '/^$/d' | head -n 4 || true)"
|
||||
if [[ -z "$vm_lines" ]]; then
|
||||
put 16 "$vm_col" "$svc_w" "${FG_GRAY}VM пока нет. Следующий этап — создать тестовую VM.${RESET}"
|
||||
else
|
||||
n=0
|
||||
while IFS= read -r line; do
|
||||
put $((16+n)) "$vm_col" "$svc_w" "${FG_WHITE}${line}${RESET}"
|
||||
n=$((n+1))
|
||||
done <<< "$vm_lines"
|
||||
fi
|
||||
|
||||
# Storage panel
|
||||
srow=25
|
||||
for dir in /var/lib/virtuality/iso /var/lib/virtuality/images /var/lib/virtuality/backups /var/log/virtuality; do
|
||||
if [[ -d "$dir" ]]; then
|
||||
used="$(du -sh "$dir" 2>/dev/null | awk '{print $1}')"
|
||||
put "$srow" 4 $((left_w-4)) "${FG_GRAY}${dir}:${RESET} ${FG_WHITE}${used}${RESET}"
|
||||
else
|
||||
put "$srow" 4 $((left_w-4)) "${FG_GRAY}${dir}:${RESET} ${FG_RED}missing${RESET}"
|
||||
fi
|
||||
srow=$((srow+1))
|
||||
done
|
||||
|
||||
# Pools panel
|
||||
pool_lines="$(virsh pool-list --all 2>/dev/null | tail -n +3 | sed '/^$/d' | head -n 4 || true)"
|
||||
if [[ -z "$pool_lines" ]]; then
|
||||
put 25 "$vm_col" "$svc_w" "${FG_GRAY}Storage pools unavailable${RESET}"
|
||||
else
|
||||
n=0
|
||||
while IFS= read -r line; do
|
||||
put $((25+n)) "$vm_col" "$svc_w" "${FG_WHITE}${line}${RESET}"
|
||||
n=$((n+1))
|
||||
done <<< "$pool_lines"
|
||||
fi
|
||||
|
||||
move $((rows-2)) 2
|
||||
printf "${FG_GRAY}Refresh:${RESET} ${FG_WHITE}${refresh_interval}s${RESET} ${FG_GRAY}|${RESET} ${FG_CYAN}Ctrl+Alt+F2${RESET} login ${FG_GRAY}|${RESET} ${FG_GREEN}bt${RESET}=btop ${FG_GRAY}|${RESET} stop: ${FG_YELLOW}sudo systemctl stop virtuality-console-dashboard${RESET}"
|
||||
move $((rows-1)) 1
|
||||
printf "${RESET}"
|
||||
sleep "$refresh_interval"
|
||||
done
|
||||
@@ -1,239 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ==========================================================
|
||||
# Virtuality first boot
|
||||
# Runs once on a system installed from the Virtuality image.
|
||||
# The web panel is installed first (offline, from bundled
|
||||
# wheels) so the setup wizard in the browser can show the
|
||||
# progress of everything that follows: network, KVM/libvirt,
|
||||
# tools. Progress is written to config/setup.json, which the
|
||||
# wizard polls. Every step is idempotent: on failure systemd
|
||||
# restarts the script after a minute and it continues from
|
||||
# the first unfinished step.
|
||||
# ==========================================================
|
||||
|
||||
SOURCE_DIR="${VIRTUALITY_SOURCE_DIR:-/opt/virtuality/source}"
|
||||
IMAGE_ENV="${VIRTUALITY_IMAGE_ENV:-/etc/virtuality/image.env}"
|
||||
STATE_DIR="${VIRTUALITY_STATE_DIR:-/var/lib/virtuality}"
|
||||
CONFIG_DIR="${STATE_DIR}/config"
|
||||
STATE_FILE="${CONFIG_DIR}/setup.json"
|
||||
DONE_FLAG="${STATE_DIR}/.firstboot-done"
|
||||
STEP_FLAG_DIR="${STATE_DIR}/.firstboot-steps"
|
||||
LOG_DIR="${VIRTUALITY_LOG_DIR:-/var/log/virtuality}"
|
||||
LOG_FILE="${LOG_DIR}/firstboot.log"
|
||||
APT_LOCK_CONF="${VIRTUALITY_APT_LOCK_CONF:-/etc/apt/apt.conf.d/90virtuality-lock-timeout}"
|
||||
ISSUE_FILE="${VIRTUALITY_ISSUE_FILE:-/etc/issue.d/virtuality.issue}"
|
||||
CTL="${VIRTUALITY_CTL:-/usr/local/bin/virtuality-ctl}"
|
||||
NETWORK_WAIT_SECONDS="${VIRTUALITY_NETWORK_WAIT_SECONDS:-0}" # 0 = wait forever
|
||||
|
||||
STEP_IDS=(panel network virtualization tools finish)
|
||||
declare -A STEP_TITLE=(
|
||||
[panel]="Панель управления"
|
||||
[network]="Подключение к интернету"
|
||||
[virtualization]="KVM, QEMU и libvirt"
|
||||
[tools]="Инструменты диагностики"
|
||||
[finish]="Завершение"
|
||||
)
|
||||
declare -A STEP_STATUS=()
|
||||
CURRENT_STEP=""
|
||||
WATCHER_PID=""
|
||||
|
||||
mkdir -p "$LOG_DIR" "$STATE_DIR" "$CONFIG_DIR" "$STEP_FLAG_DIR" "$(dirname "$ISSUE_FILE")"
|
||||
exec > >(tee -a "$LOG_FILE") 2>&1
|
||||
|
||||
now() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||
say() { echo "[$(now)] [virtuality-firstboot] $*"; }
|
||||
|
||||
# ---------------------------------------------------------------- progress for the wizard
|
||||
json_escape() {
|
||||
local text="$1"
|
||||
text="${text//\\/\\\\}"
|
||||
text="${text//\"/\\\"}"
|
||||
text="${text//$'\n'/ }"
|
||||
printf '%s' "$text"
|
||||
}
|
||||
|
||||
write_state() {
|
||||
local stage="$1" message="$2" id first=1
|
||||
{
|
||||
printf '{"stage": "%s", "message": "%s", "updated_at": "%s", "steps": [' "$stage" "$(json_escape "$message")" "$(now)"
|
||||
for id in "${STEP_IDS[@]}"; do
|
||||
(( first )) || printf ', '
|
||||
first=0
|
||||
printf '{"id": "%s", "title": "%s", "status": "%s"}' "$id" "${STEP_TITLE[$id]}" "${STEP_STATUS[$id]:-pending}"
|
||||
done
|
||||
printf ']}\n'
|
||||
} > "${STATE_FILE}.tmp"
|
||||
mv -f "${STATE_FILE}.tmp" "$STATE_FILE"
|
||||
chmod 644 "$STATE_FILE"
|
||||
}
|
||||
|
||||
step_done_before() { [[ -f "${STEP_FLAG_DIR}/$1" ]]; }
|
||||
|
||||
step_start() {
|
||||
CURRENT_STEP="$1"
|
||||
STEP_STATUS[$1]="running"
|
||||
say "step ${1}: ${STEP_TITLE[$1]}"
|
||||
write_state installing "$2"
|
||||
}
|
||||
|
||||
step_finish() {
|
||||
STEP_STATUS[$1]="done"
|
||||
touch "${STEP_FLAG_DIR}/$1"
|
||||
CURRENT_STEP=""
|
||||
write_state installing "${2:-}"
|
||||
}
|
||||
|
||||
# apt writes its progress to the installer logs; turn the last line into a friendly message.
|
||||
watch_progress() {
|
||||
local latest line pkg
|
||||
while true; do
|
||||
latest="$(ls -t "${LOG_DIR}"/install_*.log 2>/dev/null | head -n1 || true)"
|
||||
if [[ -n "$latest" ]]; then
|
||||
line="$(grep -aE '^(Get:[0-9]+ |Unpacking |Setting up |Preparing to unpack )' "$latest" 2>/dev/null | tail -n1 || true)"
|
||||
pkg=""
|
||||
case "$line" in
|
||||
Get:*) pkg="$(printf '%s' "$line" | awk '{print $5}')" ; [[ -n "$pkg" ]] && write_state installing "Скачиваем ${pkg}…" ;;
|
||||
"Unpacking "*|"Preparing to unpack "*) pkg="$(printf '%s' "$line" | sed -E 's/^(Preparing to unpack \.\.\.\/|Preparing to unpack |Unpacking )//; s/[ :].*//')"; [[ -n "$pkg" ]] && write_state installing "Распаковываем ${pkg}…" ;;
|
||||
"Setting up "*) pkg="$(printf '%s' "$line" | sed -E 's/^Setting up //; s/[ :].*//')"; [[ -n "$pkg" ]] && write_state installing "Настраиваем ${pkg}…" ;;
|
||||
esac
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
}
|
||||
|
||||
watch_start() { watch_progress & WATCHER_PID=$!; }
|
||||
watch_stop() { [[ -n "$WATCHER_PID" ]] && { kill "$WATCHER_PID" 2>/dev/null || true; wait "$WATCHER_PID" 2>/dev/null || true; }; WATCHER_PID=""; }
|
||||
|
||||
on_error() {
|
||||
local code=$?
|
||||
watch_stop
|
||||
if [[ -n "$CURRENT_STEP" ]]; then
|
||||
STEP_STATUS[$CURRENT_STEP]="error"
|
||||
fi
|
||||
write_state error "Шаг «${STEP_TITLE[${CURRENT_STEP:-finish}]}» завершился с ошибкой (код ${code}). Повторим автоматически через минуту; подробности ниже в журнале."
|
||||
say "ERROR: step ${CURRENT_STEP:-?} failed with code ${code}"
|
||||
exit "$code"
|
||||
}
|
||||
trap on_error ERR
|
||||
|
||||
# ---------------------------------------------------------------- environment
|
||||
if [[ -f "$DONE_FLAG" ]]; then
|
||||
say "already completed, nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -f "$IMAGE_ENV" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$IMAGE_ENV"
|
||||
set +a
|
||||
fi
|
||||
|
||||
first_human_user() {
|
||||
getent passwd | awk -F: '$3 >= 1000 && $3 < 60000 && $7 !~ /(nologin|false)$/ {print $1; exit}'
|
||||
}
|
||||
|
||||
AUTH_USER="${VIRTUALITY_USER:-$(first_human_user)}"
|
||||
AUTH_USER="${AUTH_USER:-root}"
|
||||
export VIRTUALITY_USER="$AUTH_USER"
|
||||
export VIRTUALITY_AUTH_USER="$AUTH_USER"
|
||||
export VIRTUALITY_WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}"
|
||||
export VIRTUALITY_AUTO_UPDATE="${VIRTUALITY_AUTO_UPDATE:-1}"
|
||||
# Disk/RAM limits are reported in the panel; they must not block an image install.
|
||||
export VIRTUALITY_SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-1}"
|
||||
export VIRTUALITY_FIRSTBOOT=1
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export TERM="${TERM:-dumb}"
|
||||
|
||||
for id in "${STEP_IDS[@]}"; do
|
||||
step_done_before "$id" && STEP_STATUS[$id]="done"
|
||||
done
|
||||
|
||||
say "source: ${SOURCE_DIR}, panel user: ${AUTH_USER}, port: ${VIRTUALITY_WEB_PORT}"
|
||||
[[ -f "${SOURCE_DIR}/install_virtuality_node.sh" ]] || { write_state error "Исходники Virtuality не найдены в ${SOURCE_DIR}"; say "ERROR: source not found"; exit 1; }
|
||||
cd "$SOURCE_DIR"
|
||||
|
||||
# unattended-upgrades often holds the dpkg lock right after the first boot.
|
||||
printf 'DPkg::Lock::Timeout "900";\nDPkg::Options { "--force-confdef"; "--force-confold"; };\n' > "$APT_LOCK_CONF"
|
||||
|
||||
cat > "$ISSUE_FILE" <<'EOF'
|
||||
Virtuality настраивается. Ход установки: sudo journalctl -fu virtuality-firstboot
|
||||
|
||||
EOF
|
||||
|
||||
network_ready() {
|
||||
[[ -n "${VIRTUALITY_NETWORK_CHECK:-}" ]] && { "$VIRTUALITY_NETWORK_CHECK"; return; }
|
||||
ip route show default 2>/dev/null | grep -q . && getent hosts archive.ubuntu.com >/dev/null 2>&1
|
||||
}
|
||||
|
||||
wait_for_network() {
|
||||
local started elapsed
|
||||
started="$(date +%s)"
|
||||
until network_ready; do
|
||||
elapsed=$(( $(date +%s) - started ))
|
||||
if (( NETWORK_WAIT_SECONDS > 0 && elapsed >= NETWORK_WAIT_SECONDS )); then
|
||||
say "ERROR: no network with DNS after ${elapsed}s"
|
||||
return 1
|
||||
fi
|
||||
if (( elapsed >= 20 )); then
|
||||
write_state waiting-network "Нет доступа в интернет. Подключите кабель или настройте сеть — установка продолжится сама."
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
write_state installing "Интернет доступен"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- 1. panel (offline)
|
||||
if ! step_done_before panel; then
|
||||
step_start panel "Устанавливаем панель управления…"
|
||||
if ! VIRTUALITY_PANEL_ONLY=1 bash ./scripts/install_web_panel.sh; then
|
||||
# No bundled wheels (image built with --no-wheels): install online instead.
|
||||
say "offline panel install failed, retrying with network"
|
||||
wait_for_network
|
||||
bash ./scripts/install_web_panel.sh
|
||||
fi
|
||||
step_finish panel "Панель управления запущена"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 2. network
|
||||
if ! step_done_before network; then
|
||||
step_start network "Проверяем подключение к интернету…"
|
||||
wait_for_network
|
||||
step_finish network "Интернет доступен"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 3. virtualization
|
||||
if ! step_done_before virtualization; then
|
||||
step_start virtualization "Устанавливаем пакеты виртуализации (это самый долгий шаг)…"
|
||||
watch_start
|
||||
bash ./install_virtuality_node.sh
|
||||
watch_stop
|
||||
step_finish virtualization "Виртуализация установлена"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 4. tools
|
||||
if ! step_done_before tools; then
|
||||
step_start tools "Устанавливаем инструменты диагностики…"
|
||||
bash ./scripts/install_healthcheck_command.sh
|
||||
step_finish tools "Инструменты установлены"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 5. finish
|
||||
step_start finish "Завершаем настройку…"
|
||||
# The panel was installed before libvirt: refresh units/firewall and restart it with everything in place.
|
||||
"$CTL" reconfigure
|
||||
PANEL_URL="$("$CTL" url 2>/dev/null || echo "http://$(hostname -I | awk '{print $1}'):${VIRTUALITY_WEB_PORT}")"
|
||||
cat > "$ISSUE_FILE" <<EOF
|
||||
Virtuality: откройте панель управления в браузере — ${PANEL_URL}
|
||||
Логин: ${AUTH_USER} (пароль как у пользователя Linux)
|
||||
|
||||
EOF
|
||||
touch "$DONE_FLAG"
|
||||
STEP_STATUS[finish]="done"
|
||||
CURRENT_STEP=""
|
||||
write_state done "Сервер готов"
|
||||
systemctl disable virtuality-firstboot.service >/dev/null 2>&1 || true
|
||||
rm -f "$APT_LOCK_CONF"
|
||||
say "completed. Panel: ${PANEL_URL} login: ${AUTH_USER}"
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="/opt/virtuality"
|
||||
DEFAULT_BRANCH="main"
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
echo "Ошибка: запусти от root: sudo bash setup_github_sync.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
apt update
|
||||
apt install -y git openssh-client
|
||||
|
||||
if [[ ! -d "$PROJECT_DIR" ]]; then
|
||||
echo "Ошибка: папка проекта не найдена: $PROJECT_DIR"
|
||||
echo "Сначала запусти install_virtuality_node.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
read -rp "GitHub repo URL [git@github.com:viktor138irk/virtuality.git]: " GITHUB_REPO
|
||||
GITHUB_REPO="${GITHUB_REPO:-git@github.com:viktor138irk/virtuality.git}"
|
||||
|
||||
read -rp "Git author name [Virtuality Node]: " GIT_NAME
|
||||
GIT_NAME="${GIT_NAME:-Virtuality Node}"
|
||||
|
||||
read -rp "Git author email [virtuality@local]: " GIT_EMAIL
|
||||
GIT_EMAIL="${GIT_EMAIL:-virtuality@local}"
|
||||
|
||||
git config --global user.name "$GIT_NAME"
|
||||
git config --global user.email "$GIT_EMAIL"
|
||||
|
||||
cat > "$PROJECT_DIR/git_sync.sh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="/opt/virtuality"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
MESSAGE="${1:-Auto sync $(date '+%Y-%m-%d %H:%M:%S')}"
|
||||
|
||||
git add .
|
||||
git commit -m "$MESSAGE" || {
|
||||
echo "Нет изменений для commit."
|
||||
exit 0
|
||||
}
|
||||
|
||||
git push origin main
|
||||
EOF
|
||||
|
||||
chmod +x "$PROJECT_DIR/git_sync.sh"
|
||||
|
||||
if [[ ! -d "$PROJECT_DIR/.git" ]]; then
|
||||
git init
|
||||
fi
|
||||
|
||||
git branch -M "$DEFAULT_BRANCH"
|
||||
|
||||
if git remote get-url origin >/dev/null 2>&1; then
|
||||
git remote set-url origin "$GITHUB_REPO"
|
||||
else
|
||||
git remote add origin "$GITHUB_REPO"
|
||||
fi
|
||||
|
||||
git add .
|
||||
git commit -m "Initial Virtuality node base" || true
|
||||
git push -u origin "$DEFAULT_BRANCH"
|
||||
|
||||
echo "GitHub sync готов."
|
||||
echo "Ручная синхронизация: sudo /opt/virtuality/git_sync.sh \"Описание изменений\""
|
||||
@@ -1,197 +1,38 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WEB_DIR = ROOT / "web"
|
||||
WEB_DIR = Path(__file__).resolve().parent.parent / "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):
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_dirs(tmp_path, monkeypatch):
|
||||
"""Уводим все записи на диск в tmp, чтобы тесты работали без root и не трогали систему."""
|
||||
import app
|
||||
import core
|
||||
import host_profile
|
||||
import network_core
|
||||
import update_core
|
||||
import backup_core
|
||||
import cloud_images
|
||||
|
||||
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
|
||||
monkeypatch.setattr(app, "OPERATIONS_DIR", tmp_path / "ops")
|
||||
monkeypatch.setattr(app, "ISO_DIR", tmp_path / "iso")
|
||||
monkeypatch.setattr(app, "IMAGES_DIR", tmp_path / "images")
|
||||
monkeypatch.setattr(app, "DISK_IMAGES_DIR", tmp_path / "disk-images")
|
||||
monkeypatch.setattr(app, "VM_TEMPLATES_FILE", tmp_path / "config" / "vm_templates.json")
|
||||
monkeypatch.setattr(backup_core, "BACKUPS_DIR", tmp_path / "backups")
|
||||
monkeypatch.setattr(backup_core, "SCHEDULE_FILE", tmp_path / "config" / "backup_schedule.json")
|
||||
monkeypatch.setattr(backup_core, "LOG_FILE", tmp_path / "log" / "backup.log")
|
||||
monkeypatch.setattr(cloud_images, "CLOUD_IMAGES_DIR", tmp_path / "cloud-images")
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(data_dirs):
|
||||
def auth_client():
|
||||
"""TestClient с валидной сессией и CSRF-токеном."""
|
||||
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
|
||||
client = TestClient(app.app)
|
||||
token = app.serializer.dumps({"user": app.AUTH_USER, "csrf": "test-csrf-token"})
|
||||
client.cookies.set("virtuality_session", token)
|
||||
return client
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the panel locally with fake virsh/systemctl for UI work.
|
||||
|
||||
python3 tests/dev_server.py [port] # login: tester / any password
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import conftest # noqa: E402 (sets sys.path and env)
|
||||
|
||||
import app # noqa: E402
|
||||
import core # noqa: E402
|
||||
import host_profile # noqa: E402
|
||||
import network_core # noqa: E402
|
||||
import update_core # noqa: E402
|
||||
|
||||
# Машины демо-сервера: имя → состояние virsh (web01 работает, db01 выключена, win11 на паузе).
|
||||
DEMO_VMS = {"web01": "running", "db01": "shut off", "win11": "paused"}
|
||||
DEMO_NOTES = {"web01": ("Сайт компании", "Nginx + база. Бэкап по пятницам.\nЗа сервер отвечает Иван."), "win11": ("Бухгалтерия 1С", "")}
|
||||
DEMO_DOMSTATS = """Domain: 'web01'
|
||||
cpu.time={cpu_web}
|
||||
balloon.current=2097152
|
||||
balloon.maximum=2097152
|
||||
balloon.rss=1534208
|
||||
vcpu.current=2
|
||||
vcpu.maximum=2
|
||||
|
||||
Domain: 'win11'
|
||||
cpu.time={cpu_win}
|
||||
balloon.current=4194304
|
||||
balloon.maximum=4194304
|
||||
balloon.rss=3102720
|
||||
vcpu.current=4
|
||||
vcpu.maximum=4
|
||||
|
||||
"""
|
||||
_cpu_clock = {"web": 48_123_456_789, "win": 90_000_000_000, "at": time.monotonic()}
|
||||
|
||||
|
||||
def demo_vm_run_cmd(cmd, timeout=12, **kwargs):
|
||||
"""Три машины в разных состояниях и живая нагрузка; остальное — conftest.fake_run_cmd."""
|
||||
if cmd[:2] == ["virsh", "list"]:
|
||||
if "--title" in cmd:
|
||||
rows = "\n".join(f" {'1' if state == 'running' else '2' if state == 'paused' else '-':<4} {name:<7} {state:<10} {DEMO_NOTES.get(name, ('', ''))[0]}" for name, state in DEMO_VMS.items())
|
||||
return conftest.fake_result(" Id Name State Title\n" + "-" * 44 + "\n" + rows + "\n")
|
||||
if "--name" in cmd:
|
||||
names = [name for name, state in DEMO_VMS.items() if "--all" in cmd or state != "shut off"]
|
||||
return conftest.fake_result("\n".join(names) + "\n")
|
||||
rows = "\n".join(f" {'1' if state == 'running' else '2' if state == 'paused' else '-':<4} {name:<7} {state}" for name, state in DEMO_VMS.items())
|
||||
return conftest.fake_result(" Id Name State\n" + "-" * 25 + "\n" + rows)
|
||||
if cmd[:2] in (["virsh", "dominfo"], ["virsh", "domstate"]):
|
||||
name = cmd[-1]
|
||||
if name not in DEMO_VMS:
|
||||
return conftest.fake_result(ok=False, stderr=f"error: failed to get domain '{name}'")
|
||||
if cmd[1] == "domstate":
|
||||
return conftest.fake_result(DEMO_VMS[name])
|
||||
info = conftest.DOMINFO.replace("web01", name).replace("State: running", f"State: {DEMO_VMS[name]}")
|
||||
if name == "db01":
|
||||
info += "Managed save: yes\n"
|
||||
return conftest.fake_result(info)
|
||||
if cmd[:2] == ["virsh", "domstats"]:
|
||||
# Процессорное время растёт вместе с реальным: web01 нагружена на ~37 % (2 ядра), win11 на ~8 % (4 ядра).
|
||||
now = time.monotonic()
|
||||
elapsed_ns = (now - _cpu_clock["at"]) * 1e9
|
||||
_cpu_clock.update(web=_cpu_clock["web"] + int(elapsed_ns * 2 * 0.37), win=_cpu_clock["win"] + int(elapsed_ns * 4 * 0.08), at=now)
|
||||
return conftest.fake_result(DEMO_DOMSTATS.format(cpu_web=_cpu_clock["web"], cpu_win=_cpu_clock["win"]))
|
||||
if cmd[:2] == ["virsh", "domblklist"]:
|
||||
return conftest.fake_result((conftest.FIXTURES / "domblklist-details.txt").read_text().replace("/var/lib/virtuality/images/web01", str(core.IMAGES_DIR / cmd[2])))
|
||||
if cmd[:1] == ["virsh"] and "desc" in cmd[:3] and not any(arg.startswith("--new-desc") for arg in cmd):
|
||||
title, description = DEMO_NOTES.get(cmd[cmd.index("desc") + 1], ("", ""))
|
||||
return conftest.fake_result(title if "--title" in cmd else description)
|
||||
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
|
||||
|
||||
|
||||
# Снимки для демо: имя, дата, состояние, описание.
|
||||
DEMO_SNAPSHOTS = [
|
||||
("snap-20260925-1012", "2026-09-25 10:12:03 +0000", "shutoff", "Перед обновлением ядра"),
|
||||
("snap-20260924-0900", "2026-09-24 09:00:41 +0000", "running", "Настроен nginx и сертификаты"),
|
||||
("snap-20260918-1830", "2026-09-18 18:30:12 +0000", "running", "Чистая система после установки"),
|
||||
("clean", "2026-09-10 12:05:00 +0000", "shutoff", ""),
|
||||
]
|
||||
|
||||
|
||||
def demo_run_cmd(cmd, timeout=12, **kwargs):
|
||||
"""Как conftest.fake_run_cmd, но со списком снимков побогаче."""
|
||||
if cmd[:2] == ["virsh", "snapshot-list"]:
|
||||
rows = "\n".join(f" {name:<20} {when} {state}" for name, when, state, _ in DEMO_SNAPSHOTS)
|
||||
return conftest.fake_result(" Name Creation Time State\n" + "-" * 62 + "\n" + rows)
|
||||
if cmd[:2] == ["virsh", "snapshot-current"]:
|
||||
return conftest.fake_result("snap-20260924-0900")
|
||||
if cmd[:2] == ["virsh", "snapshot-create-as"] and "uefi" in conftest.option(cmd, "--description").lower():
|
||||
# Описание со словом «uefi» показывает, как выглядит ошибка libvirt.
|
||||
return conftest.fake_result(ok=False, stderr="error: unsupported configuration: internal snapshots of a VM with pflash based firmware are not supported")
|
||||
if cmd[:2] == ["virsh", "snapshot-dumpxml"]:
|
||||
snap = conftest.option(cmd, "--snapshotname")
|
||||
state, description = next(((s, d) for n, _, s, d in DEMO_SNAPSHOTS if n == snap), ("shutoff", ""))
|
||||
memory = "internal" if state == "running" else "no"
|
||||
return conftest.fake_result(f"<domainsnapshot><name>{snap}</name><description>{description}</description><state>{state}</state><memory snapshot='{memory}'/></domainsnapshot>")
|
||||
return demo_vm_run_cmd(cmd, timeout, **kwargs)
|
||||
|
||||
|
||||
def seed_backups(backups_dir: Path) -> None:
|
||||
"""Несколько копий, как после пары недель работы: web01 — три (две по расписанию), db01 — одна."""
|
||||
vm_xml = conftest.DUMPXML_MIGRATABLE
|
||||
demo = [
|
||||
("web01", "20260924-0300", 2_684_354_560, "По расписанию", [("vda", 6_443_499_520)]),
|
||||
("web01", "20260917-0300", 2_598_000_000, "По расписанию", [("vda", 6_300_000_000)]),
|
||||
("web01", "20260912-1842", 2_412_000_000, "Перед обновлением сайта", [("vda", 6_100_000_000)]),
|
||||
("db01", "20260921-2215", 9_126_805_504, "После переноса базы", [("vda", 12_884_901_888), ("vdb", 8_589_934_592)]),
|
||||
]
|
||||
for vm, backup_id, size, note, disks in demo:
|
||||
path = backups_dir / vm / backup_id
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
(path / "vm.xml").write_text(vm_xml.replace("web01", vm))
|
||||
for target, _ in disks:
|
||||
with (path / f"{target}.qcow2").open("wb") as handle:
|
||||
handle.truncate(1024 * 1024)
|
||||
meta = {"vm": vm, "created_at": f"{backup_id[:4]}-{backup_id[4:6]}-{backup_id[6:8]} {backup_id[9:11]}:{backup_id[11:]}:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": s} for t, s in disks], "size_bytes": size, "version": "0.11.0", "note": note, "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")
|
||||
@@ -1,3 +0,0 @@
|
||||
5861314d7fccb39c2192173240eab44fa35ca66426201ca2acd0630a6258dd51 *resolute-server-cloudimg-amd64.img
|
||||
f69162950f235e3cdbbad33f1f912d1a504be90d8a37d002c735d6f3e3882265 *resolute-server-cloudimg-arm64.img
|
||||
e0361263a9f568c21a59e75c3b7810bc5867866e5bd48f499f8b2e6ab723f059 *resolute-server-cloudimg-amd64.vmdk
|
||||
@@ -1,3 +0,0 @@
|
||||
# generated by debian cloud team
|
||||
1412d62c2d55ae2432df6d22c36b9edf4512a59a43bb4eadb1bd3a5ef201154a3052d83ffae704d7079b50388e61992013348e4bb4dee45b3c2a266e2b296135 debian-13-genericcloud-amd64.qcow2
|
||||
27c03aab0b5ee1f5918bfaa94821d2a1500fc171ffeb0ae159e7d18662f49171551f397683004434096e4006380fa607577b3c90fafa1eceb3a92702ebd49a15 debian-13-genericcloud-arm64.qcow2
|
||||
@@ -1,3 +0,0 @@
|
||||
Capacity: 42949672960
|
||||
Allocation: 6442450944
|
||||
Physical: 6443499520
|
||||
@@ -1,4 +0,0 @@
|
||||
Type Device Target Source
|
||||
------------------------------------------------------------
|
||||
file disk vda /var/lib/virtuality/images/web01.qcow2
|
||||
file cdrom sda -
|
||||
@@ -1,16 +0,0 @@
|
||||
Domain: 'web01'
|
||||
cpu.time=48123456789
|
||||
cpu.user=30000000000
|
||||
cpu.system=18123456789
|
||||
balloon.current=2097152
|
||||
balloon.maximum=2097152
|
||||
balloon.rss=1534208
|
||||
vcpu.current=2
|
||||
vcpu.maximum=2
|
||||
vcpu.0.state=1
|
||||
vcpu.0.time=24000000000
|
||||
vcpu.0.wait=0
|
||||
vcpu.1.state=1
|
||||
vcpu.1.time=24123456789
|
||||
vcpu.1.wait=0
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<domain type='kvm' id='1'>
|
||||
<name>web01</name>
|
||||
<uuid>6c1b3c1e-0000-0000-0000-000000000001</uuid>
|
||||
<title>Сайт компании</title>
|
||||
<description>Nginx + база. Бэкап по пятницам.</description>
|
||||
<memory unit='KiB'>2097152</memory>
|
||||
<currentMemory unit='KiB'>2097152</currentMemory>
|
||||
<vcpu placement='static'>2</vcpu>
|
||||
<os>
|
||||
<type arch='x86_64' machine='pc-q35-10.2'>hvm</type>
|
||||
<boot dev='hd'/>
|
||||
</os>
|
||||
<devices>
|
||||
<emulator>/usr/bin/qemu-system-x86_64</emulator>
|
||||
<disk type='file' device='disk'>
|
||||
<driver name='qemu' type='qcow2'/>
|
||||
<source file='/var/lib/virtuality/images/web01.qcow2'/>
|
||||
<target dev='vda' bus='virtio'/>
|
||||
</disk>
|
||||
<disk type='file' device='cdrom'>
|
||||
<driver name='qemu' type='raw'/>
|
||||
<target dev='sda' bus='sata'/>
|
||||
<readonly/>
|
||||
</disk>
|
||||
<interface type='network'>
|
||||
<mac address='52:54:00:aa:bb:cc'/>
|
||||
<source network='virtuality-nat'/>
|
||||
<model type='virtio'/>
|
||||
</interface>
|
||||
<channel type='unix'>
|
||||
<target type='virtio' name='org.qemu.guest_agent.0'/>
|
||||
</channel>
|
||||
<graphics type='vnc' port='5900' autoport='yes' listen='127.0.0.1'>
|
||||
<listen type='address' address='127.0.0.1'/>
|
||||
</graphics>
|
||||
</devices>
|
||||
</domain>
|
||||
@@ -1,6 +0,0 @@
|
||||
Id Name State Title
|
||||
--------------------------------------------
|
||||
1 web01 running Сайт компании
|
||||
- db01 shut off
|
||||
- win11 shut off Бухгалтерия 1С
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<network connections='2'>
|
||||
<name>virtuality-nat</name>
|
||||
<uuid>6c1b3c1e-1111-2222-3333-444444444444</uuid>
|
||||
<forward mode='nat'>
|
||||
<nat>
|
||||
<port start='1024' end='65535'/>
|
||||
</nat>
|
||||
</forward>
|
||||
<bridge name='virbr100' stp='on' delay='0'/>
|
||||
<mac address='52:54:00:12:34:56'/>
|
||||
<ip address='192.168.100.1' netmask='255.255.255.0'>
|
||||
<dhcp>
|
||||
<range start='192.168.100.50' end='192.168.100.200'/>
|
||||
<host mac='52:54:00:aa:bb:cc' name='web01' ip='192.168.100.51'/>
|
||||
</dhcp>
|
||||
</ip>
|
||||
</network>
|
||||
@@ -1 +0,0 @@
|
||||
2ae30006299896d225c9d4de36e9a7c3d047c9b4a5793efe3a6422ec5040e8ee5f7d71326aa9922ffa446fbd11ed6cd1a8d0af4659b3d00233d3fea3550d2d78
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"children": [],
|
||||
"virtual-size": 42949672960,
|
||||
"filename": "/var/lib/virtuality/images/web01.qcow2",
|
||||
"cluster-size": 65536,
|
||||
"format": "qcow2",
|
||||
"actual-size": 6443499520,
|
||||
"format-specific": {"type": "qcow2", "data": {"compat": "1.1", "lazy-refcounts": false, "refcount-bits": 16, "corrupt": false}},
|
||||
"dirty-flag": false
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
Name: clean
|
||||
Domain: web01
|
||||
Current: yes
|
||||
State: running
|
||||
Location: internal
|
||||
Parent: -
|
||||
Children: 1
|
||||
Descendants: 1
|
||||
Metadata: yes
|
||||
@@ -1,4 +0,0 @@
|
||||
Name Creation Time State
|
||||
------------------------------------------------------
|
||||
before-upd 2026-09-25 10:12:03 +0000 shutoff
|
||||
clean 2026-09-24 09:00:41 +0000 running
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createRequire } from 'node:module';
|
||||
const { chromium } = createRequire(import.meta.url)('playwright');
|
||||
// Render an HTML file to PNG. Usage: node tests/render_html.mjs in.html out.png [width] [height]
|
||||
const [,, html, out, width = '1024', height = '768'] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: +width, height: +height }, deviceScaleFactor: 1 });
|
||||
await page.goto('file://' + (html.startsWith('/') ? html : process.cwd() + '/' + html));
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: out, type: 'png' });
|
||||
await browser.close();
|
||||
@@ -1,38 +0,0 @@
|
||||
// Screenshots of every panel page for design review.
|
||||
// Usage: node tests/screenshots.mjs [baseUrl] [outDir] [theme]
|
||||
import { createRequire } from 'node:module';
|
||||
const { chromium } = createRequire(import.meta.url)('playwright'); // NODE_PATH=$(npm root -g) for a global install
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
const base = process.argv[2] || 'http://127.0.0.1:8765';
|
||||
const out = process.argv[3] || 'screenshots';
|
||||
const theme = process.argv[4] || '';
|
||||
const pages = [
|
||||
['login', '/login'], ['dashboard', '/'], ['vm', '/vm/web01'], ['create', '/vm/create'], ['help', '/help'], ['operation', '/operations'],
|
||||
['iso', '/iso'], ['disks', '/disk-images'], ['network', '/network'], ['operations', '/operations'],
|
||||
['logs', '/logs'], ['update', '/update'], ['host', '/host'], ['console', '/vm/web01/console'],
|
||||
];
|
||||
mkdirSync(out, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
for (const [width, suffix] of [[1440, ''], [390, '-mobile']]) {
|
||||
const context = await browser.newContext({ viewport: { width, height: 900 }, deviceScaleFactor: 1 });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (err) => errors.push(err.message));
|
||||
if (theme) await page.addInitScript((t) => localStorage.setItem('virtualityTheme', t), theme);
|
||||
await page.goto(base + '/login');
|
||||
if (suffix === '') await page.screenshot({ path: `${out}/login${suffix}.png`, fullPage: true });
|
||||
await page.fill('input[name=password]', 'x');
|
||||
await page.click('button[type=submit]');
|
||||
await page.waitForURL(base + '/');
|
||||
for (const [name, path] of pages.slice(1)) {
|
||||
if (suffix && !['dashboard', 'vm', 'create'].includes(name)) continue;
|
||||
await page.goto(base + path);
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: `${out}/${name}${suffix}.png`, fullPage: true });
|
||||
}
|
||||
if (errors.length) console.log('JS errors:', errors.join(' | '));
|
||||
await context.close();
|
||||
}
|
||||
await browser.close();
|
||||
console.log('saved to', out);
|
||||
@@ -1,276 +0,0 @@
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
import pytest
|
||||
|
||||
import app
|
||||
|
||||
PAGES = ["/", "/help", "/host", "/iso", "/disk-images", "/network", "/operations", "/logs", "/update", "/vm/create", "/vm/web01", "/vm/web01/console"]
|
||||
|
||||
|
||||
def test_healthz_is_public(client):
|
||||
response = client.get("/healthz")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["ok"] is True
|
||||
assert response.json()["version"] == (app.BASE_DIR.parent / "VERSION").read_text().strip()
|
||||
|
||||
|
||||
def test_security_headers(client):
|
||||
response = client.get("/login")
|
||||
assert response.headers["x-frame-options"] == "SAMEORIGIN"
|
||||
assert response.headers["x-content-type-options"] == "nosniff"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", PAGES)
|
||||
def test_pages_require_login(client, path):
|
||||
response = client.get(path, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", PAGES)
|
||||
def test_pages_render(logged_in, path):
|
||||
response = logged_in.get(path)
|
||||
assert response.status_code == 200, response.text[:500]
|
||||
|
||||
|
||||
def test_wrong_user_is_rejected(client, monkeypatch):
|
||||
monkeypatch.setattr(app.auth, "verify_password", lambda user, password: True)
|
||||
response = client.post("/login", data={"username": "intruder", "password": "x"}, follow_redirects=False)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_login_throttle(client, monkeypatch):
|
||||
monkeypatch.setattr(app.auth, "verify_password", lambda user, password: False)
|
||||
for _ in range(5):
|
||||
assert client.post("/login", data={"username": "tester", "password": "bad"}).status_code == 401
|
||||
response = client.post("/login", data={"username": "tester", "password": "bad"})
|
||||
assert response.status_code == 429
|
||||
assert int(response.headers["retry-after"]) > 0
|
||||
|
||||
|
||||
def test_session_cookie_flags(client, monkeypatch):
|
||||
monkeypatch.setattr(app.auth, "verify_password", lambda user, password: True)
|
||||
response = client.post("/login", data={"username": "tester", "password": "x"}, follow_redirects=False)
|
||||
cookie = response.headers["set-cookie"].lower()
|
||||
assert "httponly" in cookie and "samesite=lax" in cookie
|
||||
|
||||
|
||||
def test_forged_cookie_is_rejected(client):
|
||||
client.cookies.set("virtuality_session", "forged.token.value")
|
||||
assert client.get("/", follow_redirects=False).status_code == 303
|
||||
|
||||
|
||||
def test_cross_origin_post_blocked(logged_in):
|
||||
response = logged_in.post("/vm/web01/start", headers={"Origin": "http://evil.example"}, follow_redirects=False)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_same_origin_post_allowed(logged_in):
|
||||
response = logged_in.post("/vm/web01/start", headers={"Origin": "http://panel.test"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
|
||||
|
||||
def test_vm_action_rejects_bad_name(logged_in):
|
||||
assert logged_in.post("/vm/-bad/start", follow_redirects=False).status_code == 400
|
||||
|
||||
|
||||
def test_vm_action_unknown(logged_in):
|
||||
assert logged_in.post("/vm/web01/format-disk", follow_redirects=False).status_code == 400
|
||||
|
||||
|
||||
def test_vm_details_include_autostart(data_dirs):
|
||||
details = app.vm_details("web01")
|
||||
assert details["autostart_enabled"] is True
|
||||
assert details["autostart_label"] == "enabled"
|
||||
|
||||
|
||||
def test_vm_detail_shows_autostart_enabled(logged_in):
|
||||
html = logged_in.get("/vm/web01").text
|
||||
assert 'action="/vm/web01/autostart-disable"' in html
|
||||
assert 'role="switch" aria-checked="true"' in html
|
||||
|
||||
|
||||
def test_dashboard_uses_plain_language(logged_in):
|
||||
html = logged_in.get("/").text
|
||||
assert "Работает" in html and "Выключена" in html
|
||||
assert "2 ядра" in html and "2 ГБ" in html
|
||||
assert "shut off" not in html
|
||||
|
||||
|
||||
def test_vm_create_preselects_image(logged_in, data_dirs):
|
||||
(data_dirs["iso"] / "debian.iso").write_bytes(b"iso")
|
||||
html = logged_in.get("/vm/create?iso=debian.iso").text
|
||||
assert f'value="{data_dirs["iso"] / "debian.iso"}" data-label="debian.iso" selected' in html
|
||||
|
||||
|
||||
def test_vm_create_disk_image_without_disk_size(logged_in, data_dirs, monkeypatch):
|
||||
image = data_dirs["disk_images"] / "cloud.qcow2"
|
||||
image.write_bytes(b"qcow")
|
||||
captured = {}
|
||||
monkeypatch.setattr(app, "start_background_operation", lambda operation, cmd: captured.update(cmd=cmd))
|
||||
monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {})
|
||||
form = {"name": "cloud-vm", "memory": 1024, "vcpus": 1, "disk_image_path": str(image), "source_type": "disk_image", "network_mode": "nat"}
|
||||
response = logged_in.post("/vm/create", data=form, follow_redirects=False)
|
||||
assert response.status_code == 303, response.text[:300]
|
||||
assert "--import" in captured["cmd"][-1]
|
||||
|
||||
|
||||
def test_help_page(logged_in):
|
||||
assert "Как создать машину" in logged_in.get("/help").text
|
||||
|
||||
|
||||
def test_redirect_message_is_url_encoded(logged_in, monkeypatch):
|
||||
monkeypatch.setattr(app, "apply_vm_boot_order", lambda name, order: (True, "Готово: диск & ISO?"))
|
||||
response = logged_in.post("/vm/web01/boot-order", data={"boot_order": "disk"}, follow_redirects=False)
|
||||
location = response.headers["location"]
|
||||
assert " " not in location and "&" not in location.split("?", 1)[1]
|
||||
assert unquote(location.split("boot_message=", 1)[1]) == "Готово: диск & ISO?"
|
||||
|
||||
|
||||
def test_vm_create_iso_builds_virt_install(logged_in, data_dirs, monkeypatch):
|
||||
iso = data_dirs["iso"] / "debian.iso"
|
||||
iso.write_bytes(b"iso")
|
||||
captured = {}
|
||||
monkeypatch.setattr(app, "start_background_operation", lambda operation, cmd: captured.update(operation=operation, cmd=cmd))
|
||||
monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {})
|
||||
form = {"name": "new-vm", "memory": 2048, "vcpus": 2, "disk_size": 20, "iso_path": str(iso), "source_type": "iso", "guest_arch": "x86_64", "boot_order": "auto", "network_mode": "nat"}
|
||||
response = logged_in.post("/vm/create", data=form, follow_redirects=False)
|
||||
assert response.status_code == 303, response.text[:500]
|
||||
cmd = captured["cmd"]
|
||||
assert cmd[0] == "virt-install"
|
||||
assert "--cdrom" in cmd and str(iso) in cmd
|
||||
assert any(part.startswith("network=virtuality-nat,model=virtio,mac=52:54:00:") for part in cmd)
|
||||
assert captured["operation"]["boot_order"] == "disk_cdrom"
|
||||
# После установки гость перезагружается с диска: без особого режима установки и с диском первым в очереди.
|
||||
assert cmd[cmd.index("--boot") + 1] == "hd,cdrom"
|
||||
assert cmd[cmd.index("--install") + 1] == "no_install=yes"
|
||||
|
||||
|
||||
def test_vm_create_validation_error(logged_in):
|
||||
form = {"name": "x", "memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "network_mode": "nat"}
|
||||
response = logged_in.post("/vm/create", data=form)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_iso_upload_and_delete(logged_in, data_dirs, monkeypatch):
|
||||
response = logged_in.post("/iso/upload", files={"iso_file": ("My Distro 1.0.iso", b"data", "application/octet-stream")}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert (data_dirs["iso"] / "My-Distro-1.0.iso").read_bytes() == b"data"
|
||||
logged_in.post("/iso/My-Distro-1.0.iso/delete", follow_redirects=False)
|
||||
assert not (data_dirs["iso"] / "My-Distro-1.0.iso").exists()
|
||||
|
||||
|
||||
def test_iso_upload_rejects_other_types(logged_in):
|
||||
response = logged_in.post("/iso/upload", files={"iso_file": ("evil.sh", b"#!/bin/sh", "text/plain")})
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_iso_delete_path_traversal(logged_in, data_dirs, tmp_path):
|
||||
victim = tmp_path / "victim.iso"
|
||||
victim.write_bytes(b"keep")
|
||||
logged_in.post("/iso/..%2Fvictim.iso/delete", follow_redirects=False)
|
||||
assert victim.exists()
|
||||
|
||||
|
||||
def test_port_forward_add(logged_in, data_dirs):
|
||||
form = {"vm_name": "web01", "guest_ip": "192.168.100.51", "external_port": "2222", "guest_port": "22", "protocol": "tcp"}
|
||||
response = logged_in.post("/network/forward/add", data=form, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
rules = (data_dirs["nft"] / "virtuality.nft").read_text()
|
||||
assert "tcp dport 2222 dnat to 192.168.100.51:22" in rules
|
||||
|
||||
|
||||
def test_api_requires_auth(client):
|
||||
for path in ("/api/health", "/api/operations", "/live/status", "/api/logs"):
|
||||
assert client.get(path).status_code == 401
|
||||
|
||||
|
||||
def test_live_status(logged_in):
|
||||
data = logged_in.get("/live/status").json()
|
||||
assert data["ok"] is True
|
||||
assert {vm["name"] for vm in data["vms"]} == {"web01", "db01"}
|
||||
|
||||
|
||||
def test_uploader_collects_file_before_disabling_input():
|
||||
"""Заблокированное поле браузер не отправляет: загрузка уходила пустой (44 байта) и получала 422."""
|
||||
script = (Path(__file__).resolve().parents[1] / "web" / "static" / "panel.js").read_text()
|
||||
uploader = script[script.index("function wireUploader"):script.index("/* ---------------------------------------------------- operation page */")]
|
||||
assert "input.disabled = active" in uploader
|
||||
assert uploader.index("new FormData(form)") < uploader.index("setActive(true)")
|
||||
assert "xhr.send(data)" in uploader
|
||||
|
||||
|
||||
def test_vm_create_disk_bigger_than_free_space_needs_confirmation(logged_in, data_dirs, monkeypatch):
|
||||
"""virt-install отказывал с английской ошибкой; теперь панель предупреждает сама и создаёт только с подтверждением."""
|
||||
iso = data_dirs["iso"] / "smk.iso"
|
||||
iso.write_bytes(b"iso")
|
||||
started = []
|
||||
monkeypatch.setattr(app, "start_background_operation", lambda operation, cmd: started.append(cmd))
|
||||
monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {})
|
||||
monkeypatch.setattr(app, "images_free_bytes", lambda: 25 * 1024 ** 3)
|
||||
form = {"name": "smk", "memory": 4096, "vcpus": 2, "disk_size": 40, "iso_path": str(iso), "source_type": "iso", "network_mode": "nat"}
|
||||
|
||||
page = logged_in.get("/vm/create").text
|
||||
assert "Свободно на сервере: 25" in page and 'name="allow_overcommit"' not in page
|
||||
response = logged_in.post("/vm/create", data=form)
|
||||
assert response.status_code == 400 and started == []
|
||||
assert "Диск на 40 ГБ больше, чем свободно на сервере" in response.text and 'name="allow_overcommit"' in response.text
|
||||
|
||||
response = logged_in.post("/vm/create", data={**form, "allow_overcommit": "1"}, follow_redirects=False)
|
||||
assert response.status_code == 303, response.text[:300]
|
||||
assert started[0][-2:] == ["--check", "disk_size=off"]
|
||||
|
||||
response = logged_in.post("/vm/create", data={**form, "name": "small", "disk_size": 20}, follow_redirects=False)
|
||||
assert response.status_code == 303 and "--check" not in started[1]
|
||||
|
||||
|
||||
def failed_create(data_dirs, name, disk=True, **extra):
|
||||
"""Операция создания машины, у которой virt-install завершился с ошибкой."""
|
||||
disk_path = data_dirs["images"] / f"{name}.qcow2"
|
||||
if disk:
|
||||
disk_path.write_bytes(b"QFI\xfb")
|
||||
operation = {"id": str(uuid.uuid4()), "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "", "created_at": app.utc_now(), "updated_at": app.utc_now(), "vm_name": name, "disk_path": str(disk_path), "network_mode": "bridge", **extra}
|
||||
app.write_operation(operation)
|
||||
app.run_operation_worker(operation["id"], ["bash", "-c", "echo 'ERROR The requested volume capacity will exceed the available pool space'; exit 1"])
|
||||
return app.read_operation(operation["id"]), disk_path
|
||||
|
||||
|
||||
def test_failed_vm_create_cleans_up_and_offers_retry(logged_in, data_dirs):
|
||||
"""Страница упавшей задачи предлагала «Открыть экран машины», хотя машины нет; недосозданный диск мешал повтору."""
|
||||
operation, disk_path = failed_create(data_dirs, "smk", iso_path=str(data_dirs["iso"] / "smk-server-_1.iso"))
|
||||
assert operation["status"] == "error" and "ничего не осталось" in operation["message"]
|
||||
assert not disk_path.exists() and "Недосозданный диск smk.qcow2 удалён" in operation["log_tail"]
|
||||
html = logged_in.get(f"/operations/{operation['id']}").text
|
||||
assert 'data-op-done="success" hidden' in html
|
||||
assert 'href="/vm/create?iso=smk-server-_1.iso"' in html and "Создать заново" in html
|
||||
|
||||
|
||||
def test_failed_create_of_existing_vm_keeps_its_disk(logged_in, data_dirs):
|
||||
operation, disk_path = failed_create(data_dirs, "web01") # машина определена в libvirt: её диск не трогаем
|
||||
assert disk_path.exists() and "ничего не осталось" not in operation["message"]
|
||||
html = logged_in.get(f"/operations/{operation['id']}").text
|
||||
assert "Создать заново" not in html and 'href="/vm/web01">Перейти к машине' in html
|
||||
|
||||
|
||||
def test_operation_page_reveals_only_buttons_for_its_result():
|
||||
script = (Path(__file__).resolve().parents[1] / "web" / "static" / "panel.js").read_text()
|
||||
assert '$$(`[data-op-done="${status}"]`)' in script and "$$('[data-op-done]')" not in script
|
||||
|
||||
|
||||
def test_console_websocket_path_works_with_old_and_new_novnc(tmp_path, monkeypatch):
|
||||
"""noVNC 1.6 (Ubuntu 26.04) считает path от /novnc/vnc.html: без "../" экран стучался в /novnc/console/ws/… и не подключался."""
|
||||
import posixpath
|
||||
from urllib.parse import parse_qs, urljoin, urlsplit
|
||||
|
||||
(tmp_path / "vnc.html").write_text("<html></html>")
|
||||
monkeypatch.setattr(app, "NOVNC_DIR", tmp_path)
|
||||
monkeypatch.setattr(app, "vm_vnc_display", lambda name: "127.0.0.1:0")
|
||||
info = app.console_info("web01")
|
||||
page = "https://panel.test:8443" + info["url"]
|
||||
path = parse_qs(urlsplit(page).query)["path"][0]
|
||||
token = path.rsplit("/", 1)[1]
|
||||
assert app.console_serializer.loads(token) == {"vm": "web01", "port": 5900}
|
||||
assert urlsplit(urljoin(page, path)).path == f"/console/ws/{token}" # noVNC 1.6: new URL(path, location.href)
|
||||
assert posixpath.normpath("/" + path) == f"/console/ws/{token}" # noVNC 1.3: "wss://host:port/" + path
|
||||
@@ -0,0 +1,82 @@
|
||||
import app
|
||||
|
||||
|
||||
def test_login_page_renders(auth_client):
|
||||
response = auth_client.get("/login", follow_redirects=False)
|
||||
# авторизованный пользователь уводится на дашборд
|
||||
assert response.status_code == 303
|
||||
|
||||
|
||||
def test_unauthenticated_post_redirects_to_login():
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
client = TestClient(app.app)
|
||||
response = client.post("/host/refresh", follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_post_without_csrf_rejected(auth_client):
|
||||
response = auth_client.post("/iso/refresh", follow_redirects=False)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_post_with_wrong_csrf_rejected(auth_client):
|
||||
response = auth_client.post("/iso/refresh", data={"csrf_token": "wrong"}, follow_redirects=False)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_post_with_valid_csrf_accepted(auth_client):
|
||||
response = auth_client.post("/iso/refresh", data={"csrf_token": "test-csrf-token"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
|
||||
|
||||
def test_dashboard_renders_with_csrf_inputs(auth_client):
|
||||
response = auth_client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert 'name="csrf_token"' in response.text
|
||||
assert "cpu-spark" in response.text
|
||||
|
||||
|
||||
def test_backups_page_renders(auth_client):
|
||||
response = auth_client.get("/backups")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_cloud_images_page_renders(auth_client):
|
||||
response = auth_client.get("/cloud-images")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_login_rate_limit():
|
||||
ip = "203.0.113.7"
|
||||
app.reset_login_failures(ip)
|
||||
assert app.login_block_remaining(ip) == 0
|
||||
for _ in range(app.LOGIN_MAX_FAILURES):
|
||||
app.register_login_failure(ip)
|
||||
assert app.login_block_remaining(ip) > 0
|
||||
app.reset_login_failures(ip)
|
||||
assert app.login_block_remaining(ip) == 0
|
||||
|
||||
|
||||
def test_session_token_roundtrip():
|
||||
token = app.serializer.dumps({"user": app.AUTH_USER, "csrf": "abc"})
|
||||
assert app.user_from_session_token(token) == app.AUTH_USER
|
||||
assert app.user_from_session_token("garbage") is None
|
||||
assert app.user_from_session_token(None) is None
|
||||
|
||||
|
||||
def test_template_vm_cannot_start(auth_client, monkeypatch):
|
||||
monkeypatch.setattr(app, "vm_exists", lambda name: True)
|
||||
app.set_vm_template("tpl-vm", True)
|
||||
response = auth_client.post("/vm/tpl-vm/start", data={"csrf_token": "test-csrf-token"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert "clone_error" in response.headers["location"]
|
||||
|
||||
|
||||
def test_metrics_endpoint(auth_client):
|
||||
response = auth_client.get("/live/metrics")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["ok"]
|
||||
assert "series" in payload
|
||||
@@ -1,279 +0,0 @@
|
||||
"""Regressions for the defects found by the 0.11 audit."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
import app
|
||||
import conftest
|
||||
import network_core
|
||||
|
||||
CDROM_XML = conftest.DUMPXML.replace(
|
||||
"<disk type='file' device='disk'><target dev='vda' bus='virtio'/></disk>",
|
||||
"<disk type='file' device='disk'><target dev='vda' bus='virtio'/></disk><disk type='file' device='cdrom'><source file='/var/lib/virtuality/iso/old.iso'/><target dev='sda' bus='sata'/></disk>",
|
||||
)
|
||||
|
||||
|
||||
def recording(monkeypatch, module, overrides=()):
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def run(cmd, timeout=12, **kwargs):
|
||||
calls.append(list(cmd))
|
||||
for prefix, result in overrides:
|
||||
if cmd[: len(prefix)] == list(prefix):
|
||||
return result
|
||||
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
|
||||
|
||||
monkeypatch.setattr(module, "run_cmd", run)
|
||||
return calls
|
||||
|
||||
|
||||
def find(calls, *prefix):
|
||||
return [cmd for cmd in calls if cmd[: len(prefix)] == list(prefix)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- disk image format (app.py:620)
|
||||
def test_disk_image_format_asks_qemu_img(data_dirs, monkeypatch):
|
||||
# Ubuntu cloud images are qcow2 files named *.img: the suffix must not decide the format.
|
||||
assert app.disk_image_format(Path("/tmp/noble-server-cloudimg-amd64.img")) == "qcow2"
|
||||
recording(monkeypatch, app, [(["qemu-img", "info"], conftest.fake_result(ok=False, stderr="no such file"))])
|
||||
assert app.disk_image_format(Path("/tmp/disk.img")) == "raw"
|
||||
assert app.disk_image_format(Path("/tmp/disk.qcow2")) == "qcow2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- VM actions (app.py:1977, 1981)
|
||||
def test_vm_action_reports_virsh_error(logged_in, data_dirs, monkeypatch):
|
||||
recording(monkeypatch, app, [(["virsh", "start"], conftest.fake_result(ok=False, stderr="error: Domain is already active"))])
|
||||
response = logged_in.post("/vm/web01/start", follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/vm/web01?error=" + quote("Машина уже запущена")
|
||||
assert "Машина уже запущена" in logged_in.get("/vm/web01?error=" + quote("Машина уже запущена")).text
|
||||
|
||||
|
||||
def test_vm_action_success_redirects_without_error(logged_in, data_dirs, monkeypatch):
|
||||
recording(monkeypatch, app)
|
||||
assert logged_in.post("/vm/web01/shutdown", follow_redirects=False).headers["location"] == "/vm/web01"
|
||||
|
||||
|
||||
def test_vm_delete_undefines_uefi_machines(logged_in, data_dirs, monkeypatch):
|
||||
calls = recording(monkeypatch, app)
|
||||
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
|
||||
assert response.headers["location"].startswith("/?message=")
|
||||
assert find(calls, "virsh", "destroy", "web01")
|
||||
undefine = find(calls, "virsh", "undefine", "web01")[0]
|
||||
assert "--nvram" in undefine and "--remove-all-storage" in undefine and "--snapshots-metadata" in undefine
|
||||
|
||||
|
||||
def test_vm_delete_failure_is_reported(logged_in, data_dirs, monkeypatch):
|
||||
recording(monkeypatch, app, [(["virsh", "undefine"], conftest.fake_result(ok=False, stderr="error: Requested operation is not valid: cannot undefine domain with nvram"))])
|
||||
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
|
||||
assert response.headers["location"].startswith("/vm/web01?error=")
|
||||
|
||||
|
||||
def test_vm_delete_stops_paused_machine_first(logged_in, data_dirs, monkeypatch):
|
||||
import core
|
||||
|
||||
calls = recording(monkeypatch, app)
|
||||
recording(monkeypatch, core, [(["virsh", "domstate"], conftest.fake_result("paused"))])
|
||||
logged_in.post("/vm/web01/delete", follow_redirects=False)
|
||||
assert find(calls, "virsh", "destroy", "web01") and find(calls, "virsh", "undefine", "web01")
|
||||
|
||||
|
||||
def test_vm_delete_waits_for_running_task(logged_in, data_dirs, monkeypatch):
|
||||
import core
|
||||
|
||||
calls = recording(monkeypatch, app)
|
||||
backup = core.new_operation("backup", "Резервная копия web01", vm_name="web01")
|
||||
core.update_operation(backup, status="running")
|
||||
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
|
||||
assert "Резервная копия web01" in unquote(response.headers["location"])
|
||||
assert not find(calls, "virsh", "undefine") and not find(calls, "virsh", "destroy")
|
||||
|
||||
|
||||
def test_vm_delete_removes_its_port_forwards(logged_in, data_dirs, monkeypatch):
|
||||
recording(monkeypatch, app)
|
||||
rule = {"guest_ip": "192.168.100.51", "external_port_start": 2222, "external_port_end": 2222, "guest_port_start": 22, "guest_port_end": 22, "protocol": "tcp"}
|
||||
data_dirs["network"].joinpath("port_forwards.json").write_text(json.dumps([
|
||||
{"id": "1", "vm_name": "web01", **rule},
|
||||
{"id": "2", "vm_name": "db01", **rule, "external_port_start": 2223, "external_port_end": 2223, "guest_ip": "192.168.100.52"},
|
||||
]))
|
||||
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
|
||||
assert "убраны пробросы портов: 1" in unquote(response.headers["location"])
|
||||
assert [item["vm_name"] for item in network_core.load_port_forwards()] == ["db01"]
|
||||
assert "2222" not in (data_dirs["nft"] / "virtuality.nft").read_text()
|
||||
|
||||
|
||||
def test_delete_is_offered_for_running_machine(logged_in):
|
||||
html = logged_in.get("/vm/web01").text # в тестах web01 работает
|
||||
assert 'action="/vm/web01/delete"' in html and "она будет сразу выключена" in html
|
||||
dashboard = logged_in.get("/").text
|
||||
assert 'action="/vm/web01/delete"' in dashboard and 'action="/vm/db01/delete"' in dashboard
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- ISO in the CD-ROM drive (app.py:1150, 1158)
|
||||
def test_iso_mount_uses_change_media_for_existing_drive(logged_in, data_dirs, monkeypatch):
|
||||
iso = data_dirs["iso"] / "debian.iso"
|
||||
iso.write_bytes(b"iso")
|
||||
calls = recording(monkeypatch, app, [(["virsh", "dumpxml"], conftest.fake_result(CDROM_XML))])
|
||||
ok, message = app.mount_vm_iso("web01", str(iso))
|
||||
assert ok, message
|
||||
assert find(calls, "virsh", "change-media", "web01", "sda", str(iso), "--update", "--config", "--live")
|
||||
assert not find(calls, "virsh", "detach-disk") and not find(calls, "virsh", "attach-disk")
|
||||
ok, message = app.detach_vm_iso("web01")
|
||||
assert ok and find(calls, "virsh", "change-media", "web01", "sda", "--eject", "--config", "--live")
|
||||
|
||||
|
||||
def test_iso_unmount_never_guesses_targets(data_dirs, monkeypatch):
|
||||
# web01 has only a virtio data disk: nothing may be detached blindly.
|
||||
calls = recording(monkeypatch, app)
|
||||
ok, message = app.detach_vm_iso("web01")
|
||||
assert not ok and message == "Подключенный ISO не найден."
|
||||
assert not find(calls, "virsh", "detach-disk") and not find(calls, "virsh", "change-media")
|
||||
|
||||
|
||||
def test_iso_mount_adds_drive_to_config_when_missing(data_dirs, monkeypatch):
|
||||
iso = data_dirs["iso"] / "debian.iso"
|
||||
iso.write_bytes(b"iso")
|
||||
calls = recording(monkeypatch, app)
|
||||
ok, message = app.mount_vm_iso("web01", str(iso))
|
||||
assert ok and "перезапуска" in message
|
||||
attach = find(calls, "virsh", "attach-disk", "web01")[0]
|
||||
assert "--config" in attach and "--live" not in attach
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- port forwards leave no stale firewall rules (network_core.py:459)
|
||||
def test_iptables_fallback_removes_stale_rules(data_dirs, monkeypatch):
|
||||
listing = "\n".join([
|
||||
"-A FORWARD -i eth0 -o virbr1 -p tcp -d 192.168.100.51 -m tcp --dport 22 -m comment --comment virtuality-forward -j ACCEPT",
|
||||
"-A FORWARD -j SOMETHING_ELSE",
|
||||
])
|
||||
calls = recording(monkeypatch, network_core, [(["iptables", "-t", "filter", "-S", "FORWARD"], conftest.fake_result(listing))])
|
||||
network_core.apply_iptables_fallback([])
|
||||
deleted = find(calls, "iptables", "-t", "filter", "-D", "FORWARD")
|
||||
assert deleted == [["iptables", "-t", "filter", "-D", "FORWARD", "-i", "eth0", "-o", "virbr1", "-p", "tcp", "-d", "192.168.100.51", "-m", "tcp", "--dport", "22", "-m", "comment", "--comment", "virtuality-forward", "-j", "ACCEPT"]]
|
||||
checked = find(calls, "iptables", "-t", "nat", "-C", "POSTROUTING")
|
||||
assert checked and "virtuality-forward" in checked[0]
|
||||
|
||||
|
||||
def test_ufw_rules_follow_forwards(data_dirs, monkeypatch):
|
||||
old_rule = ["allow", "2222/tcp"]
|
||||
network_core.UFW_STATE_FILE.write_text(json.dumps([old_rule, ["route", "allow", "in", "on", "eth0", "out", "on", "virbr1", "to", "192.168.100.51", "port", "22", "proto", "tcp"]]))
|
||||
calls = recording(monkeypatch, network_core, [(["ufw", "status"], conftest.fake_result("Status: active"))])
|
||||
network_core.apply_ufw_route_rules([])
|
||||
assert find(calls, "ufw", "delete", "allow", "2222/tcp")
|
||||
assert find(calls, "ufw", "route", "delete", "allow", "in", "on", "eth0")
|
||||
assert json.loads(network_core.UFW_STATE_FILE.read_text()) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- operations orphaned by a restart
|
||||
def test_orphaned_operations_are_closed_on_startup(data_dirs):
|
||||
import core
|
||||
|
||||
running = core.new_operation("snapshot_create", "Снимок web01", vm_name="web01")
|
||||
core.update_operation(running, status="running")
|
||||
done = core.new_operation("backup", "Копия web01", vm_name="web01")
|
||||
core.finish_operation(done, True, "Готово")
|
||||
assert core.active_operations_for("web01")
|
||||
assert core.interrupt_orphaned_operations() == 1
|
||||
assert core.read_operation(running["id"])["status"] == "error" and core.read_operation(running["id"])["interrupted"]
|
||||
assert core.read_operation(done["id"])["status"] == "success"
|
||||
assert core.active_operations_for("web01") == []
|
||||
|
||||
|
||||
def test_panel_startup_closes_orphaned_operations(data_dirs):
|
||||
"""Стартовые задачи панели запускает lifespan (FastAPI on_event устарел)."""
|
||||
import core
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
running = core.new_operation("snapshot_create", "Снимок web01", vm_name="web01")
|
||||
core.update_operation(running, status="running")
|
||||
with TestClient(app.app, base_url="http://panel.test"):
|
||||
assert core.read_operation(running["id"])["interrupted"] is True
|
||||
|
||||
|
||||
def test_interrupt_leaves_operations_of_other_live_processes(data_dirs):
|
||||
"""Копии по расписанию идут в отдельной службе: перезапуск панели не должен объявлять их прерванными."""
|
||||
import subprocess
|
||||
|
||||
import core
|
||||
|
||||
sleeper = subprocess.Popen(["sleep", "30"])
|
||||
finished = subprocess.Popen(["sleep", "0.1"])
|
||||
finished_owner = core.process_owner(finished.pid)
|
||||
finished.wait()
|
||||
try:
|
||||
scheduled = core.new_operation("backup", "Резервная копия web01 по расписанию", vm_name="web01")
|
||||
core.update_operation(scheduled, status="running", owner=core.process_owner(sleeper.pid))
|
||||
dead = core.new_operation("backup", "Резервная копия db01", vm_name="db01")
|
||||
core.update_operation(dead, status="running", owner=finished_owner)
|
||||
legacy = core.new_operation("backup", "Задача старой версии без владельца", vm_name="db01")
|
||||
core.update_operation(legacy, status="running", owner=None)
|
||||
assert core.interrupt_orphaned_operations() == 2
|
||||
assert core.read_operation(scheduled["id"])["status"] == "running"
|
||||
assert core.read_operation(dead["id"])["interrupted"] and core.read_operation(legacy["id"])["interrupted"]
|
||||
finally:
|
||||
sleeper.kill()
|
||||
sleeper.wait()
|
||||
|
||||
|
||||
def test_snapshot_names_with_spaces_are_listed():
|
||||
from features import snapshots
|
||||
|
||||
rows = snapshots.parse_snapshot_list(" Name Creation Time State\n------\n before update 2026-09-25 10:12:03 +0000 shutoff\n clean 2026-09-10 12:05:00 +0000 running\n")
|
||||
assert [row["name"] for row in rows] == ["before update", "clean"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- second audit round
|
||||
def test_vm_names_that_virsh_reads_as_id_or_uuid_are_rejected():
|
||||
import core
|
||||
|
||||
for module in (app, core, network_core):
|
||||
assert module.valid_vm_name("web01") and module.valid_vm_name("1c-server")
|
||||
assert not module.valid_vm_name("10") and not module.valid_vm_name("6c1b3c1e-0000-0000-0000-000000000001")
|
||||
|
||||
|
||||
def test_subprocesses_use_c_locale():
|
||||
import os
|
||||
|
||||
import core
|
||||
|
||||
assert core.STORAGE_DIR and os.environ["LC_ALL"] == "C.UTF-8" # set when core is imported
|
||||
|
||||
|
||||
def test_lease_is_not_guessed_without_mac(data_dirs, monkeypatch):
|
||||
recording(monkeypatch, network_core, [
|
||||
(["virsh", "domifaddr"], conftest.fake_result(ok=False, stderr="domain is not running")),
|
||||
(["virsh", "domiflist"], conftest.fake_result(ok=False, stderr="failed to get domain")),
|
||||
(["virsh", "net-dhcp-leases"], conftest.fake_result(" 2026-09-26 10:00:00 52:54:00:11:22:33 ipv4 192.168.100.77/24 other -")),
|
||||
])
|
||||
assert network_core.resolve_vm_ip("gone") is None
|
||||
|
||||
|
||||
def test_stopped_machines_have_no_stale_ip(data_dirs, monkeypatch):
|
||||
calls = recording(monkeypatch, app)
|
||||
rows = {row["name"]: row for row in app.parse_virsh_list()}
|
||||
assert rows["db01"]["ip"] == "—" and rows["web01"]["ip"] == "192.168.100.51"
|
||||
assert not [cmd for cmd in calls if cmd[:2] == ["virsh", "domifaddr"] and cmd[-1] == "db01"]
|
||||
|
||||
|
||||
def test_system_disk_named_like_iso_is_not_shown_as_iso(data_dirs, monkeypatch):
|
||||
xml = conftest.DUMPXML.replace("<disk type='file' device='disk'><target dev='vda' bus='virtio'/></disk>", "<disk type='file' device='disk'><source file='/var/lib/virtuality/images/web.iso.qcow2'/><target dev='vda' bus='virtio'/></disk><disk type='file' device='cdrom'><target dev='sda' bus='sata'/></disk>")
|
||||
recording(monkeypatch, app, [(["virsh", "dumpxml"], conftest.fake_result(xml))])
|
||||
assert app.current_vm_iso("web01") == ""
|
||||
|
||||
|
||||
def test_iso_in_use_is_not_deleted(logged_in, data_dirs, monkeypatch):
|
||||
iso = data_dirs["iso"] / "old.iso"
|
||||
iso.write_bytes(b"iso")
|
||||
xml = CDROM_XML.replace("/var/lib/virtuality/iso/old.iso", str(iso))
|
||||
recording(monkeypatch, app, [(["virsh", "dumpxml"], conftest.fake_result(xml)), (["virsh", "list", "--all", "--name"], conftest.fake_result("web01\n"))])
|
||||
response = logged_in.post("/iso/old.iso/delete", follow_redirects=False)
|
||||
assert "error=" in response.headers["location"] and iso.exists()
|
||||
|
||||
|
||||
def test_arm_board_is_ready_without_vmx_flags(monkeypatch):
|
||||
import host_profile
|
||||
|
||||
monkeypatch.setattr(host_profile.platform, "machine", lambda: "aarch64")
|
||||
monkeypatch.setattr(host_profile, "cpu_virtualization_flags_count", lambda: 0)
|
||||
profile = host_profile.detect_host_profile()
|
||||
assert not [check for check in profile["checks"] if check["name"] == "Поддержка виртуализации процессором"]
|
||||
@@ -1,49 +0,0 @@
|
||||
import pytest
|
||||
|
||||
import auth
|
||||
|
||||
# SHA-512 crypt hash of "s3cret" with salt "virtualitysalt".
|
||||
SHA512_HASH = "$6$virtualitysalt$"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def password_hash():
|
||||
computed = auth.crypt_hash("s3cret", SHA512_HASH)
|
||||
assert computed and computed.startswith(SHA512_HASH)
|
||||
return computed
|
||||
|
||||
|
||||
def write_shadow(tmp_path, monkeypatch, entries):
|
||||
shadow = tmp_path / "shadow"
|
||||
shadow.write_text("".join(f"{user}:{pw}:19000:0:99999:7:::\n" for user, pw in entries))
|
||||
monkeypatch.setattr(auth, "SHADOW_FILE", shadow)
|
||||
monkeypatch.setattr(auth, "_stdlib_spwd", None)
|
||||
|
||||
|
||||
def test_libcrypt_fallback_matches(password_hash, monkeypatch):
|
||||
monkeypatch.setattr(auth, "_stdlib_crypt", None)
|
||||
assert auth.crypt_hash("s3cret", password_hash) == password_hash
|
||||
assert auth.crypt_hash("wrong", password_hash) != password_hash
|
||||
|
||||
|
||||
def test_verify_password_with_shadow_file(password_hash, tmp_path, monkeypatch):
|
||||
write_shadow(tmp_path, monkeypatch, [("root", "*"), ("admin", password_hash), ("locked", "!" + password_hash)])
|
||||
monkeypatch.setattr(auth, "_stdlib_crypt", None)
|
||||
assert auth.verify_password("admin", "s3cret")
|
||||
assert not auth.verify_password("admin", "wrong")
|
||||
assert not auth.verify_password("admin", "")
|
||||
assert not auth.verify_password("root", "anything")
|
||||
assert not auth.verify_password("locked", "s3cret")
|
||||
assert not auth.verify_password("missing", "s3cret")
|
||||
|
||||
|
||||
def test_throttle_locks_and_resets():
|
||||
throttle = auth.LoginThrottle(max_failures=3, window_seconds=60, lockout_seconds=60)
|
||||
for _ in range(2):
|
||||
throttle.record_failure("1.2.3.4")
|
||||
assert throttle.retry_after("1.2.3.4") == 0
|
||||
throttle.record_failure("1.2.3.4")
|
||||
assert throttle.retry_after("1.2.3.4") > 0
|
||||
assert throttle.retry_after("5.6.7.8") == 0
|
||||
throttle.record_success("1.2.3.4")
|
||||
assert throttle.retry_after("1.2.3.4") == 0
|
||||
@@ -0,0 +1,87 @@
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import backup_core
|
||||
|
||||
|
||||
def test_backup_filename_validation():
|
||||
assert backup_core.valid_backup_filename("vm1_20260815_010101.qcow2")
|
||||
assert backup_core.valid_backup_filename("a.qcow2")
|
||||
assert not backup_core.valid_backup_filename("../evil.qcow2")
|
||||
assert not backup_core.valid_backup_filename("x.img")
|
||||
assert not backup_core.valid_backup_filename("")
|
||||
|
||||
|
||||
def test_backup_path_traversal_guard():
|
||||
assert backup_core.backup_path("vm1", "vm1_x.qcow2") is not None
|
||||
assert backup_core.backup_path("vm1", "../../etc/passwd") is None
|
||||
assert backup_core.backup_path("../vm1", "x.qcow2") is None
|
||||
assert backup_core.backup_path("vm1", "nested/x.qcow2") is None
|
||||
|
||||
|
||||
def test_new_backup_path_shape():
|
||||
path = backup_core.new_backup_path("vm1")
|
||||
assert path is not None
|
||||
assert path.name.startswith("vm1_")
|
||||
assert path.suffix == ".qcow2"
|
||||
assert backup_core.new_backup_path("bad name") is None
|
||||
|
||||
|
||||
def test_rotation_keeps_newest():
|
||||
vm_dir = backup_core.backup_dir_for("vm1")
|
||||
vm_dir.mkdir(parents=True)
|
||||
for index in range(5):
|
||||
item = vm_dir / f"vm1_{index}.qcow2"
|
||||
item.write_text("x")
|
||||
stamp = time.time() - (5 - index) * 60
|
||||
import os
|
||||
os.utime(item, (stamp, stamp))
|
||||
removed = backup_core.rotate_backups("vm1", keep=2)
|
||||
assert len(removed) == 3
|
||||
remaining = sorted(p.name for p in vm_dir.glob("*.qcow2"))
|
||||
assert remaining == ["vm1_3.qcow2", "vm1_4.qcow2"]
|
||||
|
||||
|
||||
def test_rotation_ignores_bad_input():
|
||||
assert backup_core.rotate_backups("no-such-vm", keep=3) == []
|
||||
assert backup_core.rotate_backups("vm1", keep=0) == []
|
||||
|
||||
|
||||
def test_schedule_roundtrip():
|
||||
config = backup_core.save_schedule(True, ["vm2", "vm1", "bad name", "vm1"], 99)
|
||||
assert config == {"enabled": True, "vms": ["vm1", "vm2"], "keep": 30}
|
||||
assert backup_core.load_schedule() == config
|
||||
config = backup_core.save_schedule(False, [], 0)
|
||||
assert backup_core.load_schedule() == {"enabled": False, "vms": [], "keep": 1}
|
||||
|
||||
|
||||
def test_schedule_defaults_when_missing():
|
||||
assert backup_core.load_schedule() == {"enabled": False, "vms": [], "keep": backup_core.DEFAULT_KEEP}
|
||||
|
||||
|
||||
def test_backup_script_contents():
|
||||
target = Path("/var/lib/virtuality/backups/vm1/vm1_x.qcow2")
|
||||
script = backup_core.build_backup_script("vm1", "/var/lib/virtuality/images/vm1.qcow2", target)
|
||||
assert "virsh suspend vm1" in script
|
||||
assert "qemu-img convert -p -c -O qcow2" in script
|
||||
assert "virsh resume vm1" in script
|
||||
assert f"{target}.part" in script # атомарная запись через .part
|
||||
restore = backup_core.build_restore_script("/var/lib/virtuality/images/vm1.qcow2", target)
|
||||
assert "qemu-img convert -p -O qcow2" in restore
|
||||
assert ".restore" in restore
|
||||
|
||||
|
||||
def test_first_disk_of_parsing(monkeypatch):
|
||||
sample = """ Type Device Target Source
|
||||
------------------------------------------------
|
||||
file disk vda /var/lib/virtuality/images/vm1.qcow2
|
||||
file cdrom sda /var/lib/virtuality/iso/ubuntu.iso
|
||||
"""
|
||||
|
||||
class FakeResult:
|
||||
returncode = 0
|
||||
stdout = sample
|
||||
stderr = ""
|
||||
|
||||
monkeypatch.setattr(backup_core, "_run", lambda cmd, timeout=60: FakeResult())
|
||||
assert backup_core.first_disk_of("vm1") == "/var/lib/virtuality/images/vm1.qcow2"
|
||||
@@ -1,209 +0,0 @@
|
||||
"""Резервные копии по расписанию: когда запускать, какие машины копировать, сколько хранить, форма и страницы."""
|
||||
import fcntl
|
||||
import json
|
||||
from datetime import datetime
|
||||
from urllib.parse import unquote
|
||||
|
||||
import pytest
|
||||
|
||||
import core
|
||||
from features import backup_schedule as bs
|
||||
from features import backups
|
||||
from test_backups import make_backup
|
||||
|
||||
|
||||
def schedule(**changes):
|
||||
return bs.clean_schedule({**bs.DEFAULT, "enabled": True, **changes})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- настройка и время
|
||||
def test_clean_schedule_replaces_broken_values():
|
||||
assert bs.clean_schedule({}) == bs.DEFAULT
|
||||
broken = {"enabled": "yes", "frequency": "hourly", "weekday": True, "time": "25:00", "vms": ["web01", "bad name", 5, "web01"], "keep": 0}
|
||||
assert bs.clean_schedule(broken) == {**bs.DEFAULT, "vms": ["web01"]}
|
||||
assert bs.clean_schedule({"all_vms": False, "keep": 61})["all_vms"] is False
|
||||
assert bs.clean_schedule({"keep": 61})["keep"] == bs.DEFAULT["keep"]
|
||||
|
||||
|
||||
def test_slots_daily():
|
||||
daily = schedule(time="03:00")
|
||||
assert bs.last_slot(daily, datetime(2026, 9, 27, 2, 59)) == datetime(2026, 9, 26, 3, 0)
|
||||
assert bs.last_slot(daily, datetime(2026, 9, 27, 3, 0)) == datetime(2026, 9, 27, 3, 0)
|
||||
assert bs.next_slot(daily, datetime(2026, 9, 27, 3, 0)) == datetime(2026, 9, 28, 3, 0)
|
||||
|
||||
|
||||
def test_slots_weekly():
|
||||
sunday = schedule(frequency="weekly", weekday=6, time="04:30") # 27.09.2026 — воскресенье
|
||||
assert bs.last_slot(sunday, datetime(2026, 9, 30, 12, 0)) == datetime(2026, 9, 27, 4, 30)
|
||||
assert bs.last_slot(sunday, datetime(2026, 9, 27, 4, 0)) == datetime(2026, 9, 20, 4, 30)
|
||||
assert bs.next_slot(sunday, datetime(2026, 9, 27, 4, 0)) == datetime(2026, 9, 27, 4, 30)
|
||||
|
||||
|
||||
def test_due_runs_once_and_does_not_catch_up_hours_later():
|
||||
daily = schedule(time="03:00")
|
||||
assert bs.due(daily, {}, datetime(2026, 9, 27, 3, 10)) == ("run", datetime(2026, 9, 27, 3, 0))
|
||||
assert bs.due(daily, {"slot": "2026-09-27 03:00"}, datetime(2026, 9, 27, 3, 25)) is None
|
||||
assert bs.due(daily, {}, datetime(2026, 9, 27, 5, 0)) == ("run", datetime(2026, 9, 27, 3, 0))
|
||||
assert bs.due(daily, {}, datetime(2026, 9, 27, 5, 1)) == ("missed", datetime(2026, 9, 27, 3, 0))
|
||||
assert bs.due({**daily, "enabled": False}, {}, datetime(2026, 9, 27, 3, 10)) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- запуск
|
||||
@pytest.fixture()
|
||||
def fake_copies(data_dirs, monkeypatch):
|
||||
"""create_backup сразу кладёт готовую копию; для машин из failing — отказ с текстом."""
|
||||
calls = []
|
||||
failing = {}
|
||||
minutes = iter(range(1, 60))
|
||||
|
||||
def create(vm, note="", wait=False, auto=False):
|
||||
calls.append((vm, note, wait, auto))
|
||||
if vm in failing:
|
||||
raise backups.BackupError(failing[vm])
|
||||
backup_id = f"20260927-03{next(minutes):02d}"
|
||||
make_backup(data_dirs, vm=vm, backup_id=backup_id, note=note, auto=auto)
|
||||
return {"id": f"op-{backup_id}", "status": "success", "message": "Резервная копия создана: 2.5 ГБ"}
|
||||
|
||||
monkeypatch.setattr(backups, "create_backup", create)
|
||||
monkeypatch.setattr(backups, "existing_vm_names", lambda: {"web01", "db01", "test"})
|
||||
create.calls, create.failing = calls, failing
|
||||
return create
|
||||
|
||||
|
||||
def test_run_copies_every_machine_and_keeps_last_n_auto_copies(data_dirs, fake_copies):
|
||||
bs.write_json(bs.schedule_path(), schedule(time="03:00", keep=2))
|
||||
for backup_id in ("20260920-0300", "20260921-0300", "20260922-0300"):
|
||||
make_backup(data_dirs, vm="web01", backup_id=backup_id, auto=True)
|
||||
make_backup(data_dirs, vm="web01", backup_id="20260901-1200", note="перед обновлением")
|
||||
|
||||
state = bs.run_due(datetime(2026, 9, 27, 3, 5))
|
||||
assert [call[0] for call in fake_copies.calls] == ["db01", "test", "web01"]
|
||||
assert {call[1:] for call in fake_copies.calls} == {("По расписанию", True, True)}
|
||||
assert state["status"] == "done" and state["slot"] == "2026-09-27 03:00" and all(item["ok"] for item in state["results"])
|
||||
web01 = state["results"][-1]
|
||||
assert web01["removed"] == ["20260921-0300", "20260920-0300"]
|
||||
# Ручная копия осталась, из автоматических — две последние.
|
||||
assert [item["id"] for item in backups.list_backups("web01")] == ["20260927-0303", "20260922-0300", "20260901-1200"]
|
||||
assert bs.read_json(bs.state_path()) == state
|
||||
|
||||
assert bs.run_due(datetime(2026, 9, 27, 3, 20)) is None # этот момент уже отработан
|
||||
assert len(fake_copies.calls) == 3
|
||||
|
||||
|
||||
def test_failed_copy_keeps_old_copies_and_others_continue(data_dirs, fake_copies):
|
||||
bs.write_json(bs.schedule_path(), schedule(all_vms=False, vms=["web01", "db01", "gone"], keep=1))
|
||||
make_backup(data_dirs, vm="web01", backup_id="20260920-0300", auto=True)
|
||||
make_backup(data_dirs, vm="web01", backup_id="20260921-0300", auto=True)
|
||||
fake_copies.failing["web01"] = "Недостаточно места для копии"
|
||||
|
||||
state = bs.run_due(datetime(2026, 9, 27, 3, 5))
|
||||
assert [call[0] for call in fake_copies.calls] == ["db01", "web01"] # машины gone на сервере нет
|
||||
results = {item["vm"]: item for item in state["results"]}
|
||||
assert results["db01"]["ok"] and not results["web01"]["ok"] and "места" in results["web01"]["message"]
|
||||
assert len(backups.list_backups("web01")) == 2
|
||||
|
||||
|
||||
def test_missed_slot_is_recorded_not_copied(data_dirs, fake_copies):
|
||||
bs.write_json(bs.schedule_path(), schedule(time="03:00"))
|
||||
assert bs.run_due(datetime(2026, 9, 27, 9, 0)) is None
|
||||
assert fake_copies.calls == []
|
||||
state = bs.read_json(bs.state_path())
|
||||
assert state["slot"] == state["missed"] == "2026-09-27 03:00"
|
||||
assert bs.overview(datetime(2026, 9, 27, 9, 0))["missed"] == "27 сентября, 03:00"
|
||||
|
||||
|
||||
def test_run_waits_for_previous_run(data_dirs, fake_copies):
|
||||
bs.write_json(bs.schedule_path(), schedule(time="03:00"))
|
||||
with bs.lock_path().open("w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
assert bs.run_due(datetime(2026, 9, 27, 3, 5)) is None
|
||||
assert fake_copies.calls == []
|
||||
assert bs.run_due(datetime(2026, 9, 27, 3, 20))["status"] == "done"
|
||||
|
||||
|
||||
def test_prune_leaves_copy_that_is_being_restored(data_dirs):
|
||||
make_backup(data_dirs, vm="web01", backup_id="20260920-0300", auto=True)
|
||||
make_backup(data_dirs, vm="web01", backup_id="20260921-0300", auto=True)
|
||||
core.new_operation("restore", "Восстановление web01", vm_name="web01-copy", source_vm="web01", backup_id="20260920-0300")
|
||||
assert bs.prune("web01", 1) == []
|
||||
assert len(backups.list_backups("web01")) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- форма и страницы
|
||||
FORM = {"enabled": "1", "frequency": "daily", "weekday": "6", "time": "03:00", "machines": "all", "keep": "7"}
|
||||
|
||||
|
||||
def test_schedule_form_saves_and_does_not_start_right_away(logged_in, data_dirs, monkeypatch):
|
||||
monkeypatch.setattr(backups, "existing_vm_names", lambda: {"web01", "db01"})
|
||||
page = logged_in.get("/backups/schedule")
|
||||
assert page.status_code == 200 and "Расписание копий" in page.text and 'value="db01"' in page.text
|
||||
|
||||
data = {**FORM, "frequency": "weekly", "time": "04:30", "machines": "chosen", "vms": ["web01"], "keep": "4"}
|
||||
response = logged_in.post("/backups/schedule", data=data, follow_redirects=False)
|
||||
assert response.status_code == 303 and "по воскресеньям в 04:30" in unquote(response.headers["location"])
|
||||
saved = bs.load_schedule()
|
||||
assert saved == {"enabled": True, "frequency": "weekly", "weekday": 6, "time": "04:30", "all_vms": False, "vms": ["web01"], "keep": 4}
|
||||
assert bs.due(saved, bs.read_json(bs.state_path()), datetime.now()) is None # прошедший момент не догоняется
|
||||
|
||||
response = logged_in.post("/backups/schedule", data={**FORM, "enabled": ""}, follow_redirects=False)
|
||||
assert "выключены" in unquote(response.headers["location"]) and bs.load_schedule()["enabled"] is False
|
||||
|
||||
|
||||
def test_saving_same_time_keeps_pending_copy(logged_in, data_dirs):
|
||||
bs.write_json(bs.schedule_path(), schedule(time="03:00"))
|
||||
bs.write_json(bs.state_path(), {"slot": "2026-09-20 03:00"})
|
||||
logged_in.post("/backups/schedule", data={**FORM, "keep": "3"}, follow_redirects=False)
|
||||
assert bs.load_schedule()["keep"] == 3
|
||||
assert bs.read_json(bs.state_path())["slot"] == "2026-09-20 03:00"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changes, error", [
|
||||
({"time": "3 часа"}, "ЧЧ:ММ"),
|
||||
({"keep": "0"}, "от 1 до 60"),
|
||||
({"keep": "много"}, "от 1 до 60"),
|
||||
({"machines": "chosen"}, "хотя бы одну машину"),
|
||||
])
|
||||
def test_schedule_form_errors(logged_in, data_dirs, changes, error):
|
||||
response = logged_in.post("/backups/schedule", data={**FORM, **changes})
|
||||
assert response.status_code == 400 and error in response.text
|
||||
assert not bs.schedule_path().exists()
|
||||
|
||||
|
||||
def test_schedule_requires_login(client):
|
||||
assert client.get("/backups/schedule", follow_redirects=False).status_code in (302, 303)
|
||||
assert client.post("/backups/schedule", data=FORM, follow_redirects=False).status_code in (302, 303, 403)
|
||||
assert not bs.schedule_path().exists()
|
||||
|
||||
|
||||
def test_pages_show_schedule_and_last_run(logged_in, data_dirs):
|
||||
page = logged_in.get("/backups")
|
||||
assert "Автоматические копии" in page.text and "Настроить" in page.text
|
||||
|
||||
bs.write_json(bs.schedule_path(), schedule(time="03:00", keep=5))
|
||||
results = [{"vm": "web01", "ok": True}, {"vm": "db01", "ok": False, "message": "Недостаточно места для копии"}]
|
||||
bs.write_json(bs.state_path(), {"slot": "2026-09-27 03:00", "status": "done", "started_at": "2026-09-27 03:00:05", "results": results})
|
||||
page = logged_in.get("/backups")
|
||||
assert "Каждый день в 03:00" in page.text and "хранить 5 последних копий" in page.text
|
||||
assert "скопировано 1 из 2 машин" in page.text and "Недостаточно места для копии" in page.text
|
||||
assert "Машина копируется каждый день в 03:00" in logged_in.get("/vm/web01/backups").text
|
||||
|
||||
bs.write_json(bs.schedule_path(), schedule(all_vms=False, vms=["db01"]))
|
||||
assert "не копируется автоматически" in logged_in.get("/vm/web01/backups").text
|
||||
|
||||
|
||||
def test_main_prints_summary(data_dirs, fake_copies, monkeypatch, capsys):
|
||||
bs.write_json(bs.schedule_path(), schedule(time="03:00"))
|
||||
fake_copies.failing["test"] = "Машина не выключилась"
|
||||
|
||||
class Night(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return cls(2026, 9, 27, 3, 5)
|
||||
|
||||
monkeypatch.setattr(bs, "datetime", Night)
|
||||
bs.main()
|
||||
out = capsys.readouterr().out
|
||||
assert "2 из 3 успешно" in out and "test: Машина не выключилась" in out
|
||||
assert json.loads(bs.state_path().read_text())["status"] == "done"
|
||||
bs.main() # второй запуск в тот же момент молчит
|
||||
assert capsys.readouterr().out == ""
|
||||
@@ -1,672 +0,0 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
import pytest
|
||||
|
||||
import core
|
||||
import features.backups as backups
|
||||
from conftest import DOMBLKLIST_DETAILS, DUMPXML_MIGRATABLE, QEMU_IMG_INFO, fake_result, fake_run_cmd
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||
|
||||
|
||||
def make_backup(dirs, vm="web01", backup_id="20260924-0300", size=2_684_354_560, note="", disks=("vda",), complete=True, auto=False) -> Path:
|
||||
path = dirs["backups"] / vm / backup_id
|
||||
path.mkdir(parents=True)
|
||||
(path / "vm.xml").write_text(DUMPXML_MIGRATABLE)
|
||||
for target in disks:
|
||||
if complete:
|
||||
(path / f"{target}.qcow2").write_bytes(b"QFI\xfb" + b"\0" * 60)
|
||||
meta = {"vm": vm, "created_at": "2026-09-24 03:00:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": 6_443_499_520} for t in disks], "size_bytes": size, "version": "0.11.0", "note": note, "auto": auto}
|
||||
(path / "meta.json").write_text(json.dumps(meta))
|
||||
return path
|
||||
|
||||
|
||||
class FakeVirsh:
|
||||
"""Имитация virsh с состоянием: машина выключается после `virsh shutdown`."""
|
||||
|
||||
def __init__(self, running=True, shuts_down=True, exists=("web01", "db01"), paused=False, rejects_shutdown=False):
|
||||
self.running = running
|
||||
self.paused = paused
|
||||
self.shuts_down = shuts_down
|
||||
self.rejects_shutdown = rejects_shutdown
|
||||
self.exists = set(exists)
|
||||
self.calls: list[list[str]] = []
|
||||
self.defined_xml = ""
|
||||
|
||||
def __call__(self, cmd, timeout=12, **_kwargs):
|
||||
self.calls.append(list(cmd))
|
||||
if cmd[:2] == ["virsh", "domstate"]:
|
||||
return fake_result("paused" if self.running and self.paused else "running" if self.running else "shut off")
|
||||
if cmd[:2] == ["virsh", "shutdown"]:
|
||||
if self.rejects_shutdown:
|
||||
return fake_result(ok=False, stderr="error: Requested operation is not valid: domain is not running")
|
||||
if self.shuts_down:
|
||||
self.running = False
|
||||
return fake_result()
|
||||
if cmd[:2] == ["virsh", "managedsave"]:
|
||||
self.running = False
|
||||
return fake_result(f"Domain '{cmd[2]}' state saved by libvirt")
|
||||
if cmd[:2] == ["virsh", "destroy"]:
|
||||
self.running = False
|
||||
return fake_result()
|
||||
if cmd[:2] == ["virsh", "start"]:
|
||||
self.running = True
|
||||
return fake_result()
|
||||
if cmd[:2] == ["virsh", "dominfo"]:
|
||||
return fake_result(f"Name: {cmd[-1]}\nState: running\n") if cmd[-1] in self.exists else fake_result(ok=False, stderr="error: failed to get domain")
|
||||
if cmd[:2] == ["virsh", "define"]:
|
||||
self.defined_xml = Path(cmd[2]).read_text()
|
||||
return fake_result(f"Domain defined from {cmd[2]}")
|
||||
if cmd[:2] == ["virsh", "undefine"]:
|
||||
self.exists.discard(cmd[2])
|
||||
return fake_result()
|
||||
return fake_run_cmd(cmd, timeout)
|
||||
|
||||
|
||||
def fake_stream(progress_lines=("(0.00/100%)", "(48.50/100%)", "(100.00/100%)"), code=0, calls=None):
|
||||
"""Имитация qemu-img convert. calls — общий список с FakeVirsh, если важен порядок команд."""
|
||||
calls = [] if calls is None else calls
|
||||
|
||||
def stream(cmd, on_line, timeout=None):
|
||||
calls.append(list(cmd))
|
||||
Path(cmd[-1]).write_bytes(b"QFI\xfb" + b"\0" * 1000)
|
||||
for line in progress_lines:
|
||||
on_line(line)
|
||||
return code
|
||||
|
||||
stream.calls = calls
|
||||
return stream
|
||||
|
||||
|
||||
class FixedDatetime(datetime):
|
||||
"""datetime.now() всегда 24 сентября 2026, 03:00 — чтобы id копий были предсказуемы."""
|
||||
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return cls(2026, 9, 24, 3, 0, 0)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fast_worker(monkeypatch, data_dirs):
|
||||
"""Операции и фоновые потоки выполняются синхронно, ожидание выключения — мгновенно."""
|
||||
monkeypatch.setattr(core, "run_operation", lambda operation, worker: worker(operation))
|
||||
monkeypatch.setattr(backups, "start_thread", lambda target, *args: target(*args))
|
||||
monkeypatch.setattr(backups, "POLL_INTERVAL", 0.001)
|
||||
monkeypatch.setattr(backups, "SHUTDOWN_TIMEOUT", 0.05)
|
||||
monkeypatch.setattr(backups, "LATE_SHUTDOWN_WATCH", 0.05)
|
||||
monkeypatch.setattr(backups, "free_bytes", lambda path: 500 * 1024 ** 3)
|
||||
return data_dirs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- парсеры
|
||||
def test_parse_progress():
|
||||
assert backups.parse_progress(" (12.34/100%)") == 12
|
||||
assert backups.parse_progress("(100.00/100%)") == 100
|
||||
assert backups.parse_progress("qemu-img: Could not open") is None
|
||||
|
||||
|
||||
def test_parse_qemu_img_info_fixture():
|
||||
info = backups.parse_qemu_img_info(QEMU_IMG_INFO)
|
||||
assert info == {"virtual_size": 42949672960, "actual_size": 6443499520, "format": "qcow2"}
|
||||
assert backups.parse_qemu_img_info("not json")["actual_size"] == 0
|
||||
assert backups.parse_qemu_img_info("[1, 2]")["format"] == ""
|
||||
|
||||
|
||||
def test_vm_disks_skips_cdrom(data_dirs):
|
||||
disks = backups.vm_disks("web01")
|
||||
assert [(d["target"], d["source"]) for d in disks] == [("vda", str(core.IMAGES_DIR / "web01.qcow2"))]
|
||||
assert DOMBLKLIST_DETAILS.count("cdrom") == 1
|
||||
|
||||
|
||||
def test_stream_cmd_splits_carriage_returns():
|
||||
lines: list[str] = []
|
||||
code = backups.stream_cmd(["python3", "-c", "import sys; sys.stdout.write('a\\r (10.00/100%)\\r(55.50/100%)\\nend')"], lines.append)
|
||||
assert code == 0
|
||||
assert lines == ["a", "(10.00/100%)", "(55.50/100%)", "end"]
|
||||
|
||||
|
||||
def test_stream_cmd_stops_hung_command():
|
||||
lines: list[str] = []
|
||||
code = backups.stream_cmd(["python3", "-c", "import time; time.sleep(30)"], lines.append, timeout=0.2)
|
||||
assert code != 0
|
||||
assert any("остановлена" in line for line in lines)
|
||||
|
||||
|
||||
def test_disk_info_uses_shared_lock_and_raises_on_failure(monkeypatch):
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def info_ok(cmd, timeout=12, **_kw):
|
||||
calls.append(list(cmd))
|
||||
return fake_result(QEMU_IMG_INFO)
|
||||
|
||||
monkeypatch.setattr(core, "run_cmd", info_ok)
|
||||
assert backups.disk_info("/x/web01.qcow2")["actual_size"] == 6443499520
|
||||
assert calls == [["qemu-img", "info", "-U", "--output=json", "/x/web01.qcow2"]] # -U: диск занят работающей машиной
|
||||
monkeypatch.setattr(core, "run_cmd", lambda cmd, timeout=12, **_kw: fake_result(ok=False, stderr='qemu-img: Could not open: Failed to get shared "write" lock'))
|
||||
with pytest.raises(backups.BackupError) as exc:
|
||||
backups.disk_info("/x/web01.qcow2")
|
||||
assert "размер диска" in str(exc.value) and "write" in str(exc.value)
|
||||
|
||||
|
||||
def test_rewrite_xml_renames_and_detaches_identity():
|
||||
xml = backups.rewrite_xml((FIXTURES / "dumpxml-web01.xml").read_text(), "web02", {"vda": "/var/lib/virtuality/images/web02.qcow2"})
|
||||
assert "<name>web02</name>" in xml
|
||||
assert "<uuid>" not in xml and "id=" not in xml.split(">", 1)[0]
|
||||
assert "<mac" not in xml
|
||||
assert "file=\"/var/lib/virtuality/images/web02.qcow2\"" in xml
|
||||
assert "Сайт компании" in xml # заголовок и описание сохраняются
|
||||
assert "<readonly />" in xml or "<readonly/>" in xml # привод CD остаётся как был
|
||||
|
||||
|
||||
def test_rewrite_xml_keeps_identity_on_replace_and_drops_stale_chain(tmp_path):
|
||||
present = tmp_path / "debian.iso"
|
||||
present.write_bytes(b"iso")
|
||||
source = f"""<domain type='kvm' id='7'><name>web01</name><uuid>6c1b3c1e-0000-0000-0000-000000000001</uuid>
|
||||
<devices>
|
||||
<disk type='file' device='disk'><driver name='qemu' type='qcow2'/><source file='/var/lib/virtuality/images/web01.snap.qcow2'/>
|
||||
<backingStore type='file'><format type='qcow2'/><source file='/var/lib/virtuality/images/web01.qcow2'/></backingStore>
|
||||
<target dev='vda' bus='virtio'/></disk>
|
||||
<disk type='file' device='cdrom'><driver name='qemu' type='raw'/><source file='/var/lib/virtuality/iso/gone.iso'/><target dev='sda' bus='sata'/><readonly/></disk>
|
||||
<disk type='file' device='cdrom'><driver name='qemu' type='raw'/><source file='{present}'/><target dev='sdb' bus='sata'/><readonly/></disk>
|
||||
<interface type='network'><mac address='52:54:00:aa:bb:cc'/><source network='virtuality-nat'/></interface>
|
||||
</devices></domain>"""
|
||||
replaced = backups.rewrite_xml(source, "web01", {"vda": "/var/lib/virtuality/images/web01.qcow2"}, keep_identity=True)
|
||||
assert "<uuid>6c1b3c1e-0000-0000-0000-000000000001</uuid>" in replaced and 'mac address="52:54:00:aa:bb:cc"' in replaced
|
||||
assert "backingStore" not in replaced and "web01.snap.qcow2" not in replaced
|
||||
assert "gone.iso" not in replaced and str(present) in replaced # пропавший образ вынут, существующий остаётся
|
||||
assert 'id="7"' not in replaced
|
||||
fresh = backups.rewrite_xml(source, "web02", {"vda": "/var/lib/virtuality/images/web02.qcow2"})
|
||||
assert "<uuid>" not in fresh and "<mac" not in fresh and "backingStore" not in fresh
|
||||
|
||||
|
||||
def test_rewrite_xml_converts_block_disk_and_drops_nvram():
|
||||
source = """<domain type='kvm'><name>old</name><os><type>hvm</type><loader type='pflash'>/usr/share/OVMF/OVMF_CODE.fd</loader><nvram>/var/lib/libvirt/qemu/nvram/old_VARS.fd</nvram></os>
|
||||
<devices><disk type='block' device='disk'><driver name='qemu' type='raw'/><source dev='/dev/vg/old'/><target dev='vda' bus='virtio'/></disk></devices></domain>"""
|
||||
xml = backups.rewrite_xml(source, "new", {"vda": "/var/lib/virtuality/images/new.qcow2"})
|
||||
assert "<nvram>" not in xml
|
||||
assert 'type="file"' in xml and 'file="/var/lib/virtuality/images/new.qcow2"' in xml
|
||||
assert 'type="qcow2"' in xml and "source dev=" not in xml and 'type="raw"' not in xml
|
||||
|
||||
|
||||
def test_format_backup_date():
|
||||
assert backups.format_backup_date("20260924-0300") == "24 сентября 2026, 03:00"
|
||||
assert backups.format_backup_date("20260924-0300-2") == "24 сентября 2026, 03:00" # вторая копия в ту же минуту
|
||||
assert backups.format_backup_date("garbage") == "garbage"
|
||||
|
||||
|
||||
def test_clean_note_limits_length_and_control_chars():
|
||||
assert backups.clean_note(" до\x00обновления\n ") == "до обновления"
|
||||
assert len(backups.clean_note("x" * 500)) == backups.NOTE_MAX
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- хранилище
|
||||
def test_list_and_group_backups(data_dirs):
|
||||
make_backup(data_dirs, "web01", "20260917-0300", size=100)
|
||||
make_backup(data_dirs, "web01", "20260924-0300", size=200, note="перед обновлением")
|
||||
make_backup(data_dirs, "old-vm", "20260901-1200", size=50)
|
||||
(data_dirs["backups"] / "web01" / "junk").mkdir()
|
||||
(data_dirs["backups"] / "..hidden").mkdir()
|
||||
items = backups.list_backups()
|
||||
assert [(i["vm"], i["id"]) for i in items] == [("web01", "20260924-0300"), ("web01", "20260917-0300"), ("old-vm", "20260901-1200")]
|
||||
assert items[0]["note"] == "перед обновлением" and items[0]["complete"] is True
|
||||
groups = backups.group_by_vm(items, {"web01"})
|
||||
assert [(g["vm"], g["count"], g["size_bytes"], g["exists"]) for g in groups] == [("old-vm", 1, 50, False), ("web01", 2, 300, True)]
|
||||
assert backups.list_backups("web01")[0]["id"] == "20260924-0300"
|
||||
assert backups.storage_summary(items)["used_bytes"] == 350
|
||||
|
||||
|
||||
def test_backup_path_rejects_traversal(data_dirs):
|
||||
assert backups.backup_path("../etc", "20260924-0300") is None
|
||||
assert backups.backup_path("web01", "../../x") is None
|
||||
assert backups.backup_path("web01", "20260924-0300") == data_dirs["backups"] / "web01" / "20260924-0300"
|
||||
|
||||
|
||||
def test_incomplete_backup_is_flagged(data_dirs):
|
||||
make_backup(data_dirs, complete=False)
|
||||
assert backups.read_backup("web01", "20260924-0300")["complete"] is False
|
||||
|
||||
|
||||
def test_backup_with_unsafe_disk_target_is_incomplete(data_dirs):
|
||||
path = make_backup(data_dirs)
|
||||
meta = json.loads((path / "meta.json").read_text())
|
||||
meta["disks"].append({"target": "../../etc/shadow", "source": "/x", "size": 1})
|
||||
(path / "meta.json").write_text(json.dumps(meta))
|
||||
backup = backups.read_backup("web01", "20260924-0300")
|
||||
assert [disk["target"] for disk in backup["disks"]] == ["vda"] and backup["complete"] is False
|
||||
|
||||
|
||||
def test_storage_summary_percent_is_share_of_whole_disk(data_dirs, monkeypatch):
|
||||
monkeypatch.setattr(backups, "total_bytes", lambda path: 1000)
|
||||
monkeypatch.setattr(backups, "free_bytes", lambda path: 400)
|
||||
summary = backups.storage_summary([{"size_bytes": 350}])
|
||||
assert summary["used_pct"] == 35 and summary["free_bytes"] == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- создание копии
|
||||
def test_create_backup_refuses_without_space(data_dirs, monkeypatch):
|
||||
monkeypatch.setattr(backups, "free_bytes", lambda path: 1024)
|
||||
with pytest.raises(backups.BackupError) as exc:
|
||||
backups.create_backup("web01")
|
||||
assert "Недостаточно места" in str(exc.value) and "6.0 ГБ" in str(exc.value)
|
||||
|
||||
|
||||
def test_create_backup_rejects_unknown_vm(fast_worker):
|
||||
with pytest.raises(backups.BackupError):
|
||||
backups.create_backup("nope")
|
||||
with pytest.raises(backups.BackupError):
|
||||
backups.create_backup("bad name")
|
||||
|
||||
|
||||
def test_create_backup_shuts_down_copies_and_restarts(fast_worker, monkeypatch):
|
||||
virsh = FakeVirsh(running=True)
|
||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||
stream = fake_stream()
|
||||
monkeypatch.setattr(backups, "stream_cmd", stream)
|
||||
queued = backups.create_backup("web01", " ночная\tкопия ")
|
||||
operation = core.read_operation(queued["id"])
|
||||
assert operation["status"] == "success", operation["message"]
|
||||
assert operation["progress"] == 100 and "Резервная копия создана" in operation["message"]
|
||||
assert ["virsh", "shutdown", "web01"] in virsh.calls
|
||||
assert ["virsh", "dumpxml", "web01", "--migratable"] in virsh.calls
|
||||
assert ["qemu-img", "info", "-U", "--output=json", str(core.IMAGES_DIR / "web01.qcow2")] in virsh.calls
|
||||
assert virsh.calls[-1] == ["virsh", "start", "web01"]
|
||||
assert ["virsh", "destroy", "web01"] not in virsh.calls
|
||||
assert stream.calls == [["qemu-img", "convert", "-p", "-O", "qcow2", "-c", str(core.IMAGES_DIR / "web01.qcow2"), str(fast_worker["backups"] / "web01" / operation["backup_id"] / "vda.qcow2")]]
|
||||
path = fast_worker["backups"] / "web01" / operation["backup_id"]
|
||||
meta = json.loads((path / "meta.json").read_text())
|
||||
assert meta["vm"] == "web01" and meta["note"] == "ночная копия" and meta["disks"][0]["target"] == "vda"
|
||||
assert meta["size_bytes"] == sum(f.stat().st_size for f in path.iterdir() if f.name != "meta.json")
|
||||
assert "Сайт компании" in (path / "vm.xml").read_text()
|
||||
log = core.read_operation(operation["id"])["log_tail"]
|
||||
assert "48%" in log and "Машина запущена снова" in log
|
||||
|
||||
|
||||
def test_create_backup_wait_returns_finished_operation(fast_worker, monkeypatch):
|
||||
monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False))
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
operation = backups.create_backup("db01", wait=True)
|
||||
assert operation["status"] == "success" and operation["type"] == "backup"
|
||||
|
||||
|
||||
def test_scheduled_backup_is_marked_auto(fast_worker, monkeypatch):
|
||||
monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False))
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
operation = backups.create_backup("db01", "По расписанию", wait=True, auto=True)
|
||||
assert operation["status"] == "success" and operation["title"] == "Резервная копия db01 по расписанию"
|
||||
assert backups.list_backups("db01")[0]["auto"] is True
|
||||
backups.create_backup("web01", wait=True)
|
||||
assert backups.list_backups("web01")[0]["auto"] is False
|
||||
|
||||
|
||||
def test_backup_fails_when_vm_does_not_shut_down(fast_worker, monkeypatch):
|
||||
virsh = FakeVirsh(running=True, shuts_down=False)
|
||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
operation = backups.create_backup("web01")
|
||||
fresh = core.read_operation(operation["id"])
|
||||
assert fresh["status"] == "error" and "не выключилась" in fresh["message"] and "запустит её снова" in fresh["message"]
|
||||
assert ["virsh", "destroy", "web01"] not in virsh.calls
|
||||
assert ["virsh", "start", "web01"] not in virsh.calls # так и не выключилась — запускать нечего
|
||||
assert "так и не выключилась" in fresh["log_tail"]
|
||||
assert not (fast_worker["backups"] / "web01").exists()
|
||||
|
||||
|
||||
def test_backup_restarts_vm_that_shuts_down_late(fast_worker, monkeypatch):
|
||||
virsh = FakeVirsh(running=True, shuts_down=False)
|
||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
|
||||
def shut_down_after_giving_up(target, *args):
|
||||
virsh.running = False # ACPI-выключение дошло уже после того, как задача сдалась
|
||||
target(*args)
|
||||
|
||||
monkeypatch.setattr(backups, "start_thread", shut_down_after_giving_up)
|
||||
operation = backups.create_backup("web01")
|
||||
fresh = core.read_operation(operation["id"])
|
||||
assert fresh["status"] == "error" and "не выключилась" in fresh["message"]
|
||||
assert virsh.calls[-1] == ["virsh", "start", "web01"] and virsh.running
|
||||
assert "запускаем её снова" in fresh["log_tail"]
|
||||
|
||||
|
||||
def test_backup_reports_rejected_shutdown_command(fast_worker, monkeypatch):
|
||||
virsh = FakeVirsh(running=True, rejects_shutdown=True)
|
||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
monkeypatch.setattr(backups, "start_thread", lambda target, *args: pytest.fail("нечего ждать — команда не принята"))
|
||||
operation = backups.create_backup("web01")
|
||||
fresh = core.read_operation(operation["id"])
|
||||
assert fresh["status"] == "error"
|
||||
assert "не приняла команду выключения" in fresh["message"] and "domain is not running" in fresh["message"]
|
||||
assert "не выключилась" not in fresh["message"]
|
||||
assert ["virsh", "start", "web01"] not in virsh.calls
|
||||
assert not (fast_worker["backups"] / "web01").exists()
|
||||
|
||||
|
||||
def test_backup_of_paused_vm_saves_state_instead_of_shutdown(fast_worker, monkeypatch):
|
||||
virsh = FakeVirsh(running=True, paused=True)
|
||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
operation = backups.create_backup("web01")
|
||||
fresh = core.read_operation(operation["id"])
|
||||
assert fresh["status"] == "success", fresh["message"]
|
||||
assert ["virsh", "managedsave", "web01"] in virsh.calls and ["virsh", "shutdown", "web01"] not in virsh.calls
|
||||
assert virsh.calls.index(["virsh", "managedsave", "web01"]) < virsh.calls.index(["virsh", "dumpxml", "web01", "--migratable"])
|
||||
assert virsh.calls[-1] == ["virsh", "start", "web01"]
|
||||
assert "на паузе" in fresh["log_tail"]
|
||||
|
||||
|
||||
def test_backup_cleans_up_and_restarts_after_copy_error(fast_worker, monkeypatch):
|
||||
virsh = FakeVirsh(running=True)
|
||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream(code=1))
|
||||
operation = backups.create_backup("web01")
|
||||
fresh = core.read_operation(operation["id"])
|
||||
assert fresh["status"] == "error" and "vda" in fresh["message"]
|
||||
assert not (fast_worker["backups"] / "web01").exists()
|
||||
assert virsh.calls[-1] == ["virsh", "start", "web01"]
|
||||
|
||||
|
||||
def test_create_backup_refuses_while_another_runs(fast_worker, monkeypatch):
|
||||
monkeypatch.setattr(core, "run_operation", lambda operation, worker: None) # остаётся в очереди
|
||||
backups.create_backup("web01")
|
||||
with pytest.raises(backups.BackupError) as exc:
|
||||
backups.create_backup("web01")
|
||||
assert "уже идёт" in str(exc.value)
|
||||
|
||||
|
||||
def test_second_backup_in_same_minute_gets_own_folder_and_keeps_first(fast_worker, monkeypatch):
|
||||
monkeypatch.setattr(backups, "datetime", FixedDatetime)
|
||||
monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False))
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
first = core.read_operation(backups.create_backup("web01", "первая")["id"])
|
||||
assert first["status"] == "success" and first["backup_id"] == "20260924-0300"
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream(code=1))
|
||||
second = core.read_operation(backups.create_backup("web01", "вторая")["id"])
|
||||
assert second["status"] == "error" and second["backup_id"] == "20260924-0300-2"
|
||||
first_dir = fast_worker["backups"] / "web01" / "20260924-0300"
|
||||
assert (first_dir / "meta.json").exists() and (first_dir / "vda.qcow2").exists() # чужую папку неудачная задача не трогает
|
||||
assert not (fast_worker["backups"] / "web01" / "20260924-0300-2").exists()
|
||||
assert [item["id"] for item in backups.list_backups("web01")] == ["20260924-0300"]
|
||||
assert backups.backup_path("web01", "20260924-0300-2") == fast_worker["backups"] / "web01" / "20260924-0300-2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- восстановление
|
||||
def test_restore_under_new_name(fast_worker, monkeypatch):
|
||||
make_backup(fast_worker)
|
||||
virsh = FakeVirsh(running=False)
|
||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||
stream = fake_stream()
|
||||
monkeypatch.setattr(backups, "stream_cmd", stream)
|
||||
operation = backups.start_restore("web01", "20260924-0300", "web02", False)
|
||||
fresh = core.read_operation(operation["id"])
|
||||
assert fresh["status"] == "success", fresh["message"]
|
||||
assert fresh["vm_name"] == "web02"
|
||||
target = fast_worker["images"] / "web02.qcow2"
|
||||
assert stream.calls[0][:6] == ["qemu-img", "convert", "-p", "-O", "qcow2", str(fast_worker["backups"] / "web01" / "20260924-0300" / "vda.qcow2")]
|
||||
assert stream.calls[0][6].startswith(str(fast_worker["images"] / "web02.restore-")) # сначала во временный файл
|
||||
assert target.exists() and not list(fast_worker["images"].glob("*.restore-*"))
|
||||
assert "<name>web02</name>" in virsh.defined_xml and str(target) in virsh.defined_xml and "<mac" not in virsh.defined_xml
|
||||
assert not any(cmd[:2] == ["virsh", "undefine"] for cmd in virsh.calls)
|
||||
assert not list((fast_worker["backups"] / "web01" / "20260924-0300").glob("restore-*.xml"))
|
||||
|
||||
|
||||
def test_restore_requires_replace_confirmation(fast_worker):
|
||||
make_backup(fast_worker)
|
||||
with pytest.raises(backups.BackupError) as exc:
|
||||
backups.start_restore("web01", "20260924-0300", "web01", False)
|
||||
assert exc.value.code == "exists"
|
||||
|
||||
|
||||
UNDEFINE = ["virsh", "undefine", "web01", "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"]
|
||||
|
||||
|
||||
def test_restore_replaces_existing_vm_only_after_disks_are_ready(fast_worker, monkeypatch):
|
||||
make_backup(fast_worker)
|
||||
virsh = FakeVirsh(running=True)
|
||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream(calls=virsh.calls))
|
||||
operation = backups.start_restore("web01", "20260924-0300", "", True)
|
||||
fresh = core.read_operation(operation["id"])
|
||||
assert fresh["status"] == "success", fresh["message"]
|
||||
convert = next(index for index, cmd in enumerate(virsh.calls) if cmd[:2] == ["qemu-img", "convert"])
|
||||
assert convert < virsh.calls.index(["virsh", "destroy", "web01"]) < virsh.calls.index(UNDEFINE)
|
||||
assert virsh.calls.index(UNDEFINE) < next(index for index, cmd in enumerate(virsh.calls) if cmd[:2] == ["virsh", "define"])
|
||||
assert (fast_worker["images"] / "web01.qcow2").exists() and not list(fast_worker["images"].glob("*.restore-*"))
|
||||
assert "<name>web01</name>" in virsh.defined_xml
|
||||
# при замене машина остаётся «той же»: UUID и MAC из копии сохраняются
|
||||
assert "<uuid>6c1b3c1e-0000-0000-0000-000000000001</uuid>" in virsh.defined_xml and "52:54:00:aa:bb:cc" in virsh.defined_xml
|
||||
|
||||
|
||||
def test_replace_restore_keeps_old_vm_when_copy_fails(fast_worker, monkeypatch):
|
||||
make_backup(fast_worker)
|
||||
virsh = FakeVirsh(running=True)
|
||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream(code=1))
|
||||
operation = backups.start_restore("web01", "20260924-0300", "web01", True)
|
||||
fresh = core.read_operation(operation["id"])
|
||||
assert fresh["status"] == "error" and "Не удалось восстановить диск vda" in fresh["message"] and "qemu-img" not in fresh["message"]
|
||||
assert "qemu-img завершился с кодом 1" in fresh["log_tail"]
|
||||
assert ["virsh", "destroy", "web01"] not in virsh.calls and UNDEFINE not in virsh.calls and virsh.running
|
||||
assert not list(fast_worker["images"].iterdir())
|
||||
|
||||
|
||||
def test_restore_refuses_when_space_is_tight_even_if_old_vm_would_free_it(fast_worker, monkeypatch):
|
||||
make_backup(fast_worker)
|
||||
monkeypatch.setattr(backups, "free_bytes", lambda path: 1024)
|
||||
with pytest.raises(backups.BackupError) as exc:
|
||||
backups.start_restore("web01", "20260924-0300", "web01", True)
|
||||
assert exc.value.code == "space" and "рядом со старой машиной" in str(exc.value)
|
||||
with pytest.raises(backups.BackupError) as exc:
|
||||
backups.start_restore("web01", "20260924-0300", "web02", False)
|
||||
assert exc.value.code == "space" and "рядом со старой машиной" not in str(exc.value)
|
||||
|
||||
|
||||
def test_restore_ejects_iso_before_undefine(fast_worker, monkeypatch):
|
||||
make_backup(fast_worker)
|
||||
virsh = FakeVirsh(running=False)
|
||||
base = virsh.__call__
|
||||
|
||||
def with_iso(cmd, timeout=12, **kw):
|
||||
if cmd[:2] == ["virsh", "domblklist"]:
|
||||
virsh.calls.append(list(cmd))
|
||||
return fake_result(DOMBLKLIST_DETAILS.replace("sda -", "sda /var/lib/virtuality/iso/debian.iso"))
|
||||
return base(cmd, timeout, **kw)
|
||||
|
||||
monkeypatch.setattr(core, "run_cmd", with_iso)
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
backups.start_restore("web01", "20260924-0300", "web01", True)
|
||||
eject = ["virsh", "change-media", "web01", "sda", "--eject", "--config"]
|
||||
assert eject in virsh.calls
|
||||
assert virsh.calls.index(eject) < virsh.calls.index(UNDEFINE)
|
||||
|
||||
|
||||
def test_restore_rejects_bad_name_and_incomplete_backup(fast_worker):
|
||||
make_backup(fast_worker)
|
||||
with pytest.raises(backups.BackupError) as exc:
|
||||
backups.start_restore("web01", "20260924-0300", "-bad", False)
|
||||
assert exc.value.code == "name"
|
||||
make_backup(fast_worker, backup_id="20260901-0000", complete=False)
|
||||
with pytest.raises(backups.BackupError) as exc:
|
||||
backups.start_restore("web01", "20260901-0000", "web02", False)
|
||||
assert "неполная" in str(exc.value)
|
||||
|
||||
|
||||
def test_restore_cleans_up_on_define_failure(fast_worker, monkeypatch):
|
||||
make_backup(fast_worker)
|
||||
virsh = FakeVirsh(running=False)
|
||||
base = virsh.__call__
|
||||
|
||||
def failing_define(cmd, timeout=12, **kw):
|
||||
if cmd[:2] == ["virsh", "define"]:
|
||||
return fake_result(ok=False, stderr="error: XML error: bad thing")
|
||||
return base(cmd, timeout, **kw)
|
||||
|
||||
monkeypatch.setattr(core, "run_cmd", failing_define)
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
operation = backups.start_restore("web01", "20260924-0300", "web02", False)
|
||||
fresh = core.read_operation(operation["id"])
|
||||
assert fresh["status"] == "error" and "bad thing" in fresh["message"]
|
||||
assert not (fast_worker["images"] / "web02.qcow2").exists() and not list(fast_worker["images"].glob("*.restore-*"))
|
||||
|
||||
|
||||
def test_restore_disk_path_avoids_collisions(data_dirs):
|
||||
(data_dirs["images"] / "web02.qcow2").write_bytes(b"x")
|
||||
assert backups.restore_disk_path("web02", "vda", 0) == data_dirs["images"] / "web02-vda.qcow2"
|
||||
assert backups.restore_disk_path("web02", "vdb", 1) == data_dirs["images"] / "web02-vdb.qcow2"
|
||||
(data_dirs["images"] / "web02-vda.qcow2").write_bytes(b"x")
|
||||
assert backups.restore_disk_path("web02", "vda", 0) == data_dirs["images"] / "web02-vda-2.qcow2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- страницы
|
||||
def test_backup_pages_require_login(client):
|
||||
for path in ("/backups", "/vm/web01/backups", "/backups/web01/20260924-0300/restore"):
|
||||
response = client.get(path, follow_redirects=False)
|
||||
assert response.status_code == 303 and response.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_backups_page_lists_groups(logged_in, data_dirs):
|
||||
make_backup(data_dirs, "web01", "20260924-0300", size=2_684_354_560, note="перед обновлением")
|
||||
make_backup(data_dirs, "gone-vm", "20260901-1200", size=1024 ** 3)
|
||||
html = logged_in.get("/backups").text
|
||||
assert "24 сентября 2026, 03:00" in html and "2.5 ГБ" in html and "перед обновлением" in html
|
||||
assert 'href="/backups/web01/20260924-0300/restore"' in html
|
||||
assert "машина удалена" in html and 'href="/vm/web01/backups"' in html
|
||||
assert "Резервные копии" in html
|
||||
|
||||
|
||||
def test_backups_page_empty_state(logged_in):
|
||||
html = logged_in.get("/backups").text
|
||||
assert "Копий пока нет" in html
|
||||
|
||||
|
||||
def test_vm_backups_page_and_tab_link(logged_in, data_dirs):
|
||||
make_backup(data_dirs)
|
||||
html = logged_in.get("/vm/web01/backups").text
|
||||
assert 'action="/vm/web01/backups/create"' in html and "Создать копию" in html
|
||||
assert "24 сентября 2026, 03:00" in html
|
||||
assert 'href="/vm/web01/backups"' in logged_in.get("/vm/web01").text
|
||||
assert logged_in.get("/vm/nope/backups", follow_redirects=False).headers["location"] == "/"
|
||||
|
||||
|
||||
def test_vm_backup_create_route(logged_in, fast_worker, monkeypatch):
|
||||
monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False))
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
response = logged_in.post("/vm/web01/backups/create", data={"note": "перед обновлением"}, follow_redirects=False)
|
||||
assert response.status_code == 303 and response.headers["location"].startswith("/operations/")
|
||||
operation_id = response.headers["location"].rsplit("/", 1)[1]
|
||||
assert core.read_operation(operation_id)["status"] == "success"
|
||||
assert logged_in.get(f"/operations/{operation_id}").status_code == 200
|
||||
|
||||
|
||||
def test_vm_backup_create_route_shows_error(logged_in, data_dirs, monkeypatch):
|
||||
monkeypatch.setattr(backups, "free_bytes", lambda path: 0)
|
||||
response = logged_in.post("/vm/web01/backups/create", data={"note": ""}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert "backup_error=" in response.headers["location"] and "Недостаточно" in unquote(response.headers["location"])
|
||||
assert "Недостаточно места" in logged_in.get(response.headers["location"]).text
|
||||
|
||||
|
||||
def test_restore_page_and_submit(logged_in, fast_worker, monkeypatch):
|
||||
make_backup(fast_worker)
|
||||
monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False))
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
html = logged_in.get("/backups/web01/20260924-0300/restore").text
|
||||
assert 'name="replace"' in html and "Заменить существующую машину web01" in html
|
||||
assert "Старая машина удаляется только после распаковки" in html and "Сетевые адреса сохраняются" in html
|
||||
response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "web01", "replace": ""})
|
||||
assert response.status_code == 400 and "уже есть на сервере" in response.text
|
||||
response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "web02", "replace": ""}, follow_redirects=False)
|
||||
assert response.status_code == 303 and response.headers["location"].startswith("/operations/")
|
||||
response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "-bad"})
|
||||
assert response.status_code == 400
|
||||
assert logged_in.get("/backups/web01/20990101-0000/restore", follow_redirects=False).headers["location"].startswith("/backups?backup_error=")
|
||||
|
||||
|
||||
def test_delete_backup_route(logged_in, data_dirs):
|
||||
path = make_backup(data_dirs)
|
||||
response = logged_in.post("/backups/web01/20260924-0300/delete", data={"next": "/vm/web01/backups"}, follow_redirects=False)
|
||||
assert response.status_code == 303 and response.headers["location"].startswith("/vm/web01/backups?backup_message=")
|
||||
assert not path.exists() and not path.parent.exists()
|
||||
response = logged_in.post("/backups/web01/20260924-0300/delete", data={"next": "http://evil"}, follow_redirects=False)
|
||||
assert response.headers["location"].startswith("/backups?backup_error=")
|
||||
|
||||
|
||||
def test_delete_backup_path_traversal(logged_in, data_dirs, tmp_path):
|
||||
victim = tmp_path / "victim"
|
||||
victim.mkdir()
|
||||
(victim / "20260924-0300").mkdir()
|
||||
# %2F раскрывается до маршрутизации: лишние сегменты не подходят ни под один маршрут
|
||||
assert logged_in.post("/backups/..%2Fvictim/20260924-0300/delete", follow_redirects=False).status_code == 404
|
||||
assert logged_in.post("/backups/web01/..%2F..%2Fvictim/delete", follow_redirects=False).status_code == 404
|
||||
# а сама проверка путей — в delete_backup, независимо от маршрутизации
|
||||
with pytest.raises(backups.BackupError):
|
||||
backups.delete_backup("../victim", "20260924-0300")
|
||||
with pytest.raises(backups.BackupError):
|
||||
backups.delete_backup("web01", "../x")
|
||||
assert (victim / "20260924-0300").exists()
|
||||
|
||||
|
||||
def test_delete_backup_refused_while_copy_is_in_use(fast_worker, monkeypatch):
|
||||
monkeypatch.setattr(core, "run_operation", lambda operation, worker: None) # задачи остаются в очереди
|
||||
monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False))
|
||||
queued = backups.create_backup("web01")
|
||||
with pytest.raises(backups.BackupError) as exc:
|
||||
backups.delete_backup("web01", queued["backup_id"])
|
||||
assert "идёт работа" in str(exc.value)
|
||||
core.finish_operation(queued, False, "прервано")
|
||||
backups.delete_backup("web01", queued["backup_id"])
|
||||
assert not (fast_worker["backups"] / "web01").exists()
|
||||
|
||||
path = make_backup(fast_worker)
|
||||
restore = backups.start_restore("web01", "20260924-0300", "web02", False) # под другим именем: задача записана на web02
|
||||
second = backups.start_restore("web01", "20260924-0300", "web03", False) # две распаковки одной копии сразу
|
||||
with pytest.raises(backups.BackupError) as exc:
|
||||
backups.delete_backup("web01", "20260924-0300")
|
||||
assert "идёт работа" in str(exc.value)
|
||||
core.finish_operation(second, False, "прервано") # первая ещё идёт — копию по-прежнему нельзя удалить
|
||||
with pytest.raises(backups.BackupError):
|
||||
backups.delete_backup("web01", "20260924-0300")
|
||||
core.finish_operation(restore, False, "прервано")
|
||||
backups.delete_backup("web01", "20260924-0300")
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_late_restart_watch_is_cancelled_by_user_action(fast_worker, monkeypatch):
|
||||
import threading
|
||||
|
||||
virsh = FakeVirsh(running=True, shuts_down=False)
|
||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
monkeypatch.setattr(backups, "LATE_SHUTDOWN_WATCH", 5.0)
|
||||
watchers = []
|
||||
monkeypatch.setattr(backups, "start_thread", lambda target, *args: watchers.append(threading.Thread(target=target, args=args)) or watchers[-1].start())
|
||||
operation = backups.create_backup("web01")
|
||||
for _ in range(200): # наблюдатель запущен и ждёт
|
||||
if "web01" in backups.LATE_WATCHES:
|
||||
break
|
||||
__import__("time").sleep(0.01)
|
||||
backups.cancel_late_restart("web01") # пользователь нажал «Выключить» в панели
|
||||
virsh.running = False
|
||||
watchers[0].join(timeout=5)
|
||||
assert not watchers[0].is_alive() and ["virsh", "start", "web01"] not in virsh.calls
|
||||
assert "Наблюдение снято" in core.read_operation(operation["id"])["log_tail"]
|
||||
|
||||
|
||||
def test_late_restart_rechecks_cancel_after_shutdown(fast_worker, monkeypatch):
|
||||
virsh = FakeVirsh(running=True, shuts_down=False)
|
||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||
monkeypatch.setattr(backups, "LATE_SHUTDOWN_WATCH", 5.0)
|
||||
operation = core.new_operation("backup", "Резервная копия web01", vm_name="web01")
|
||||
|
||||
def shut_down_by_user(vm):
|
||||
backups.cancel_late_restart(vm) # «Выключить» нажали, пока наблюдатель спрашивал состояние машины
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(core, "vm_is_running", shut_down_by_user)
|
||||
backups.restart_after_late_shutdown(operation, "web01")
|
||||
assert ["virsh", "start", "web01"] not in virsh.calls
|
||||
assert "Наблюдение снято" in core.read_operation(operation["id"])["log_tail"]
|
||||
assert "web01" not in backups.LATE_WATCHES
|
||||
@@ -0,0 +1,80 @@
|
||||
import cloud_images
|
||||
|
||||
|
||||
def test_catalog_entries_consistent():
|
||||
keys = [entry["key"] for entry in cloud_images.CATALOG]
|
||||
assert len(keys) == len(set(keys))
|
||||
for entry in cloud_images.CATALOG:
|
||||
assert entry["arch"] in ("x86_64", "aarch64")
|
||||
assert entry["url"].startswith("https://")
|
||||
assert cloud_images.safe_image_filename(entry["filename"])
|
||||
|
||||
|
||||
def test_catalog_entry_lookup():
|
||||
assert cloud_images.catalog_entry("ubuntu-24.04-x86_64") is not None
|
||||
assert cloud_images.catalog_entry("nope") is None
|
||||
|
||||
|
||||
def test_catalog_for_arch_puts_native_first():
|
||||
entries = cloud_images.catalog_for_arch("aarch64")
|
||||
natives = [e["native"] for e in entries]
|
||||
assert natives == sorted(natives, reverse=True)
|
||||
assert entries[0]["arch"] == "aarch64"
|
||||
entries_x86 = cloud_images.catalog_for_arch("x86_64")
|
||||
assert entries_x86[0]["arch"] == "x86_64"
|
||||
|
||||
|
||||
def test_safe_image_filename():
|
||||
assert cloud_images.safe_image_filename("ubuntu-24.04-x86_64.qcow2")
|
||||
assert cloud_images.safe_image_filename("img.img")
|
||||
assert cloud_images.safe_image_filename("../../etc/passwd") is None
|
||||
assert cloud_images.safe_image_filename("../escape.qcow2") == "escape.qcow2" # basename-санитизация
|
||||
assert cloud_images.safe_image_filename("evil.iso") is None
|
||||
|
||||
|
||||
def test_image_path_traversal_guard(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(cloud_images, "CLOUD_IMAGES_DIR", tmp_path)
|
||||
assert cloud_images.image_path_by_name("ok.qcow2") == tmp_path / "ok.qcow2"
|
||||
# Каталоги отбрасываются до basename — результат всегда внутри CLOUD_IMAGES_DIR
|
||||
assert cloud_images.image_path_by_name("../escape.qcow2") == tmp_path / "escape.qcow2"
|
||||
assert cloud_images.image_path_by_name("bad name.qcow2") is None
|
||||
|
||||
|
||||
def test_valid_cloud_username():
|
||||
assert cloud_images.valid_cloud_username("admin")
|
||||
assert cloud_images.valid_cloud_username("_svc-user")
|
||||
assert not cloud_images.valid_cloud_username("Admin")
|
||||
assert not cloud_images.valid_cloud_username("1abc")
|
||||
assert not cloud_images.valid_cloud_username("")
|
||||
|
||||
|
||||
def test_valid_ssh_key():
|
||||
assert cloud_images.valid_ssh_key("") # ключ необязателен
|
||||
assert cloud_images.valid_ssh_key("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample user@host")
|
||||
assert cloud_images.valid_ssh_key("ssh-rsa AAAAB3NzaC1yc2E=")
|
||||
assert not cloud_images.valid_ssh_key("not a key")
|
||||
assert not cloud_images.valid_ssh_key("ssh-ed25519")
|
||||
|
||||
|
||||
def test_build_user_data_with_password():
|
||||
data = cloud_images.build_user_data("vm1", "admin", 'p"ss', "")
|
||||
assert data.startswith("#cloud-config")
|
||||
assert "hostname: vm1" in data
|
||||
assert "name: admin" in data
|
||||
assert "ssh_pwauth: true" in data
|
||||
assert '"p\\"ss"' in data # пароль экранирован как JSON/YAML-строка
|
||||
assert "ssh_authorized_keys" not in data
|
||||
|
||||
|
||||
def test_build_user_data_with_key_only():
|
||||
key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample user@host"
|
||||
data = cloud_images.build_user_data("vm1", "admin", "", key)
|
||||
assert "ssh_pwauth: false" in data
|
||||
assert key in data
|
||||
assert "chpasswd" not in data
|
||||
|
||||
|
||||
def test_build_meta_data():
|
||||
meta = cloud_images.build_meta_data("vm1")
|
||||
assert "instance-id: virtuality-vm1" in meta
|
||||
assert "local-hostname: vm1" in meta
|
||||
@@ -1,206 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import network_core
|
||||
from conftest import ROOT
|
||||
import restore_network
|
||||
import update_core
|
||||
|
||||
|
||||
def test_parse_port_range():
|
||||
assert network_core.parse_port_range("80") == (80, 80)
|
||||
assert network_core.parse_port_range("10000-10010") == (10000, 10010)
|
||||
assert network_core.parse_port_range("10000:10010") == (10000, 10010)
|
||||
for bad in ("", "0", "70000", "20-10", "abc", "80;rm"):
|
||||
with pytest.raises(network_core.NetworkError):
|
||||
network_core.parse_port_range(bad)
|
||||
|
||||
|
||||
def test_add_port_forward_rejects_overlap(data_dirs):
|
||||
network_core.add_port_forward("web01", "192.168.100.51", "8000-8010", "8000-8010", "tcp")
|
||||
with pytest.raises(network_core.NetworkError):
|
||||
network_core.add_port_forward("web01", "192.168.100.52", "8005", "80", "tcp")
|
||||
network_core.add_port_forward("web01", "192.168.100.52", "8005", "80", "udp")
|
||||
|
||||
|
||||
def test_add_port_forward_rejects_mismatched_ranges(data_dirs):
|
||||
with pytest.raises(network_core.NetworkError):
|
||||
network_core.add_port_forward("web01", "192.168.100.51", "8000-8010", "80", "tcp")
|
||||
|
||||
|
||||
def test_render_nft_rules_range(data_dirs):
|
||||
network_core.add_port_forward("web01", "192.168.100.51", "10000-10002", "20000-20002", "udp")
|
||||
rules = network_core.render_nft_rules()
|
||||
assert 'iifname "eth0" udp dport 10000-10002 dnat to 192.168.100.51:20000-20002' in rules
|
||||
assert "masquerade" in rules
|
||||
|
||||
|
||||
def test_restore_network_noop_without_config(data_dirs, capsys):
|
||||
assert restore_network.main() == 0
|
||||
assert "nothing to restore" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_restore_network_applies_rules(data_dirs):
|
||||
data_dirs["network"].joinpath("port_forwards.json").write_text(json.dumps([
|
||||
{"id": "1", "vm_name": "web01", "guest_ip": "192.168.100.51", "external_port_start": 2222, "external_port_end": 2222, "guest_port_start": 22, "guest_port_end": 22, "protocol": "tcp"},
|
||||
]))
|
||||
assert restore_network.main() == 0
|
||||
assert "dport 2222 dnat to 192.168.100.51:22" in (data_dirs["nft"] / "virtuality.nft").read_text()
|
||||
|
||||
|
||||
def test_version_tuple_ordering():
|
||||
assert update_core.version_tuple("0.9.6") < update_core.version_tuple("0.10.0")
|
||||
assert update_core.version_tuple("v1.0") == update_core.version_tuple("1.0.0")
|
||||
|
||||
|
||||
def test_missing_versions():
|
||||
manifest = {"versions": [{"version": v} for v in ("0.9.5", "0.9.6", "0.10.0", "0.10.1")]}
|
||||
missed = update_core.missing_versions("0.9.6", "0.10.1", manifest)
|
||||
assert [item["version"] for item in missed] == ["0.10.0", "0.10.1"]
|
||||
|
||||
|
||||
def test_versions_manifest_matches_version_file():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
version = (root / "VERSION").read_text().strip()
|
||||
manifest = json.loads((root / "updates" / "versions.json").read_text())
|
||||
assert version in [item["version"] for item in manifest["versions"]]
|
||||
|
||||
|
||||
def test_presenters_parse_virsh_tables():
|
||||
import presenters
|
||||
|
||||
disks = presenters.parse_domblklist(" Type Device Target Source\n------------------------------------\n file disk vda /var/lib/virtuality/images/web01.qcow2\n file cdrom sda -\n")
|
||||
assert [d["name"] for d in disks] == ["web01.qcow2", "Пусто"]
|
||||
assert disks[1]["is_cdrom"]
|
||||
nics = presenters.parse_domiflist(" Interface Type Source Model MAC\n---\n vnet0 network virtuality-nat virtio 52:54:00:aa:bb:cc\n")
|
||||
assert nics[0]["mac"] == "52:54:00:aa:bb:cc"
|
||||
info = presenters.parse_dominfo("State: shut off\nCPU(s): 4\nMax memory: 8388608 KiB\nUsed memory: 8388608 KiB\nAutostart: disable\n")
|
||||
assert (info["vcpus"], info["memory_mb"], info["autostart"]) == (4, 8192, False)
|
||||
assert presenters.vm_state("shut off")["label"] == "Выключена"
|
||||
assert presenters.format_mb(1536) == "1.5 ГБ"
|
||||
assert presenters.plural(22, "ядро", "ядра", "ядер") == "ядра"
|
||||
|
||||
|
||||
def test_update_not_offered_when_local_is_newer(tmp_path, monkeypatch, data_dirs):
|
||||
import subprocess
|
||||
|
||||
def git(*args, cwd):
|
||||
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
|
||||
|
||||
remote, local = tmp_path / "remote", tmp_path / "local"
|
||||
remote.mkdir()
|
||||
git("init", "-q", "-b", "main", cwd=remote)
|
||||
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "base", cwd=remote)
|
||||
git("clone", "-q", str(remote), str(local), cwd=tmp_path)
|
||||
(local / "VERSION").write_text("0.10.0\n")
|
||||
git("add", "VERSION", cwd=local)
|
||||
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "newer local build", cwd=local)
|
||||
monkeypatch.setattr(update_core, "SOURCE_DIR", local)
|
||||
assert update_core.check_updates(fetch=True)["has_update"] is False
|
||||
|
||||
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "upstream", cwd=remote)
|
||||
git("reset", "-q", "--hard", "origin/main", cwd=local)
|
||||
assert update_core.check_updates(fetch=True)["has_update"] is True
|
||||
|
||||
|
||||
GITEA = "https://git.stackworks.ru/viktor/virtuality"
|
||||
|
||||
|
||||
def make_source(path):
|
||||
import subprocess
|
||||
|
||||
def git(*args):
|
||||
return subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], cwd=path, check=True, capture_output=True, text=True).stdout.strip()
|
||||
|
||||
path.mkdir()
|
||||
git("init", "-q", "-b", "main")
|
||||
git("commit", "-q", "--allow-empty", "-m", "base")
|
||||
git("remote", "add", "origin", "https://github.com/viktor138irk/virtuality.git")
|
||||
return git
|
||||
|
||||
|
||||
def test_migrate_remote_moves_only_the_old_github_address(tmp_path, monkeypatch, data_dirs):
|
||||
git = make_source(tmp_path / "source")
|
||||
monkeypatch.setattr(update_core, "SOURCE_DIR", tmp_path / "source")
|
||||
assert update_core.migrate_remote() is True
|
||||
assert git("remote", "get-url", "origin") == GITEA + ".git"
|
||||
assert update_core.migrate_remote() is False
|
||||
git("remote", "set-url", "origin", "https://example.org/me/fork.git")
|
||||
assert update_core.migrate_remote() is False
|
||||
assert git("remote", "get-url", "origin") == "https://example.org/me/fork.git"
|
||||
|
||||
|
||||
def test_update_target_script_downloads_from_gitea(tmp_path):
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
source = tmp_path / "source"
|
||||
git = make_source(source)
|
||||
|
||||
def target(channel, script="resolve_update_target"):
|
||||
env = {**os.environ, "VIRTUALITY_UPDATE_CHANNEL": channel}
|
||||
code = f'SOURCE_DIR="{source}"; REMOTE=origin; BRANCH=main; . "{ROOT}/scripts/update_target.sh"; {script}; echo "$TARGET_LABEL $TARGET_ZIP_URL"'
|
||||
return subprocess.run(["bash", "-c", code], env=env, check=True, capture_output=True, text=True).stdout.strip().splitlines()
|
||||
|
||||
moved, result = target("main", "migrate_update_remote; resolve_update_target")
|
||||
assert moved == f"update remote moved: https://github.com/viktor138irk/virtuality.git -> {GITEA}.git"
|
||||
assert result == f"main {GITEA}/archive/main.zip"
|
||||
assert git("remote", "get-url", "origin") == GITEA + ".git"
|
||||
git("tag", "v0.11.1")
|
||||
assert target("stable") == [f"v0.11.1 {GITEA}/archive/v0.11.1.zip"]
|
||||
assert target("main") == [f"main {GITEA}/archive/main.zip"]
|
||||
# A fork downloads from itself; a non-https remote falls back to the project's address.
|
||||
git("remote", "set-url", "origin", "https://example.org/me/fork.git")
|
||||
assert target("stable", "migrate_update_remote; resolve_update_target") == ["v0.11.1 https://example.org/me/fork/archive/v0.11.1.zip"]
|
||||
git("remote", "set-url", "origin", "git@example.org:me/fork.git")
|
||||
assert target("stable") == [f"v0.11.1 {GITEA}/archive/v0.11.1.zip"]
|
||||
|
||||
|
||||
def test_stable_channel_follows_release_tags(tmp_path, monkeypatch, data_dirs):
|
||||
import subprocess
|
||||
|
||||
def git(*args, cwd):
|
||||
subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], cwd=cwd, check=True, capture_output=True)
|
||||
|
||||
remote, local = tmp_path / "remote", tmp_path / "local"
|
||||
remote.mkdir()
|
||||
git("init", "-q", "-b", "main", cwd=remote)
|
||||
(remote / "VERSION").write_text("0.10.0\n")
|
||||
git("add", "VERSION", cwd=remote)
|
||||
git("commit", "-q", "-m", "0.10.0", cwd=remote)
|
||||
git("clone", "-q", str(remote), str(local), cwd=tmp_path)
|
||||
monkeypatch.setattr(update_core, "SOURCE_DIR", local)
|
||||
monkeypatch.setattr(update_core, "NODE_ENV", tmp_path / "web.env")
|
||||
monkeypatch.delenv("VIRTUALITY_UPDATE_CHANNEL", raising=False)
|
||||
assert update_core.update_channel() == "stable"
|
||||
|
||||
# Work on main without a release: stable follows main until the first tag exists.
|
||||
(remote / "VERSION").write_text("0.11.0\n")
|
||||
git("commit", "-q", "-am", "0.11.0", cwd=remote)
|
||||
data = update_core.check_updates(fetch=True)
|
||||
assert data["has_update"] and data["target"] == "main"
|
||||
|
||||
# With a release tag, stable offers the tag and ignores newer commits on main.
|
||||
git("tag", "v0.11.0", cwd=remote)
|
||||
git("commit", "-q", "--allow-empty", "-m", "work in progress", cwd=remote)
|
||||
data = update_core.check_updates(fetch=True)
|
||||
assert data["channel"] == "stable" and data["target"] == "v0.11.0" and data["latest_version"] == "0.11.0" and data["has_update"]
|
||||
git("checkout", "-q", "v0.11.0", cwd=local)
|
||||
assert update_core.check_updates(fetch=True)["has_update"] is False
|
||||
|
||||
# The early-access channel sees every change on main.
|
||||
(tmp_path / "web.env").write_text("VIRTUALITY_UPDATE_CHANNEL=main\n")
|
||||
data = update_core.check_updates(fetch=True)
|
||||
assert data["channel"] == "main" and data["target"] == "main" and data["has_update"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("username", ["admin", "root", "kvm", "libvirt-qemu"])
|
||||
def test_build_iso_refuses_names_the_ubuntu_installer_rejects(username):
|
||||
"""Установщик Ubuntu не принимает системные имена: автоматический образ с таким именем падал на первом шаге."""
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(["bash", str(ROOT / "image" / "build-iso.sh"), "--unattended", "--username", username, "--password-hash", "$6$salt$hash"], capture_output=True, text=True)
|
||||
assert result.returncode != 0 and "reserved by the Ubuntu installer" in result.stderr
|
||||
|
||||
@@ -1,638 +0,0 @@
|
||||
"""Скачивание образов по ссылке и из каталога: парсер контрольных сумм,
|
||||
проверка ссылок, фоновая загрузка с локального http-сервера, отмена."""
|
||||
import hashlib
|
||||
import lzma
|
||||
import socket
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import unquote
|
||||
|
||||
import pytest
|
||||
|
||||
import app # noqa: F401 — подключает features.downloads к приложению
|
||||
import catalog
|
||||
import core
|
||||
import features.downloads as downloads
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||
MB = 1024 * 1024
|
||||
SLOW_TOTAL = 4 * MB
|
||||
CHUNKED_TOTAL = 40 * 64 * 1024
|
||||
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
"""Раздаёт файлы из каталога www; /slow.iso отдаёт 4 МБ по 64 КБ с паузами, /redirect.iso — перенаправляет,
|
||||
/chunked.iso — 2,5 МБ с паузами без заголовка Content-Length (размер заранее неизвестен)."""
|
||||
|
||||
root = ""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=self.root, **kwargs)
|
||||
|
||||
def log_message(self, *args): # noqa: D102 — тише в тестах
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/redirect.iso":
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/small.iso")
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path in ("/slow.iso", "/chunked.iso"):
|
||||
total = SLOW_TOTAL if self.path == "/slow.iso" else CHUNKED_TOTAL
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
if self.path == "/slow.iso":
|
||||
self.send_header("Content-Length", str(total))
|
||||
self.end_headers()
|
||||
try:
|
||||
for _ in range(total // (64 * 1024)):
|
||||
self.wfile.write(b"\0" * 64 * 1024)
|
||||
self.wfile.flush()
|
||||
time.sleep(0.05)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def www(tmp_path):
|
||||
root = tmp_path / "www"
|
||||
root.mkdir()
|
||||
(root / "small.iso").write_bytes(b"ISO" * 5000)
|
||||
handler = type("TestHandler", (Handler,), {"root": str(root)})
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
yield SimpleNamespace(root=root, url=f"http://127.0.0.1:{server.server_port}")
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def wait_for(operation_id: str, timeout: float = 20.0) -> dict:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
operation = core.read_operation(operation_id)
|
||||
if operation and operation.get("status") in ("success", "error"):
|
||||
return operation
|
||||
time.sleep(0.05)
|
||||
raise AssertionError("операция не завершилась вовремя")
|
||||
|
||||
|
||||
def wait_until(predicate, timeout: float = 10.0) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(0.05)
|
||||
raise AssertionError("условие не наступило вовремя")
|
||||
|
||||
|
||||
def message_of(operation_id: str) -> str:
|
||||
return str((core.read_operation(operation_id) or {}).get("message", ""))
|
||||
|
||||
|
||||
def seed_running_download(kind: str, filename: str, **meta) -> dict:
|
||||
"""Операция «скачивается» без потока — как после перезапуска панели."""
|
||||
target = downloads.target_path(kind, filename)
|
||||
operation = core.new_operation("download", f"Скачивание {filename}", download_kind=kind, url=f"http://127.0.0.1:9/{filename}", filename=filename, target_path=str(target), cancel_requested=False, **meta)
|
||||
core.update_operation(operation, status="running")
|
||||
return operation
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- parsers & formatting
|
||||
def test_parse_checksums_ubuntu_star_format():
|
||||
text = (FIXTURES / "SHA256SUMS").read_text()
|
||||
assert downloads.parse_checksums(text, "resolute-server-cloudimg-amd64.img") == "5861314d7fccb39c2192173240eab44fa35ca66426201ca2acd0630a6258dd51"
|
||||
assert downloads.parse_checksums(text, "resolute-server-cloudimg-arm64.img").startswith("f6916295")
|
||||
assert downloads.parse_checksums(text, "missing.img") is None
|
||||
|
||||
|
||||
def test_parse_checksums_debian_two_spaces_and_comments():
|
||||
text = (FIXTURES / "SHA512SUMS").read_text()
|
||||
assert downloads.parse_checksums(text, "debian-13-genericcloud-amd64.qcow2").startswith("1412d62c2d55")
|
||||
assert downloads.parse_checksums(text, "debian-13-genericcloud-arm64.qcow2").startswith("27c03aab0b5e")
|
||||
|
||||
|
||||
def test_parse_checksums_alpine_bare_hash_and_bsd_style():
|
||||
bare = (FIXTURES / "nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512").read_text()
|
||||
assert downloads.parse_checksums(bare, "anything.qcow2") == bare.strip().lower()
|
||||
bsd = "SHA256 (disk.qcow2) = ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789\n"
|
||||
assert downloads.parse_checksums(bsd, "disk.qcow2") == "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
||||
assert downloads.parse_checksums(bsd, "other.qcow2") is None
|
||||
assert downloads.parse_checksums("not a checksum file\n", "disk.qcow2") is None
|
||||
|
||||
|
||||
def test_human_size_and_eta():
|
||||
assert downloads.human_size(0) == "0 Б"
|
||||
assert downloads.human_size(1023) == "1023 Б"
|
||||
assert downloads.human_size(1536) == "1,5 КБ"
|
||||
assert downloads.human_size(245 * MB) == "245 МБ"
|
||||
assert downloads.human_size(int(3.1 * 1024 * MB)) == "3,1 ГБ"
|
||||
assert downloads.human_size(10 * MB) == "10 МБ"
|
||||
assert downloads.human_eta(45) == "45 с"
|
||||
assert downloads.human_eta(244) == "4 мин"
|
||||
assert downloads.human_eta(3700) == "1 ч 2 мин"
|
||||
assert downloads.human_eta(7200) == "2 ч"
|
||||
|
||||
|
||||
def test_progress_message():
|
||||
assert downloads.progress_message(245 * MB, int(3.1 * 1024 * MB), 12 * MB) == "245 МБ из 3,1 ГБ · 12 МБ/с · осталось 4 мин"
|
||||
assert downloads.progress_message(245 * MB, None, 12 * MB) == "245 МБ · 12 МБ/с"
|
||||
assert downloads.progress_message(0, 100 * MB, None) == "0 Б из 100 МБ"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- validation
|
||||
@pytest.mark.parametrize("url", ["", "ftp://example.org/x.iso", "file:///etc/passwd", "javascript:alert(1)", "http://", "http:///x.iso", "http://example.org/a b.iso", "http://example.org:abc/x.iso", "http://[::1/x.iso"])
|
||||
def test_validate_url_rejects(url):
|
||||
with pytest.raises(downloads.DownloadError):
|
||||
downloads.validate_url(url)
|
||||
|
||||
|
||||
def test_encode_url_handles_cyrillic_and_keeps_encoded_links():
|
||||
encoded = downloads.encode_url("http://пример.рф:8080/папка/образ диска.iso?имя=ы#x")
|
||||
assert encoded == "http://xn--e1afmkfd.xn--p1ai:8080/%D0%BF%D0%B0%D0%BF%D0%BA%D0%B0/%D0%BE%D0%B1%D1%80%D0%B0%D0%B7%20%D0%B4%D0%B8%D1%81%D0%BA%D0%B0.iso?%D0%B8%D0%BC%D1%8F=%D1%8B"
|
||||
assert downloads.encode_url(encoded) == encoded
|
||||
for same in ("https://user:pw@h.example/a%20b/x.iso?a=1&b=%2F", "http://[::1]:9/x.iso", "http://127.0.0.1/x.iso"):
|
||||
assert downloads.encode_url(same) == same
|
||||
assert downloads.validate_url("https://пример.рф/образ.iso") == "https://пример.рф/образ.iso"
|
||||
|
||||
|
||||
def test_describe_network_error_is_friendly():
|
||||
describe = downloads.describe_network_error
|
||||
assert describe(urllib.error.HTTPError("u", 404, "Not Found", {}, None)) == "Файл по ссылке не найден (ошибка 404)"
|
||||
assert describe(urllib.error.HTTPError("u", 403, "Forbidden", {}, None)) == "Сервер запретил скачивание (ошибка 403)"
|
||||
assert describe(urllib.error.HTTPError("u", 418, "Teapot", {}, None)) == "Сервер ответил ошибкой 418"
|
||||
assert describe(urllib.error.URLError(socket.gaierror(-2, "Name or service not known"))) == "Не удалось найти сервер по этой ссылке — проверьте адрес"
|
||||
assert describe(urllib.error.URLError(ConnectionRefusedError(111, "Connection refused"))) == "Сервер отказал в подключении"
|
||||
assert describe(urllib.error.URLError(ssl.SSLCertVerificationError("CERTIFICATE_VERIFY_FAILED"))) == "У сервера проблема с сертификатом безопасности"
|
||||
assert describe(urllib.error.URLError(TimeoutError())).startswith("Сервер не отвечает")
|
||||
assert describe(UnicodeEncodeError("ascii", "образ", 0, 5, "ordinal not in range(128)")) == "Ссылка содержит символы, которые сервер не понимает"
|
||||
assert describe(ConnectionResetError(104, "reset")) == "Сервер разорвал соединение"
|
||||
assert describe(RuntimeError("boom")) == "boom"
|
||||
|
||||
|
||||
def test_filename_from_url_sanitises_and_checks_suffix():
|
||||
assert downloads.filename_from_url("https://h.example/a%20b%20(1).ISO?x=1#frag", "iso") == "a-b-_1.iso"
|
||||
assert downloads.filename_from_url("https://h.example/dir/debian-13.qcow2", "disk") == "debian-13.qcow2"
|
||||
assert downloads.filename_from_url("https://h.example/rpi/2026-01-01-raspios.img.xz", "disk") == "2026-01-01-raspios.img.xz"
|
||||
assert downloads.filename_from_url("https://h.example/../../etc.iso", "iso") == "etc.iso"
|
||||
for url, kind in (("https://h.example/", "iso"), ("https://h.example/setup.exe", "iso"), ("https://h.example/x.iso", "disk"), ("https://h.example/x.qcow2", "iso")):
|
||||
with pytest.raises(downloads.DownloadError):
|
||||
downloads.filename_from_url(url, kind)
|
||||
|
||||
|
||||
def test_start_download_refuses_existing_file(data_dirs):
|
||||
(data_dirs["iso"] / "small.iso").write_bytes(b"x")
|
||||
with pytest.raises(downloads.DownloadError, match="уже есть"):
|
||||
downloads.start_download("http://127.0.0.1:9/small.iso", "iso")
|
||||
|
||||
|
||||
def test_start_download_refuses_when_extracted_image_exists(data_dirs):
|
||||
# Из pi.img.xz после скачивания остаётся pi.img (или pi.qcow2 после конвертации) — это тот же образ.
|
||||
(data_dirs["disk_images"] / "pi.img").write_bytes(b"x")
|
||||
with pytest.raises(downloads.DownloadError, match="pi.img уже есть"):
|
||||
downloads.start_download("http://127.0.0.1:9/pi.img.xz", "disk")
|
||||
(data_dirs["disk_images"] / "pi.img").unlink()
|
||||
(data_dirs["disk_images"] / "pi.qcow2").write_bytes(b"x")
|
||||
with pytest.raises(downloads.DownloadError, match="pi.qcow2 уже есть"):
|
||||
downloads.start_download("http://127.0.0.1:9/pi.img", "disk")
|
||||
|
||||
|
||||
def test_start_download_refuses_when_already_downloading(data_dirs, monkeypatch):
|
||||
seed_running_download("iso", "small.iso")
|
||||
with pytest.raises(downloads.DownloadError, match="уже скачивается"):
|
||||
downloads.start_download("http://127.0.0.1:9/small.iso", "iso")
|
||||
# проверка идёт по всем операциям, а не по последним 50
|
||||
for index in range(60):
|
||||
core.new_operation("vm_create", f"Создание VM {index}")
|
||||
with pytest.raises(downloads.DownloadError, match="уже скачивается"):
|
||||
downloads.start_download("http://127.0.0.1:9/small.iso", "iso")
|
||||
|
||||
|
||||
def test_start_download_checks_free_space(data_dirs, monkeypatch):
|
||||
monkeypatch.setattr(downloads.shutil, "disk_usage", lambda path: SimpleNamespace(free=1000, total=1000, used=0))
|
||||
with pytest.raises(downloads.DownloadError, match="мало места"):
|
||||
downloads.start_download("http://127.0.0.1:9/small.iso", "iso")
|
||||
|
||||
|
||||
def test_image_candidates():
|
||||
assert downloads.image_candidates("a.img.xz") == ["a.img.xz", "a.img", "a.qcow2"]
|
||||
assert downloads.image_candidates("a.img") == ["a.img", "a.qcow2"]
|
||||
assert downloads.image_candidates("a.qcow2") == ["a.qcow2"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- background download
|
||||
def test_download_iso_success_refreshes_pool(data_dirs, www, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(downloads, "run_cmd", lambda cmd, timeout=12: calls.append(cmd) or {"ok": True, "stdout": "", "stderr": ""})
|
||||
operation = downloads.start_download(f"{www.url}/small.iso", "iso")
|
||||
assert operation["type"] == "download" and operation["download_kind"] == "iso"
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "success", done
|
||||
assert done["progress"] == 100
|
||||
assert done["total_bytes"] == 15000 and done["downloaded_bytes"] == 15000
|
||||
assert (data_dirs["iso"] / "small.iso").read_bytes() == b"ISO" * 5000
|
||||
assert not (data_dirs["iso"] / ".small.iso.part").exists()
|
||||
assert ["virsh", "pool-refresh", "virtuality-iso"] in calls
|
||||
assert "Готово" in done["message"]
|
||||
wait_until(lambda: not downloads.is_live(operation["id"])) # поток снимает отметку сразу после завершения
|
||||
|
||||
|
||||
def test_download_follows_redirect(data_dirs, www):
|
||||
operation = downloads.start_download(f"{www.url}/redirect.iso", "iso")
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "success", done
|
||||
assert (data_dirs["iso"] / "redirect.iso").stat().st_size == 15000
|
||||
assert "Перенаправление" in done["log_tail"]
|
||||
|
||||
|
||||
def test_download_with_cyrillic_file_name_in_url(data_dirs, www):
|
||||
(www.root / "образ.iso").write_bytes(b"cyrillic" * 10)
|
||||
operation = downloads.start_download(f"{www.url}/образ.iso", "iso")
|
||||
assert operation["filename"] == "virtuality-iso.iso"
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "success", done
|
||||
assert (data_dirs["iso"] / "virtuality-iso.iso").read_bytes() == b"cyrillic" * 10
|
||||
assert "Перенаправление" not in done["log_tail"]
|
||||
|
||||
|
||||
def test_download_missing_file_fails(data_dirs, www):
|
||||
operation = downloads.start_download(f"{www.url}/nope.iso", "iso")
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "error"
|
||||
assert done["message"] == "Файл по ссылке не найден (ошибка 404)"
|
||||
assert "HTTP Error 404" in done["log_tail"] # исходный текст остаётся в журнале
|
||||
assert not list(data_dirs["iso"].iterdir())
|
||||
|
||||
|
||||
def test_download_refuses_when_disk_fills_up(data_dirs, www, monkeypatch):
|
||||
real = downloads.free_space_ok
|
||||
seen = []
|
||||
|
||||
def fake(directory, needed):
|
||||
seen.append(needed)
|
||||
return real(directory, needed) if len(seen) == 1 else (False, 42)
|
||||
|
||||
monkeypatch.setattr(downloads, "free_space_ok", fake)
|
||||
operation = downloads.start_download(f"{www.url}/small.iso", "iso")
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "error"
|
||||
assert "мало места" in done["message"] and "42 Б" in done["message"]
|
||||
assert not list(data_dirs["iso"].iterdir())
|
||||
|
||||
|
||||
def test_download_without_content_length_stops_when_disk_fills_up(data_dirs, www, monkeypatch):
|
||||
real = downloads.free_space_ok
|
||||
seen = []
|
||||
|
||||
def fake(directory, needed):
|
||||
seen.append(needed)
|
||||
return real(directory, needed) if len(seen) < 3 else (False, 42)
|
||||
|
||||
monkeypatch.setattr(downloads, "free_space_ok", fake)
|
||||
operation = downloads.start_download(f"{www.url}/chunked.iso", "iso")
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "error"
|
||||
assert "закончилось место" in done["message"] and "42 Б" in done["message"]
|
||||
assert done["total_bytes"] is None and seen == [None, None, None]
|
||||
assert not (data_dirs["iso"] / ".chunked.iso.part").exists()
|
||||
assert not list(data_dirs["iso"].iterdir())
|
||||
|
||||
|
||||
def test_download_cancel(data_dirs, www, logged_in):
|
||||
operation = downloads.start_download(f"{www.url}/slow.iso", "iso")
|
||||
wait_until(lambda: (core.read_operation(operation["id"]) or {}).get("status") == "running")
|
||||
wait_until(lambda: (data_dirs["iso"] / ".slow.iso.part").exists())
|
||||
response = logged_in.post(f"/api/operations/{operation['id']}/cancel")
|
||||
assert response.status_code == 200 and response.json()["ok"] is True
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "error"
|
||||
assert done["message"] == "Скачивание отменено"
|
||||
assert done["cancelled"] is True and done["cancel_requested"] is True
|
||||
assert not (data_dirs["iso"] / ".slow.iso.part").exists()
|
||||
assert not (data_dirs["iso"] / "slow.iso").exists()
|
||||
# The worker thread releases its markers right after it finishes the operation.
|
||||
wait_until(lambda: not downloads.cancel_requested(operation["id"]) and not downloads.is_live(operation["id"]))
|
||||
|
||||
|
||||
def test_cancel_flag_on_disk_is_honoured_and_not_overwritten(data_dirs, www):
|
||||
# Отмену могла принять другая копия панели: поток видит флаг в JSON операции раз в секунду.
|
||||
operation = downloads.start_download(f"{www.url}/slow.iso", "iso")
|
||||
wait_until(lambda: (data_dirs["iso"] / ".slow.iso.part").exists())
|
||||
fresh = core.read_operation(operation["id"])
|
||||
fresh.pop("log_tail", None)
|
||||
core.update_operation(fresh, cancel_requested=True, message="Останавливаем скачивание…")
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "error" and done["cancelled"] is True and done["cancel_requested"] is True
|
||||
assert not (data_dirs["iso"] / ".slow.iso.part").exists()
|
||||
|
||||
|
||||
def test_second_download_of_same_file_does_not_touch_the_first(data_dirs, www):
|
||||
first = downloads.start_download(f"{www.url}/slow.iso", "iso")
|
||||
part = data_dirs["iso"] / ".slow.iso.part"
|
||||
wait_until(part.exists)
|
||||
# Второй запуск, проскочивший проверку «уже скачивается» (например, из другого процесса): операцию создаём руками.
|
||||
second = core.new_operation("download", "Скачивание slow.iso", download_kind="iso", url=f"{www.url}/slow.iso", filename="slow.iso", target_path=first["target_path"], cancel_requested=False)
|
||||
core.update_operation(second, status="running")
|
||||
downloads.download_worker(second)
|
||||
done = core.read_operation(second["id"])
|
||||
assert done["status"] == "error" and "уже скачивается" in done["message"]
|
||||
assert part.exists() # чужой файл не тронут
|
||||
assert wait_for(first["id"])["status"] == "success"
|
||||
assert (data_dirs["iso"] / "slow.iso").stat().st_size == SLOW_TOTAL
|
||||
|
||||
|
||||
def test_leftover_part_file_without_owner_does_not_block_download(data_dirs, www):
|
||||
part = data_dirs["iso"] / ".small.iso.part"
|
||||
part.write_bytes(b"half of a crashed download")
|
||||
operation = downloads.start_download(f"{www.url}/small.iso", "iso")
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "success", done
|
||||
assert (data_dirs["iso"] / "small.iso").read_bytes() == b"ISO" * 5000
|
||||
assert not part.exists()
|
||||
|
||||
|
||||
def test_stale_downloads_are_reaped_on_startup(data_dirs, www, monkeypatch):
|
||||
stale = seed_running_download("iso", "small.iso")
|
||||
part = downloads.part_path(Path(stale["target_path"]))
|
||||
part.write_bytes(b"half")
|
||||
(data_dirs["disk_images"] / ".orphan.img.part").write_bytes(b"x")
|
||||
live = downloads.start_download(f"{www.url}/slow.iso", "iso")
|
||||
wait_until(lambda: (data_dirs["iso"] / ".slow.iso.part").exists())
|
||||
|
||||
assert downloads.reap_stale_downloads() == 1
|
||||
closed = core.read_operation(stale["id"])
|
||||
assert closed["status"] == "error" and closed["interrupted"] is True and "перезапущена" in closed["message"]
|
||||
assert not part.exists() and not (data_dirs["disk_images"] / ".orphan.img.part").exists()
|
||||
assert (data_dirs["iso"] / ".slow.iso.part").exists() # живое скачивание не трогаем
|
||||
assert wait_for(live["id"])["status"] == "success"
|
||||
# после закрытия зависшей операции тот же файл можно скачать снова
|
||||
done = wait_for(downloads.start_download(f"{www.url}/small.iso", "iso")["id"])
|
||||
assert done["status"] == "success" and (data_dirs["iso"] / "small.iso").read_bytes() == b"ISO" * 5000
|
||||
assert downloads.close_stale_downloads() is None
|
||||
|
||||
|
||||
def test_cancel_of_stale_download_finishes_it_immediately(data_dirs, logged_in):
|
||||
stale = seed_running_download("disk", "y.qcow2")
|
||||
part = downloads.part_path(Path(stale["target_path"]))
|
||||
part.write_bytes(b"x")
|
||||
response = logged_in.post(f"/api/operations/{stale['id']}/cancel")
|
||||
operation = response.json()["operation"]
|
||||
assert response.status_code == 200 and operation["status"] == "error" and operation["cancelled"] is True
|
||||
assert operation["message"] == "Скачивание отменено" and operation["cancel_requested"] is True
|
||||
assert not part.exists()
|
||||
assert not downloads.cancel_requested(stale["id"])
|
||||
assert logged_in.post(f"/api/operations/{stale['id']}/cancel").status_code == 409
|
||||
|
||||
|
||||
def test_catalog_download_verifies_checksum(data_dirs, www):
|
||||
payload = b"QCOW2 image bytes" * 100
|
||||
(www.root / "disk.qcow2").write_bytes(payload)
|
||||
(www.root / "SHA256SUMS").write_text(f"{hashlib.sha256(payload).hexdigest()} *disk.qcow2\n")
|
||||
operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA256SUMS", algo="sha256", catalog_id="test")
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "success", done
|
||||
assert "совпала" in done["log_tail"]
|
||||
assert (data_dirs["disk_images"] / "disk.qcow2").read_bytes() == payload
|
||||
|
||||
|
||||
def test_catalog_download_checksum_mismatch_deletes_file(data_dirs, www):
|
||||
(www.root / "disk.qcow2").write_bytes(b"corrupted" * 100)
|
||||
(www.root / "SHA512SUMS").write_text(f"{'0' * 128} disk.qcow2\n")
|
||||
operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA512SUMS", algo="sha512")
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "error"
|
||||
assert "Контрольная сумма не совпала" in done["message"]
|
||||
assert not list(data_dirs["disk_images"].iterdir())
|
||||
|
||||
|
||||
def test_catalog_download_fails_without_sums_entry(data_dirs, www):
|
||||
(www.root / "disk.qcow2").write_bytes(b"data")
|
||||
(www.root / "SHA256SUMS").write_text(f"{'a' * 64} *other.qcow2\n")
|
||||
operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA256SUMS", algo="sha256")
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "error"
|
||||
assert "нет записи" in done["message"]
|
||||
assert not list(data_dirs["disk_images"].iterdir())
|
||||
|
||||
|
||||
def test_cancel_during_checksum_verification_removes_file(data_dirs, www, logged_in, monkeypatch):
|
||||
payload = b"QCOW2 image bytes" * 100
|
||||
(www.root / "disk.qcow2").write_bytes(payload)
|
||||
(www.root / "SHA256SUMS").write_text(f"{hashlib.sha256(payload).hexdigest()} *disk.qcow2\n")
|
||||
|
||||
def slow_digest(path, algo, on_progress=None):
|
||||
for _ in range(200):
|
||||
time.sleep(0.05)
|
||||
if on_progress:
|
||||
on_progress(1)
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
monkeypatch.setattr(downloads, "file_digest", slow_digest)
|
||||
operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA256SUMS", algo="sha256")
|
||||
wait_until(lambda: "Проверяем контрольную сумму" in message_of(operation["id"]))
|
||||
assert logged_in.post(f"/api/operations/{operation['id']}/cancel").status_code == 200
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "error" and done["cancelled"] is True and done["message"] == "Скачивание отменено"
|
||||
assert "во время проверки" in done["log_tail"]
|
||||
assert not list(data_dirs["disk_images"].iterdir())
|
||||
|
||||
|
||||
def test_disk_download_extracts_xz_and_starts_conversion(data_dirs, www, monkeypatch):
|
||||
raw = bytes(range(256)) * 256
|
||||
(www.root / "pi.img.xz").write_bytes(lzma.compress(raw))
|
||||
started = []
|
||||
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: started.append(path) or {"id": "conv-1"})
|
||||
operation = downloads.start_download(f"{www.url}/pi.img.xz", "disk")
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "success", done
|
||||
assert (data_dirs["disk_images"] / "pi.img").read_bytes() == raw
|
||||
assert not (data_dirs["disk_images"] / "pi.img.xz").exists()
|
||||
assert started == [data_dirs["disk_images"] / "pi.img"]
|
||||
assert done["convert_operation_id"] == "conv-1"
|
||||
assert "qcow2" in done["message"]
|
||||
|
||||
|
||||
def test_cancel_during_unpack_removes_archive_and_partial_image(data_dirs, www, logged_in, monkeypatch):
|
||||
raw = bytes(range(256)) * 4096 # 1 МБ, распаковка кусками по 16 КБ с паузой — успеваем отменить
|
||||
(www.root / "pi.img.xz").write_bytes(lzma.compress(raw))
|
||||
real = downloads.extract_xz_image
|
||||
|
||||
def slow_extract(compressed, should_stop=None):
|
||||
def stop():
|
||||
time.sleep(0.05)
|
||||
return bool(should_stop and should_stop())
|
||||
|
||||
return real(compressed, stop)
|
||||
|
||||
started = []
|
||||
monkeypatch.setattr(downloads, "extract_xz_image", slow_extract)
|
||||
monkeypatch.setattr(downloads, "CHUNK_SIZE", 16 * 1024)
|
||||
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: started.append(path))
|
||||
operation = downloads.start_download(f"{www.url}/pi.img.xz", "disk")
|
||||
wait_until(lambda: "Распаковываем" in message_of(operation["id"]))
|
||||
assert logged_in.post(f"/api/operations/{operation['id']}/cancel").status_code == 200
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "error" and done["cancelled"] is True and done["message"] == "Скачивание отменено"
|
||||
assert "Распаковка остановлена" in done["log_tail"]
|
||||
assert not list(data_dirs["disk_images"].iterdir()) and started == []
|
||||
|
||||
|
||||
def test_corrupt_xz_archive_is_removed_and_can_be_downloaded_again(data_dirs, www, monkeypatch):
|
||||
(www.root / "bad.img.xz").write_bytes(b"definitely not an xz archive" * 100)
|
||||
started = []
|
||||
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: started.append(path))
|
||||
done = wait_for(downloads.start_download(f"{www.url}/bad.img.xz", "disk")["id"])
|
||||
assert done["status"] == "error"
|
||||
assert done["message"] == "Не удалось распаковать скачанный архив — файл повреждён и удалён. Попробуйте скачать ещё раз"
|
||||
assert "Ошибка распаковки" in done["log_tail"]
|
||||
assert not list(data_dirs["disk_images"].iterdir()) and started == []
|
||||
# архив убран, поэтому повторное скачивание не упирается в «уже есть на сервере»
|
||||
assert wait_for(downloads.start_download(f"{www.url}/bad.img.xz", "disk")["id"])["status"] == "error"
|
||||
|
||||
|
||||
def test_extract_xz_image_stops_and_cleans_up(data_dirs):
|
||||
raw = bytes(range(256)) * 1024
|
||||
compressed = data_dirs["disk_images"] / "pi.img.xz"
|
||||
compressed.write_bytes(lzma.compress(raw))
|
||||
assert downloads.extract_xz_image(compressed).read_bytes() == raw
|
||||
(data_dirs["disk_images"] / "pi.img").unlink()
|
||||
with pytest.raises(downloads.DownloadCancelled):
|
||||
downloads.extract_xz_image(compressed, lambda: True)
|
||||
assert sorted(item.name for item in data_dirs["disk_images"].iterdir()) == ["pi.img.xz"]
|
||||
|
||||
|
||||
def test_qcow2_download_needs_no_conversion(data_dirs, www, monkeypatch):
|
||||
(www.root / "ready.qcow2").write_bytes(b"q" * 10)
|
||||
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: None)
|
||||
done = wait_for(downloads.start_download(f"{www.url}/ready.qcow2", "disk")["id"])
|
||||
assert done["status"] == "success" and "convert_operation_id" not in done
|
||||
assert done["result_path"] == str(data_dirs["disk_images"] / "ready.qcow2")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- routes
|
||||
def test_url_route_starts_download(logged_in, data_dirs, www, monkeypatch):
|
||||
# The worker is started by hand below: a 15 KB file would otherwise finish before the checks.
|
||||
pending = {}
|
||||
monkeypatch.setattr(downloads, "run_operation", lambda operation, worker: pending.update(operation=operation, worker=worker))
|
||||
response = logged_in.post("/downloads/url", data={"url": f"{www.url}/small.iso", "kind": "iso"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
location = unquote(response.headers["location"])
|
||||
assert location.startswith("/iso?download_message=") and "small.iso" in location
|
||||
running = downloads.active_downloads("iso")
|
||||
assert len(running) == 1 and running[0]["filename"] == "small.iso"
|
||||
core.run_operation(pending["operation"], pending["worker"])
|
||||
assert wait_for(running[0]["id"])["status"] == "success"
|
||||
assert (data_dirs["iso"] / "small.iso").exists()
|
||||
|
||||
|
||||
def test_url_route_rejects_bad_links(logged_in, data_dirs):
|
||||
response = logged_in.post("/downloads/url", data={"url": "ftp://example.org/x.iso", "kind": "iso"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert "download_error=" in response.headers["location"] and response.headers["location"].startswith("/iso?")
|
||||
response = logged_in.post("/downloads/url", data={"url": "https://example.org/setup.exe", "kind": "disk"}, follow_redirects=False)
|
||||
assert response.headers["location"].startswith("/disk-images?download_error=")
|
||||
assert not core.running_operations("download") and not downloads.unfinished_downloads()
|
||||
|
||||
|
||||
def test_url_route_requires_login(client):
|
||||
assert client.post("/downloads/url", data={"url": "https://example.org/x.iso"}, follow_redirects=False).headers["location"] == "/login"
|
||||
assert client.post("/api/operations/x/cancel").status_code == 401
|
||||
|
||||
|
||||
def test_catalog_route(logged_in, data_dirs, www, monkeypatch):
|
||||
payload = b"debian" * 1000
|
||||
(www.root / "debian-13-genericcloud-amd64.qcow2").write_bytes(payload)
|
||||
(www.root / "SHA512SUMS").write_text(f"{hashlib.sha512(payload).hexdigest()} debian-13-genericcloud-amd64.qcow2\n")
|
||||
entry = {"id": "debian-13", "title": "Debian 13", "hint": "x", "login": "debian", "arches": {"x86_64": {"url": f"{www.url}/debian-13-genericcloud-amd64.qcow2", "sums": f"{www.url}/SHA512SUMS", "algo": "sha512"}}}
|
||||
monkeypatch.setattr(catalog, "CLOUD_IMAGES", [entry])
|
||||
monkeypatch.setattr(downloads, "host_arch", lambda: "x86_64")
|
||||
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: None)
|
||||
|
||||
assert unquote(logged_in.post("/downloads/catalog/nope", follow_redirects=False).headers["location"]).startswith("/disk-images?download_error=Такого образа нет")
|
||||
# The worker is started by hand below: a 6 KB file would otherwise finish before the page is rendered.
|
||||
pending = {}
|
||||
monkeypatch.setattr(downloads, "run_operation", lambda operation, worker: pending.update(operation=operation, worker=worker))
|
||||
response = logged_in.post("/downloads/catalog/debian-13", follow_redirects=False)
|
||||
assert response.status_code == 303 and "download_message=" in response.headers["location"]
|
||||
page = logged_in.get("/disk-images").text
|
||||
assert "Скачивается" in page
|
||||
running = downloads.active_downloads("disk")
|
||||
assert running[0]["catalog_id"] == "debian-13"
|
||||
core.run_operation(pending["operation"], pending["worker"])
|
||||
assert wait_for(running[0]["id"])["status"] == "success"
|
||||
assert (data_dirs["disk_images"] / "debian-13-genericcloud-amd64.qcow2").read_bytes() == payload
|
||||
page = logged_in.get("/disk-images").text
|
||||
assert "Загружен" in page and "/vm/create?image=debian-13-genericcloud-amd64.qcow2" in page
|
||||
# второй раз — файл уже есть
|
||||
response = logged_in.post("/downloads/catalog/debian-13", follow_redirects=False)
|
||||
assert "download_error=" in response.headers["location"]
|
||||
|
||||
|
||||
def test_catalog_route_skips_missing_arch(logged_in, data_dirs, monkeypatch):
|
||||
monkeypatch.setattr(catalog, "CLOUD_IMAGES", [{"id": "only-arm", "title": "ARM only", "arches": {"aarch64": {"url": "https://example.org/a.qcow2"}}}])
|
||||
monkeypatch.setattr(downloads, "host_arch", lambda: "x86_64")
|
||||
location = unquote(logged_in.post("/downloads/catalog/only-arm", follow_redirects=False).headers["location"])
|
||||
assert "нет версии для этого сервера (x86_64)" in location
|
||||
assert downloads.catalog_entries() == []
|
||||
assert "готовых образов в каталоге нет" in logged_in.get("/disk-images").text
|
||||
|
||||
|
||||
def test_cancel_api_rules(logged_in, data_dirs):
|
||||
assert logged_in.post("/api/operations/00000000-0000-0000-0000-000000000000/cancel").status_code == 404
|
||||
other = core.new_operation("vm_create", "Создание VM x")
|
||||
response = logged_in.post(f"/api/operations/{other['id']}/cancel")
|
||||
assert response.status_code == 400 and "только скачивание" in response.json()["error"]
|
||||
finished = core.new_operation("download", "Скачивание x", download_kind="iso", filename="x.iso")
|
||||
core.finish_operation(finished, True, "Готово")
|
||||
assert logged_in.post(f"/api/operations/{finished['id']}/cancel").status_code == 409
|
||||
# операция без потока и без файла (панель перезапускалась) закрывается сразу
|
||||
running = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2")
|
||||
core.update_operation(running, status="running")
|
||||
response = logged_in.post(f"/api/operations/{running['id']}/cancel")
|
||||
operation = response.json()["operation"]
|
||||
assert response.status_code == 200 and operation["cancel_requested"] is True and operation["status"] == "error" and operation["cancelled"] is True
|
||||
assert not downloads.cancel_requested(running["id"])
|
||||
|
||||
|
||||
def test_cancel_form_redirects_to_page(logged_in, data_dirs, www):
|
||||
stale = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2")
|
||||
core.update_operation(stale, status="running")
|
||||
response = logged_in.post(f"/downloads/{stale['id']}/cancel", follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert unquote(response.headers["location"]).startswith("/disk-images?download_message=Скачивание y.qcow2 отменено")
|
||||
assert core.read_operation(stale["id"])["status"] == "error"
|
||||
live = downloads.start_download(f"{www.url}/slow.iso", "iso")
|
||||
response = logged_in.post(f"/downloads/{live['id']}/cancel", follow_redirects=False)
|
||||
assert unquote(response.headers["location"]).startswith("/iso?download_message=Останавливаем скачивание slow.iso")
|
||||
assert wait_for(live["id"])["cancelled"] is True
|
||||
assert unquote(logged_in.post("/downloads/nope/cancel", follow_redirects=False).headers["location"]).startswith("/iso?download_error=")
|
||||
|
||||
|
||||
def test_pages_show_download_cards(logged_in, data_dirs, monkeypatch):
|
||||
reads = []
|
||||
real = downloads.unfinished_downloads
|
||||
monkeypatch.setattr(downloads, "unfinished_downloads", lambda: reads.append(1) or real())
|
||||
iso_page = logged_in.get("/iso").text
|
||||
assert "Скачать по ссылке" in iso_page and 'name="kind" value="iso"' in iso_page
|
||||
assert "Скачиваем на сервер" not in iso_page
|
||||
assert "не нужно сначала загружать его на компьютер" in iso_page
|
||||
disk_page = logged_in.get("/disk-images").text
|
||||
assert "Каталог готовых систем" in disk_page and "Рекомендуем" in disk_page and 'name="kind" value="disk"' in disk_page
|
||||
assert "подходящие этому серверу" in disk_page
|
||||
assert len(reads) == 2 # каталог операций читается один раз на страницу, а не в каждой вставке
|
||||
running = core.new_operation("download", "Скачивание big.iso", download_kind="iso", filename="big.iso", url="https://example.org/big.iso", total_bytes=None)
|
||||
core.update_operation(running, status="running", progress=0, message="Подключаемся к серверу…")
|
||||
iso_page = logged_in.get("/iso?download_message=Скачиваем").text
|
||||
assert "Скачиваем на сервер" in iso_page and "big.iso" in iso_page and "indeterminate" in iso_page
|
||||
assert f"/downloads/{running['id']}/cancel" in iso_page and "Скачиваем</span>" in iso_page
|
||||
# отмена — не ошибка: без тоста при отправке формы, опрос показывает её как предупреждение
|
||||
assert 'data-toast="Скачивание отменено"' not in iso_page and "op.cancelled" in iso_page
|
||||
assert "big.iso" not in logged_in.get("/disk-images").text
|
||||
@@ -1,152 +0,0 @@
|
||||
"""Quick machines from cloud images (cloud-init), OS types for installers, fixed NAT addresses."""
|
||||
import shlex
|
||||
|
||||
import app
|
||||
import cloudinit
|
||||
import conftest
|
||||
import network_core
|
||||
from test_audit_fixes import find, recording
|
||||
|
||||
|
||||
def create(logged_in, monkeypatch, form):
|
||||
captured = {}
|
||||
monkeypatch.setattr(app, "start_background_operation", lambda operation, cmd: captured.update(operation=operation, cmd=cmd))
|
||||
monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {})
|
||||
response = logged_in.post("/vm/create", data=form, follow_redirects=False)
|
||||
return response, captured
|
||||
|
||||
|
||||
def cloud_form(image, **extra):
|
||||
form = {"name": "cloud-vm", "memory": 2048, "vcpus": 2, "disk_size": 20, "disk_image_path": str(image), "source_type": "disk_image", "network_mode": "nat", "cloud_init": "1", "ci_user": "viktor", "ci_password": "secret123"}
|
||||
form.update(extra)
|
||||
return form
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- cloud-init seed
|
||||
def test_user_data_has_user_password_and_keys():
|
||||
text = cloudinit.build_user_data("web", "viktor", "$6$abc", ["ssh-ed25519 AAAAC3 me@laptop"])
|
||||
assert "hostname: web" in text and "- name: viktor" in text and "passwd: '$6$abc'" in text
|
||||
assert "- ssh-ed25519 AAAAC3 me@laptop" in text and "ssh_pwauth: true" in text and "growpart" in text
|
||||
assert "packages: [qemu-guest-agent]" in text and "package_update: true" in text
|
||||
assert "ssh_pwauth: false" in cloudinit.build_user_data("web", "viktor", "", ["ssh-ed25519 AAAAC3 me"])
|
||||
|
||||
|
||||
def test_ssh_keys_and_users_are_validated():
|
||||
assert cloudinit.parse_ssh_keys("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 me\n\nssh-rsa AAAAB3 x\n") == ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 me", "ssh-rsa AAAAB3 x"]
|
||||
assert cloudinit.parse_ssh_keys("-----BEGIN OPENSSH PRIVATE KEY-----") is None
|
||||
assert cloudinit.parse_ssh_keys("") == []
|
||||
assert cloudinit.valid_user("viktor") and cloudinit.valid_user("web_admin-2")
|
||||
assert not cloudinit.valid_user("root") and not cloudinit.valid_user("Viktor") and not cloudinit.valid_user("")
|
||||
|
||||
|
||||
def test_password_hash_uses_sha512_crypt():
|
||||
digest = cloudinit.hash_password("secret123")
|
||||
assert digest.startswith("$6$") and len(digest) > 60
|
||||
|
||||
|
||||
def test_default_login_comes_from_catalog():
|
||||
assert cloudinit.default_user("resolute-server-cloudimg-amd64.img") == "ubuntu"
|
||||
assert cloudinit.default_user("debian-13-genericcloud-arm64.qcow2") == "debian"
|
||||
assert cloudinit.default_user("my-own-image.qcow2") == "admin"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- creating from a cloud image
|
||||
def test_cloud_image_vm_gets_seed_resize_and_fixed_address(logged_in, data_dirs, monkeypatch):
|
||||
image = data_dirs["disk_images"] / "resolute-server-cloudimg-amd64.img"
|
||||
image.write_bytes(b"QFI\xfb")
|
||||
monkeypatch.setattr(cloudinit, "hash_password", lambda password: "$6$fake$hash")
|
||||
calls = recording(monkeypatch, network_core, [(["virsh", "net-dumpxml"], conftest.fake_result((conftest.FIXTURES / "net-dumpxml.xml").read_text()))])
|
||||
response, captured = create(logged_in, monkeypatch, cloud_form(image, disk_size=60))
|
||||
assert response.status_code == 303, response.text[:400]
|
||||
script = captured["cmd"][-1]
|
||||
assert "qemu-img convert -p -f qcow2 -O qcow2" in script
|
||||
assert f"qemu-img resize {shlex.quote(str(data_dirs['images'] / 'cloud-vm.qcow2'))} 60G" in script
|
||||
assert "--import" in script and "--osinfo ubuntu24.04" in script and "--cloud-init user-data=" in script and "bus=virtio" in script
|
||||
operation = captured["operation"]
|
||||
assert operation["cloud_init_user"] == "viktor" and operation["nat_ip"] == "192.168.100.50"
|
||||
assert "Вход: viktor по паролю" in operation["success_message"] and "192.168.100.50" in operation["success_message"]
|
||||
mac = network_core.stable_mac("cloud-vm")
|
||||
assert f"mac={mac}" in operation["network"] and mac.startswith("52:54:00:")
|
||||
reservation = find(calls, "virsh", "net-update", "virtuality-nat", "add-last", "ip-dhcp-host")[0]
|
||||
assert f"mac='{mac}' name='cloud-vm' ip='192.168.100.50'" in reservation[5] and "--live" in reservation and "--config" in reservation
|
||||
user_data = open(operation["seed_file"]).read()
|
||||
assert "- name: viktor" in user_data and "$6$fake$hash" in user_data and "hostname: cloud-vm" in user_data
|
||||
cloudinit.remove_seed(operation["seed_file"])
|
||||
|
||||
|
||||
def test_cloud_image_smaller_disk_is_not_resized(logged_in, data_dirs, monkeypatch):
|
||||
image = data_dirs["disk_images"] / "cloud.qcow2"
|
||||
image.write_bytes(b"qcow")
|
||||
monkeypatch.setattr(cloudinit, "hash_password", lambda password: "$6$fake$hash")
|
||||
response, captured = create(logged_in, monkeypatch, cloud_form(image, disk_size=20))
|
||||
assert response.status_code == 303
|
||||
assert "qemu-img resize" not in captured["cmd"][-1] and "--osinfo detect=on,require=off" in captured["cmd"][-1]
|
||||
cloudinit.remove_seed(captured["operation"]["seed_file"])
|
||||
|
||||
|
||||
def test_cloud_init_can_be_skipped(logged_in, data_dirs, monkeypatch):
|
||||
image = data_dirs["disk_images"] / "cloud.qcow2"
|
||||
image.write_bytes(b"qcow")
|
||||
response, captured = create(logged_in, monkeypatch, cloud_form(image, cloud_init="0", ci_user="", ci_password=""))
|
||||
assert response.status_code == 303 and "--cloud-init" not in captured["cmd"][-1] and captured["operation"]["seed_file"] == ""
|
||||
|
||||
|
||||
def test_cloud_init_validation(logged_in, data_dirs, monkeypatch):
|
||||
image = data_dirs["disk_images"] / "cloud.qcow2"
|
||||
image.write_bytes(b"qcow")
|
||||
assert create(logged_in, monkeypatch, cloud_form(image, ci_password="123"))[0].status_code == 400
|
||||
assert create(logged_in, monkeypatch, cloud_form(image, ci_user="Root User"))[0].status_code == 400
|
||||
assert create(logged_in, monkeypatch, cloud_form(image, ci_user="root"))[0].status_code == 400
|
||||
assert create(logged_in, monkeypatch, cloud_form(image, ci_ssh_key="not a key"))[0].status_code == 400
|
||||
monkeypatch.setattr(cloudinit, "hash_password", lambda password: "$6$fake$hash")
|
||||
ok, captured = create(logged_in, monkeypatch, cloud_form(image, ci_password="", ci_ssh_key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA me@pc"))
|
||||
assert ok.status_code == 303 and "по SSH-ключу" in captured["operation"]["success_message"]
|
||||
cloudinit.remove_seed(captured["operation"]["seed_file"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- installers by OS type
|
||||
def iso_form(iso, **extra):
|
||||
form = {"name": "new-vm", "memory": 4096, "vcpus": 2, "disk_size": 64, "iso_path": str(iso), "source_type": "iso", "network_mode": "nat"}
|
||||
form.update(extra)
|
||||
return form
|
||||
|
||||
|
||||
def test_windows11_gets_uefi_tpm_sata_and_e1000e(logged_in, data_dirs, monkeypatch):
|
||||
iso = data_dirs["iso"] / "win11.iso"
|
||||
iso.write_bytes(b"iso")
|
||||
response, captured = create(logged_in, monkeypatch, iso_form(iso, os_type="windows11"))
|
||||
assert response.status_code == 303
|
||||
cmd = captured["cmd"]
|
||||
assert "--osinfo" in cmd and cmd[cmd.index("--osinfo") + 1] == "win11"
|
||||
assert cmd[cmd.index("--boot") + 1] == "uefi,hd,cdrom"
|
||||
assert "--tpm" in cmd and "backend.version=2.0" in cmd[cmd.index("--tpm") + 1]
|
||||
assert "bus=sata" in cmd[cmd.index("--disk") + 1] and "model=e1000e" in cmd[cmd.index("--network") + 1]
|
||||
assert "--channel" not in cmd # Windows has no guest agent without extra drivers
|
||||
|
||||
|
||||
def test_linux_installer_detects_os_and_uses_virtio(logged_in, data_dirs, monkeypatch):
|
||||
iso = data_dirs["iso"] / "debian.iso"
|
||||
iso.write_bytes(b"iso")
|
||||
response, captured = create(logged_in, monkeypatch, iso_form(iso))
|
||||
cmd = captured["cmd"]
|
||||
assert cmd[cmd.index("--osinfo") + 1] == "detect=on,require=off" and cmd[cmd.index("--boot") + 1] == "hd,cdrom"
|
||||
assert "bus=virtio" in cmd[cmd.index("--disk") + 1] and "model=virtio" in cmd[cmd.index("--network") + 1] and "--tpm" not in cmd
|
||||
assert cmd[cmd.index("--channel") + 1] == "unix,target.type=virtio,target.name=org.qemu.guest_agent.0"
|
||||
assert create(logged_in, monkeypatch, iso_form(iso, os_type="bsd"))[0].status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- fixed addresses
|
||||
def test_reservation_is_reused_and_released(data_dirs, monkeypatch):
|
||||
calls = recording(monkeypatch, network_core, [(["virsh", "net-dumpxml"], conftest.fake_result((conftest.FIXTURES / "net-dumpxml.xml").read_text()))])
|
||||
assert network_core.reserve_nat_address("web01", "52:54:00:aa:bb:cc") == "192.168.100.51"
|
||||
assert not find(calls, "virsh", "net-update")
|
||||
network_core.release_nat_address("web01")
|
||||
deleted = find(calls, "virsh", "net-update", "virtuality-nat", "delete")[0]
|
||||
assert "name='web01' ip='192.168.100.51'" in deleted[5]
|
||||
|
||||
|
||||
def test_vm_delete_releases_address(logged_in, data_dirs, monkeypatch):
|
||||
calls = recording(monkeypatch, app, [])
|
||||
net_calls = recording(monkeypatch, network_core, [(["virsh", "net-dumpxml"], conftest.fake_result((conftest.FIXTURES / "net-dumpxml.xml").read_text()))])
|
||||
assert logged_in.post("/vm/web01/delete", follow_redirects=False).status_code == 303
|
||||
assert find(calls, "virsh", "undefine") and find(net_calls, "virsh", "net-update", "virtuality-nat", "delete")
|
||||
@@ -1,88 +0,0 @@
|
||||
import nodectl
|
||||
from test_setup import prepare
|
||||
|
||||
|
||||
def ready(data_dirs, monkeypatch, ctl=True):
|
||||
prepare(data_dirs, monkeypatch)
|
||||
nodectl.mark_wizard_done()
|
||||
monkeypatch.setattr(nodectl, "ctl_available", lambda: ctl)
|
||||
nodectl.NODE_ENV = data_dirs["config"] / "web.env"
|
||||
nodectl.NODE_ENV.write_text("VIRTUALITY_WEB_PORT=8088\nVIRTUALITY_TLS=0\nVIRTUALITY_AUTO_UPDATE=1\nVIRTUALITY_UPDATE_CHANNEL=stable\n")
|
||||
|
||||
|
||||
def test_settings_page_renders(logged_in, data_dirs, monkeypatch):
|
||||
ready(data_dirs, monkeypatch)
|
||||
html = logged_in.get("/settings").text
|
||||
for text in ("Доступ к панели", "Сеть машин", "Хранилище", "Часовой пояс", "Обновления", "Перезагрузить", "http://panel.test:8088"):
|
||||
assert text in html, text
|
||||
|
||||
|
||||
def test_settings_page_without_ctl_disables_actions(logged_in, data_dirs, monkeypatch):
|
||||
ready(data_dirs, monkeypatch, ctl=False)
|
||||
html = logged_in.get("/settings").text
|
||||
assert "virtuality-ctl не найдена" in html and "disabled" in html
|
||||
|
||||
|
||||
def test_settings_requires_login(client, data_dirs, monkeypatch):
|
||||
ready(data_dirs, monkeypatch)
|
||||
assert client.get("/settings", follow_redirects=False).status_code == 303
|
||||
|
||||
|
||||
def test_access_change_applies_and_shows_new_url(logged_in, data_dirs, monkeypatch):
|
||||
ready(data_dirs, monkeypatch)
|
||||
calls = []
|
||||
monkeypatch.setattr(nodectl, "ctl_detached", lambda *args, **kw: calls.append(args) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""})
|
||||
response = logged_in.post("/settings/access", data={"tls": "1", "port": "8088"})
|
||||
assert response.status_code == 200 and "https://panel.test:8443" in response.text
|
||||
assert calls == [("set", "VIRTUALITY_TLS=1", "VIRTUALITY_WEB_PORT=8088")]
|
||||
assert logged_in.post("/settings/access", data={"tls": "0", "port": "22"}).status_code == 400
|
||||
unchanged = logged_in.post("/settings/access", data={"tls": "0", "port": "8088"}, follow_redirects=False)
|
||||
assert unchanged.status_code == 303 and "message=" in unchanged.headers["location"]
|
||||
|
||||
|
||||
def test_password_change(logged_in, data_dirs, monkeypatch):
|
||||
ready(data_dirs, monkeypatch)
|
||||
seen = {}
|
||||
monkeypatch.setattr(nodectl, "ctl", lambda *args, **kw: seen.update(args=args, stdin=kw.get("stdin")) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""})
|
||||
assert logged_in.post("/settings/password", data={"password": "abc", "password2": "abd"}).status_code == 400
|
||||
assert logged_in.post("/settings/password", data={"password": "abc", "password2": "abc"}).status_code == 400
|
||||
response = logged_in.post("/settings/password", data={"password": "secret1", "password2": "secret1"}, follow_redirects=False)
|
||||
assert response.status_code == 303 and seen["args"] == ("passwd", "tester") and seen["stdin"] == "secret1\n"
|
||||
|
||||
|
||||
def test_timezone_and_updates(logged_in, data_dirs, monkeypatch):
|
||||
ready(data_dirs, monkeypatch)
|
||||
calls = []
|
||||
monkeypatch.setattr(nodectl, "ctl", lambda *args, **kw: calls.append(args) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""})
|
||||
monkeypatch.setattr(nodectl, "ctl_detached", lambda *args, **kw: calls.append(args) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""})
|
||||
assert logged_in.post("/settings/timezone", data={"timezone": "Europe/Moscow"}, follow_redirects=False).status_code == 303
|
||||
assert logged_in.post("/settings/timezone", data={"timezone": "../etc"}).status_code == 400
|
||||
assert logged_in.post("/settings/updates", data={"channel": "main"}, follow_redirects=False).status_code == 303
|
||||
assert calls == [("timezone", "Europe/Moscow"), ("set", "VIRTUALITY_AUTO_UPDATE=0", "VIRTUALITY_UPDATE_CHANNEL=main")]
|
||||
|
||||
|
||||
def test_network_bridge_and_revert(logged_in, data_dirs, monkeypatch):
|
||||
ready(data_dirs, monkeypatch)
|
||||
calls = []
|
||||
monkeypatch.setattr(nodectl, "enable_bridge", lambda iface, mode="dhcp": calls.append(("bridge", iface, mode)) or (True, "ok"))
|
||||
monkeypatch.setattr(nodectl, "revert_bridge", lambda: calls.append(("revert",)) or (True, "Возвращаем прежнюю сеть"))
|
||||
response = logged_in.post("/settings/network", data={"mode": "bridge"})
|
||||
assert response.status_code == 200 and "Перенастраиваем сеть" in response.text and 'data-next-url="/settings"' in response.text
|
||||
assert logged_in.post("/settings/network/revert", follow_redirects=False).headers["location"].startswith("/settings?message=")
|
||||
assert calls == [("bridge", "enp3s0", "dhcp"), ("revert",)]
|
||||
|
||||
|
||||
def test_storage_power_and_downloads(logged_in, data_dirs, monkeypatch, tmp_path):
|
||||
ready(data_dirs, monkeypatch)
|
||||
monkeypatch.setattr(nodectl, "use_disk_for_storage", lambda path: (path == "/dev/sdb", "Диск подключён" if path == "/dev/sdb" else "Нельзя"))
|
||||
assert "message=" in logged_in.post("/settings/storage", data={"disk": "/dev/sdb"}, follow_redirects=False).headers["location"]
|
||||
assert "error=" in logged_in.post("/settings/storage", data={"disk": "/dev/sda"}, follow_redirects=False).headers["location"]
|
||||
monkeypatch.setattr(nodectl, "ctl_detached", lambda *args, **kw: {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""})
|
||||
assert "Перезагружаем сервер" in logged_in.post("/settings/power", data={"action": "reboot"}).text
|
||||
assert logged_in.post("/settings/power", data={"action": "halt"}).status_code == 400
|
||||
archive = tmp_path / "config-1.tar.gz"
|
||||
archive.write_bytes(b"\x1f\x8b" + b"0" * 16)
|
||||
monkeypatch.setattr(nodectl, "ctl", lambda *args, **kw: {"ok": True, "stdout": str(archive), "stderr": "", "code": 0, "cmd": ""})
|
||||
response = logged_in.post("/settings/backup-config")
|
||||
assert response.status_code == 200 and response.headers["content-disposition"].endswith('"config-1.tar.gz"') and response.content.startswith(b"\x1f\x8b")
|
||||
assert logged_in.post("/settings/support-bundle").status_code == 200
|
||||
@@ -1,101 +0,0 @@
|
||||
import json
|
||||
|
||||
import app
|
||||
import nodectl
|
||||
from features import setup
|
||||
|
||||
|
||||
def prepare(data_dirs, monkeypatch, stage="done"):
|
||||
config = data_dirs["config"]
|
||||
monkeypatch.setattr(nodectl, "CONFIG_DIR", config)
|
||||
monkeypatch.setattr(nodectl, "SETUP_STATE", config / "setup.json")
|
||||
monkeypatch.setattr(nodectl, "SETUP_DONE", config / "setup_done")
|
||||
monkeypatch.setattr(nodectl, "WIZARD_FILE", config / "wizard.json")
|
||||
monkeypatch.setattr(nodectl, "FIRSTBOOT_LOG", config / "firstboot.log")
|
||||
monkeypatch.setattr(nodectl, "ctl_available", lambda: False)
|
||||
(config / "setup.json").write_text(json.dumps({"stage": stage, "message": "Устанавливаем…", "steps": [{"id": "panel", "title": "Панель", "status": "done"}, {"id": "virt", "title": "KVM", "status": "running" if stage != "done" else "done"}]}))
|
||||
monkeypatch.setattr(nodectl, "network_facts", lambda: {"interface": "enp3s0", "gateway": "192.168.1.1", "address": "192.168.1.10", "prefix": 24, "mac": "aa:bb:cc:dd:ee:ff", "wireless": False, "private": True, "is_vps": False, "virt": "none", "bridge_present": False, "on_bridge": False, "recommended": "bridge", "revert_armed": False, "bridge_possible": True})
|
||||
monkeypatch.setattr(nodectl, "spare_disks", lambda: [])
|
||||
monkeypatch.setattr(nodectl, "timezones", lambda: {"current": "UTC", "popular": ["Europe/Moscow", "UTC"], "all": ["Europe/Moscow", "UTC"]})
|
||||
|
||||
|
||||
def test_dashboard_redirects_to_wizard_until_done(logged_in, data_dirs, monkeypatch):
|
||||
prepare(data_dirs, monkeypatch)
|
||||
response = logged_in.get("/", follow_redirects=False)
|
||||
assert response.status_code == 303 and response.headers["location"] == "/setup"
|
||||
nodectl.mark_wizard_done()
|
||||
assert logged_in.get("/", follow_redirects=False).status_code == 200
|
||||
|
||||
|
||||
def test_dashboard_without_installer_state_has_no_wizard(logged_in, data_dirs, monkeypatch):
|
||||
prepare(data_dirs, monkeypatch)
|
||||
nodectl.SETUP_STATE.unlink()
|
||||
assert logged_in.get("/", follow_redirects=False).status_code == 200
|
||||
|
||||
|
||||
def test_install_step_shown_while_installing(logged_in, data_dirs, monkeypatch):
|
||||
prepare(data_dirs, monkeypatch, stage="installing")
|
||||
assert logged_in.get("/setup", follow_redirects=False).headers["location"] == "/setup/install"
|
||||
html = logged_in.get("/setup/install").text
|
||||
assert "Настраиваем сервер" in html and "Устанавливаем…" in html
|
||||
# other steps are locked until the installation finishes
|
||||
assert logged_in.get("/setup/welcome", follow_redirects=False).headers["location"] == "/setup/install"
|
||||
state = logged_in.get("/api/setup/state").json()
|
||||
assert state["ok"] and state["state"]["installed"] is False
|
||||
|
||||
|
||||
def test_wizard_steps_render_and_store_choices(logged_in, data_dirs, monkeypatch):
|
||||
prepare(data_dirs, monkeypatch)
|
||||
monkeypatch.setattr(nodectl, "set_timezone", lambda tz: (True, tz))
|
||||
for step in setup.STEP_IDS[1:]:
|
||||
assert logged_in.get(f"/setup/{step}").status_code == 200, step
|
||||
assert logged_in.post("/setup/welcome", data={"timezone": "Europe/Moscow"}, follow_redirects=False).headers["location"] == "/setup/account"
|
||||
assert logged_in.post("/setup/account", data={"password": "", "password2": ""}, follow_redirects=False).headers["location"] == "/setup/access"
|
||||
assert logged_in.post("/setup/account", data={"password": "abcdef", "password2": "other"}).status_code == 400
|
||||
assert logged_in.post("/setup/access", data={"tls": "0", "port": "8090"}, follow_redirects=False).headers["location"] == "/setup/network"
|
||||
assert logged_in.post("/setup/access", data={"tls": "1", "port": "22"}).status_code == 400
|
||||
monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {})
|
||||
assert logged_in.post("/setup/network", data={"mode": "nat"}, follow_redirects=False).headers["location"] == "/setup/storage"
|
||||
assert logged_in.post("/setup/storage", data={"disk": "keep"}, follow_redirects=False).headers["location"] == "/setup/updates"
|
||||
assert logged_in.post("/setup/updates", data={"auto_update": "0", "channel": "stable"}, follow_redirects=False).headers["location"] == "/setup/finish"
|
||||
choices = nodectl.wizard_choices()
|
||||
assert choices["timezone"] == "Europe/Moscow" and choices["port"] == "8090" and choices["tls"] == "0" and choices["network"] == "nat" and choices["auto_update"] == "0"
|
||||
finish = logged_in.get("/setup/finish").text
|
||||
assert "http://" in finish and "Europe/Moscow" in finish and "Вручную" in finish
|
||||
|
||||
|
||||
def test_finish_marks_done_without_ctl(logged_in, data_dirs, monkeypatch):
|
||||
prepare(data_dirs, monkeypatch)
|
||||
response = logged_in.post("/setup/finish", follow_redirects=False)
|
||||
assert response.status_code == 303 and response.headers["location"].startswith("/?message=")
|
||||
assert nodectl.wizard_done()
|
||||
|
||||
|
||||
def test_finish_applies_settings_through_ctl(logged_in, data_dirs, monkeypatch):
|
||||
prepare(data_dirs, monkeypatch)
|
||||
calls = []
|
||||
monkeypatch.setattr(nodectl, "ctl_available", lambda: True)
|
||||
monkeypatch.setattr(nodectl, "ctl_detached", lambda *args, **kw: calls.append(args) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""})
|
||||
nodectl.save_wizard_choices(tls="1", port="8088", auto_update="1", channel="stable")
|
||||
response = logged_in.post("/setup/finish", follow_redirects=False)
|
||||
assert response.status_code == 200 and "https://panel.test:8443" in response.text
|
||||
assert calls and calls[0][0] == "set" and "VIRTUALITY_TLS=1" in calls[0]
|
||||
|
||||
|
||||
def test_bridge_mode_starts_network_change(logged_in, data_dirs, monkeypatch):
|
||||
prepare(data_dirs, monkeypatch)
|
||||
calls = []
|
||||
monkeypatch.setattr(nodectl, "enable_bridge", lambda iface, mode="dhcp": calls.append((iface, mode)) or (True, "ok"))
|
||||
response = logged_in.post("/setup/network", data={"mode": "bridge"})
|
||||
assert response.status_code == 200 and "Перенастраиваем сеть" in response.text
|
||||
assert calls == [("enp3s0", "dhcp")]
|
||||
monkeypatch.setattr(nodectl, "confirm_bridge", lambda: (True, "Сеть подтверждена"))
|
||||
assert logged_in.post("/setup/network/confirm").json()["ok"] is True
|
||||
assert nodectl.wizard_choices()["bridge_pending"] is False
|
||||
|
||||
|
||||
def test_restart_wizard_from_settings(logged_in, data_dirs, monkeypatch):
|
||||
prepare(data_dirs, monkeypatch)
|
||||
nodectl.mark_wizard_done()
|
||||
assert logged_in.post("/setup/restart", follow_redirects=False).headers["location"] == "/setup/welcome"
|
||||
assert not nodectl.wizard_done()
|
||||
@@ -1,286 +0,0 @@
|
||||
"""Снимки (точки восстановления) машины: парсеры и маршруты /vm/{name}/snapshots."""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
import pytest
|
||||
|
||||
import conftest
|
||||
import core
|
||||
from features import snapshots
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- parsers
|
||||
def test_parse_snapshot_list_fixture():
|
||||
rows = snapshots.parse_snapshot_list((FIXTURES / "snapshot-list.txt").read_text())
|
||||
assert [row["name"] for row in rows] == ["before-upd", "clean"] # новые сверху
|
||||
assert rows[0] == {"name": "before-upd", "created_at": "2026-09-25 10:12:03", "tz": "+0000", "state": "shutoff"}
|
||||
assert rows[1]["state"] == "running"
|
||||
|
||||
|
||||
def test_parse_snapshot_list_without_timezone_and_garbage():
|
||||
text = " Name Creation Time State\n------\n snap1 2026-01-02 03:04:05 running\nerror: something\n\n"
|
||||
rows = snapshots.parse_snapshot_list(text)
|
||||
assert rows == [{"name": "snap1", "created_at": "2026-01-02 03:04:05", "tz": "", "state": "running"}]
|
||||
assert snapshots.parse_snapshot_list("") == []
|
||||
|
||||
|
||||
def test_parse_snapshot_xml():
|
||||
meta = snapshots.parse_snapshot_xml(conftest.fake_snapshot_xml("clean"))
|
||||
assert meta["description"] == "Чистая система после установки"
|
||||
assert meta["state"] == "running" and meta["with_memory"] is True
|
||||
assert snapshots.parse_snapshot_xml(conftest.fake_snapshot_xml("before-upd"))["with_memory"] is False
|
||||
assert snapshots.parse_snapshot_xml("<broken") == {}
|
||||
|
||||
|
||||
def test_inspect_domain_xml_qcow2_fixture():
|
||||
info = snapshots.inspect_domain_xml((FIXTURES / "dumpxml-web01.xml").read_text())
|
||||
assert info["disks"] == [{"target": "vda", "format": "qcow2"}] # cdrom пропущен
|
||||
assert info["unsupported"] == [] and info["uefi"] is False
|
||||
|
||||
|
||||
def test_inspect_domain_xml_raw_and_uefi():
|
||||
xml = """<domain type='kvm'><name>x</name>
|
||||
<os firmware='efi'><type arch='x86_64'>hvm</type><loader readonly='yes' type='pflash'>/usr/share/OVMF/OVMF_CODE.fd</loader><nvram>/var/lib/libvirt/qemu/nvram/x.fd</nvram></os>
|
||||
<devices>
|
||||
<disk type='file' device='disk'><driver name='qemu' type='raw'/><source file='/x.img'/><target dev='vda' bus='virtio'/></disk>
|
||||
<disk type='file' device='disk'><driver name='qemu' type='qcow2'/><source file='/y.qcow2'/><target dev='vdb' bus='virtio'/></disk>
|
||||
</devices></domain>"""
|
||||
info = snapshots.inspect_domain_xml(xml)
|
||||
assert info["unsupported"] == [{"target": "vda", "format": "raw"}]
|
||||
assert info["uefi"] is True
|
||||
assert snapshots.inspect_domain_xml("<nope") == {"disks": [], "unsupported": [], "uefi": False}
|
||||
|
||||
|
||||
def test_format_when():
|
||||
now = datetime(2026, 9, 25, 12, 0, 0)
|
||||
assert snapshots.format_when("2026-09-25 10:12:03", now) == "Сегодня, 10:12"
|
||||
assert snapshots.format_when("2026-09-24 09:00:41", now) == "Вчера, 09:00"
|
||||
assert snapshots.format_when("2026-03-08 18:30:00", now) == "8 марта 2026, 18:30"
|
||||
assert snapshots.format_when("garbage", now) == "garbage"
|
||||
|
||||
|
||||
def test_explain_error_translates_known_libvirt_messages():
|
||||
text = snapshots.explain_error("error: unsupported configuration: internal snapshots of a VM with pflash based firmware are not supported")
|
||||
assert text.startswith("Для машин с UEFI-прошивкой") and "libvirt:" in text
|
||||
assert snapshots.explain_error("error: internal snapshot for disk vda unsupported for storage type raw").startswith("Внутренние снимки работают только с дисками qcow2")
|
||||
assert snapshots.explain_error("error: Timed out during operation: cannot acquire state change lock").startswith("Машина занята")
|
||||
assert snapshots.explain_error("error: something odd\nsecond line") == "something odd"
|
||||
assert "не объяснил" in snapshots.explain_error("")
|
||||
|
||||
|
||||
def test_next_snapshot_name_and_description():
|
||||
now = datetime(2026, 9, 25, 10, 12)
|
||||
assert snapshots.next_snapshot_name([], now) == "snap-20260925-1012"
|
||||
assert snapshots.next_snapshot_name(["snap-20260925-1012"], now) == "snap-20260925-1012-2"
|
||||
assert snapshots.next_snapshot_name(["snap-20260925-1012", "snap-20260925-1012-2"], now) == "snap-20260925-1012-3"
|
||||
assert core.valid_label(snapshots.next_snapshot_name([], now))
|
||||
assert snapshots.clean_description(" --force\x00 перед\nобновлением ") == "force перед обновлением"
|
||||
assert len(snapshots.clean_description("x" * 500)) == snapshots.DESCRIPTION_MAX
|
||||
|
||||
|
||||
def test_snapshot_state_labels():
|
||||
assert snapshots.snapshot_state("running") == {"label": "Работала", "tone": "success"}
|
||||
assert snapshots.snapshot_state("shutoff")["label"] == "Была выключена"
|
||||
assert snapshots.snapshot_state("weird") == {"label": "weird", "tone": "neutral"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- helpers for routes
|
||||
@pytest.fixture()
|
||||
def recorder(monkeypatch):
|
||||
"""run_cmd, который записывает команды virsh и выполняет операции сразу (без потоков)."""
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def run(cmd, timeout=12, **kwargs):
|
||||
calls.append(list(cmd))
|
||||
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
|
||||
|
||||
monkeypatch.setattr(snapshots, "run_cmd", run)
|
||||
monkeypatch.setattr(snapshots, "run_operation", lambda operation, worker: worker(operation))
|
||||
return calls
|
||||
|
||||
|
||||
def find(calls, *prefix):
|
||||
return [cmd for cmd in calls if cmd[: len(prefix)] == list(prefix)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- routes
|
||||
def test_snapshots_page_requires_login(client):
|
||||
response = client.get("/vm/web01/snapshots", follow_redirects=False)
|
||||
assert response.status_code == 303 and response.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_snapshots_page_renders_list(logged_in):
|
||||
html = logged_in.get("/vm/web01/snapshots").text
|
||||
assert "Перед обновлением ядра" in html and "Чистая система после установки" in html
|
||||
assert "Текущий" in html and "Была выключена" in html and "Работала" in html
|
||||
assert 'action="/vm/web01/snapshots/before-upd/revert"' in html
|
||||
assert 'action="/vm/web01/snapshots/clean/delete"' in html
|
||||
assert "2 из 8" in html
|
||||
assert "snap-" in html # предложенное имя
|
||||
assert "shut off" not in html
|
||||
|
||||
|
||||
def test_snapshots_page_unknown_vm_redirects_home(logged_in):
|
||||
response = logged_in.get("/vm/ghost/snapshots", follow_redirects=False)
|
||||
assert response.status_code == 303 and response.headers["location"] == "/"
|
||||
assert logged_in.get("/vm/-bad/snapshots", follow_redirects=False).status_code == 400
|
||||
|
||||
|
||||
def test_vm_page_links_to_snapshots_tab(logged_in):
|
||||
assert 'href="/vm/web01/snapshots"' in logged_in.get("/vm/web01").text
|
||||
|
||||
|
||||
def test_create_snapshot_runs_virsh_and_reports_operation(logged_in, data_dirs, recorder):
|
||||
response = logged_in.post("/vm/web01/snapshots/create", data={"description": "Перед экспериментом"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
location = response.headers["location"]
|
||||
assert location.startswith("/vm/web01/snapshots?op=")
|
||||
cmd = find(recorder, "virsh", "snapshot-create-as")[0]
|
||||
assert cmd[2:6] == ["--domain", "web01", "--name", cmd[5]] and cmd[5].startswith("snap-")
|
||||
assert cmd[6:] == ["--description", "Перед экспериментом", "--atomic"]
|
||||
operation = core.read_operation(location.split("op=", 1)[1])
|
||||
assert operation["status"] == "success" and operation["type"] == "snapshot_create" and operation["vm_name"] == "web01"
|
||||
assert "создан" in operation["message"]
|
||||
html = logged_in.get(location).text
|
||||
assert "создан" in html
|
||||
|
||||
|
||||
def test_create_snapshot_without_description_omits_option(logged_in, data_dirs, recorder):
|
||||
logged_in.post("/vm/web01/snapshots/create", data={"description": " "}, follow_redirects=False)
|
||||
cmd = find(recorder, "virsh", "snapshot-create-as")[0]
|
||||
assert "--description" not in cmd and cmd[-1] == "--atomic"
|
||||
|
||||
|
||||
def test_create_snapshot_limit(logged_in, data_dirs, recorder, monkeypatch):
|
||||
rows = "\n".join(f" snap-{i:02d} 2026-09-{i + 1:02d} 10:00:00 +0000 shutoff" for i in range(8))
|
||||
original = snapshots.run_cmd
|
||||
|
||||
def run(cmd, timeout=12, **kwargs):
|
||||
if cmd[:2] == ["virsh", "snapshot-list"]:
|
||||
return conftest.fake_result(" Name Creation Time State\n---\n" + rows)
|
||||
return original(cmd, timeout, **kwargs)
|
||||
|
||||
monkeypatch.setattr(snapshots, "run_cmd", run)
|
||||
response = logged_in.post("/vm/web01/snapshots/create", data={"description": "x"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert "максимум" in unquote(response.headers["location"])
|
||||
assert not find(recorder, "virsh", "snapshot-create-as")
|
||||
html = logged_in.get("/vm/web01/snapshots").text
|
||||
assert "8 из 8" in html and "максимум" in html
|
||||
|
||||
|
||||
def test_create_snapshot_rejects_raw_disk(logged_in, data_dirs, recorder, monkeypatch):
|
||||
original = snapshots.run_cmd
|
||||
|
||||
def run(cmd, timeout=12, **kwargs):
|
||||
if cmd[:2] == ["virsh", "dumpxml"]:
|
||||
return conftest.fake_result("<domain><devices><disk device='disk'><driver type='raw'/><target dev='vda'/></disk></devices></domain>")
|
||||
return original(cmd, timeout, **kwargs)
|
||||
|
||||
monkeypatch.setattr(snapshots, "run_cmd", run)
|
||||
response = logged_in.post("/vm/web01/snapshots/create", data={"description": "x"}, follow_redirects=False)
|
||||
assert "qcow2" in unquote(response.headers["location"])
|
||||
assert not find(recorder, "virsh", "snapshot-create-as")
|
||||
|
||||
|
||||
def test_create_snapshot_surfaces_libvirt_error(logged_in, data_dirs, recorder, monkeypatch):
|
||||
original = snapshots.run_cmd
|
||||
|
||||
def run(cmd, timeout=12, **kwargs):
|
||||
if cmd[:2] == ["virsh", "snapshot-create-as"]:
|
||||
return conftest.fake_result(ok=False, stderr="error: unsupported configuration: internal snapshots of a VM with pflash based firmware are not supported")
|
||||
return original(cmd, timeout, **kwargs)
|
||||
|
||||
monkeypatch.setattr(snapshots, "run_cmd", run)
|
||||
response = logged_in.post("/vm/web01/snapshots/create", data={}, follow_redirects=False)
|
||||
location = response.headers["location"]
|
||||
operation = core.read_operation(location.split("op=", 1)[1])
|
||||
assert operation["status"] == "error"
|
||||
assert operation["message"].startswith("Для машин с UEFI-прошивкой") and "pflash" in operation["message"]
|
||||
assert "pflash" in operation["log_tail"]
|
||||
html = logged_in.get(location).text
|
||||
assert "Для машин с UEFI-прошивкой" in html
|
||||
|
||||
|
||||
def test_create_snapshot_waits_for_active_operation(logged_in, data_dirs, recorder):
|
||||
core.new_operation("snapshot_create", "Снимок машины web01", vm_name="web01", snapshot="snap-x")
|
||||
response = logged_in.post("/vm/web01/snapshots/create", data={}, follow_redirects=False)
|
||||
assert "предыдущей операции" in unquote(response.headers["location"])
|
||||
assert not find(recorder, "virsh", "snapshot-create-as")
|
||||
html = logged_in.get("/vm/web01/snapshots").text
|
||||
assert 'data-watch-operation=' in html and "/api/operations/" in html
|
||||
|
||||
|
||||
def test_revert_snapshot(logged_in, data_dirs, recorder):
|
||||
response = logged_in.post("/vm/web01/snapshots/clean/revert", follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert find(recorder, "virsh", "snapshot-revert") == [["virsh", "snapshot-revert", "--domain", "web01", "--snapshotname", "clean"]]
|
||||
operation = core.read_operation(response.headers["location"].split("op=", 1)[1])
|
||||
assert operation["status"] == "success" and "возвращена" in operation["message"] and "работает" in operation["message"]
|
||||
|
||||
|
||||
def test_revert_retries_with_force(logged_in, data_dirs, recorder, monkeypatch):
|
||||
original = snapshots.run_cmd
|
||||
|
||||
def run(cmd, timeout=12, **kwargs):
|
||||
if cmd[:2] == ["virsh", "snapshot-revert"] and "--force" not in cmd:
|
||||
recorder.append(list(cmd))
|
||||
return conftest.fake_result(ok=False, stderr="error: revert requires force: snapshot was created with a different configuration")
|
||||
return original(cmd, timeout, **kwargs)
|
||||
|
||||
monkeypatch.setattr(snapshots, "run_cmd", run)
|
||||
response = logged_in.post("/vm/web01/snapshots/clean/revert", follow_redirects=False)
|
||||
reverts = find(recorder, "virsh", "snapshot-revert")
|
||||
assert len(reverts) == 2 and reverts[1][-1] == "--force"
|
||||
assert core.read_operation(response.headers["location"].split("op=", 1)[1])["status"] == "success"
|
||||
|
||||
|
||||
def test_revert_reports_error(logged_in, data_dirs, recorder, monkeypatch):
|
||||
original = snapshots.run_cmd
|
||||
|
||||
def run(cmd, timeout=12, **kwargs):
|
||||
if cmd[:2] == ["virsh", "snapshot-revert"]:
|
||||
return conftest.fake_result(ok=False, stderr="error: Domain snapshot not found: no domain snapshot with matching name 'clean'")
|
||||
return original(cmd, timeout, **kwargs)
|
||||
|
||||
monkeypatch.setattr(snapshots, "run_cmd", run)
|
||||
response = logged_in.post("/vm/web01/snapshots/clean/revert", follow_redirects=False)
|
||||
operation = core.read_operation(response.headers["location"].split("op=", 1)[1])
|
||||
assert operation["status"] == "error" and operation["message"].startswith("Такого снимка уже нет")
|
||||
|
||||
|
||||
def test_delete_snapshot(logged_in, data_dirs, recorder):
|
||||
response = logged_in.post("/vm/web01/snapshots/before-upd/delete", follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert "удалён" in unquote(response.headers["location"])
|
||||
assert find(recorder, "virsh", "snapshot-delete") == [["virsh", "snapshot-delete", "--domain", "web01", "--snapshotname", "before-upd"]]
|
||||
|
||||
|
||||
def test_delete_snapshot_error(logged_in, data_dirs, recorder, monkeypatch):
|
||||
original = snapshots.run_cmd
|
||||
|
||||
def run(cmd, timeout=12, **kwargs):
|
||||
if cmd[:2] == ["virsh", "snapshot-delete"]:
|
||||
return conftest.fake_result(ok=False, stderr="error: Timed out during operation: cannot acquire state change lock")
|
||||
return original(cmd, timeout, **kwargs)
|
||||
|
||||
monkeypatch.setattr(snapshots, "run_cmd", run)
|
||||
response = logged_in.post("/vm/web01/snapshots/before-upd/delete", follow_redirects=False)
|
||||
assert "snapshot_error=" in response.headers["location"] and "занята" in unquote(response.headers["location"])
|
||||
|
||||
|
||||
def test_snapshot_routes_validate_names(logged_in, data_dirs, recorder):
|
||||
bad = logged_in.post("/vm/web01/snapshots/-bad/revert", follow_redirects=False)
|
||||
assert bad.status_code == 303 and "snapshot_error=" in bad.headers["location"]
|
||||
assert logged_in.post("/vm/web01/snapshots/..%2Fx/delete", follow_redirects=False).status_code in (400, 404)
|
||||
assert logged_in.post("/vm/-bad/snapshots/create", data={}, follow_redirects=False).status_code == 400
|
||||
assert not find(recorder, "virsh", "snapshot-delete") and not find(recorder, "virsh", "snapshot-revert")
|
||||
|
||||
|
||||
def test_snapshot_routes_require_login(client):
|
||||
for path in ("/vm/web01/snapshots/create", "/vm/web01/snapshots/clean/revert", "/vm/web01/snapshots/clean/delete"):
|
||||
response = client.post(path, follow_redirects=False)
|
||||
assert response.status_code == 303 and response.headers["location"] == "/login"
|
||||
@@ -0,0 +1,93 @@
|
||||
import app
|
||||
|
||||
|
||||
def test_valid_vm_name():
|
||||
assert app.valid_vm_name("ubuntu-test")
|
||||
assert app.valid_vm_name("vm1.local_x")
|
||||
assert not app.valid_vm_name("")
|
||||
assert not app.valid_vm_name("a") # минимум 2 символа
|
||||
assert not app.valid_vm_name("-starts-with-dash")
|
||||
assert not app.valid_vm_name("has space")
|
||||
assert not app.valid_vm_name("x" * 64)
|
||||
|
||||
|
||||
def test_valid_snapshot_name():
|
||||
assert app.valid_snapshot_name("before-update")
|
||||
assert app.valid_snapshot_name("s1")
|
||||
assert app.valid_snapshot_name("a") # один символ допустим
|
||||
assert not app.valid_snapshot_name("")
|
||||
assert not app.valid_snapshot_name("bad name")
|
||||
assert not app.valid_snapshot_name(".hidden")
|
||||
|
||||
|
||||
def test_safe_iso_filename():
|
||||
assert app.safe_iso_filename("ubuntu-24.04.iso") == "ubuntu-24.04.iso"
|
||||
assert app.safe_iso_filename("my image.iso") == "my-image.iso"
|
||||
assert app.safe_iso_filename("../../etc/passwd") is None
|
||||
assert app.safe_iso_filename("evil.qcow2") is None
|
||||
assert app.safe_iso_filename("") is None
|
||||
|
||||
|
||||
def test_iso_path_traversal_guard(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(app, "ISO_DIR", tmp_path)
|
||||
assert app.iso_path_by_name("ok.iso") == tmp_path / "ok.iso"
|
||||
# Пути с каталогами санитизируются до basename и остаются внутри ISO_DIR
|
||||
assert app.iso_path_by_name("../escape.iso") == tmp_path / "escape.iso"
|
||||
assert app.iso_path_by_name("nested/evil.iso") == tmp_path / "evil.iso"
|
||||
assert app.iso_path_by_name("не-ascii.iso") != tmp_path / "не-ascii.iso" # кириллица заменяется
|
||||
assert app.iso_path_by_name("no-extension") is None
|
||||
|
||||
|
||||
def test_progress_parsers():
|
||||
assert app.wget_progress(10, " 512000K .......... 42% 10.5M 2m30s") == 42
|
||||
assert app.wget_progress(50, "no percent here") == 50
|
||||
assert app.wget_progress(10, "100%") == 99 # качаем — не показываем 100 до mv
|
||||
assert app.qemu_convert_progress(0, " (42.00/100%)") == 42
|
||||
assert app.qemu_convert_progress(77, " (42.00/100%)") == 77 # прогресс не откатывается
|
||||
assert app.progress_from_line(10, "Allocating disk...") == 30
|
||||
assert app.progress_from_line(50, "Creating domain...") == 75
|
||||
|
||||
|
||||
def test_snapshot_list_parser(monkeypatch):
|
||||
sample = """ Name Creation Time State
|
||||
--------------------------------------------------------
|
||||
before-update 2026-08-15 12:10:33 +0300 running
|
||||
clean 2026-08-14 09:01:02 +0300 shutoff
|
||||
"""
|
||||
monkeypatch.setattr(app, "run_cmd", lambda cmd, timeout=12: {"ok": True, "stdout": sample, "stderr": "", "code": 0, "cmd": ""})
|
||||
snaps = app.list_vm_snapshots("vm1")
|
||||
assert [s["name"] for s in snaps] == ["before-update", "clean"]
|
||||
assert snaps[0]["state"] == "running"
|
||||
assert snaps[0]["created"] == "2026-08-15 12:10:33 +0300"
|
||||
|
||||
|
||||
def test_snapshot_action_rejects_bad_name():
|
||||
ok, message = app.snapshot_action("vm1", "create", "bad name")
|
||||
assert not ok
|
||||
assert "Имя снапшота" in message
|
||||
|
||||
|
||||
def test_vm_templates_flag(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(app, "VM_TEMPLATES_FILE", tmp_path / "vm_templates.json")
|
||||
assert app.load_vm_templates() == []
|
||||
app.set_vm_template("vm1", True)
|
||||
app.set_vm_template("vm2", True)
|
||||
assert app.is_vm_template("vm1")
|
||||
assert sorted(app.load_vm_templates()) == ["vm1", "vm2"]
|
||||
app.set_vm_template("vm1", False)
|
||||
assert not app.is_vm_template("vm1")
|
||||
|
||||
|
||||
def test_ttl_cache(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def producer():
|
||||
calls.append(1)
|
||||
return len(calls)
|
||||
|
||||
app.cache_invalidate()
|
||||
assert app.cached("k1", 60, producer) == 1
|
||||
assert app.cached("k1", 60, producer) == 1
|
||||
assert len(calls) == 1
|
||||
app.cache_invalidate("k1")
|
||||
assert app.cached("k1", 60, producer) == 2
|
||||
@@ -1,441 +0,0 @@
|
||||
"""vmops: увеличение диска, клонирование, пауза и спящий режим, заметки, живая нагрузка."""
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
import pytest
|
||||
|
||||
import conftest
|
||||
import core
|
||||
import presenters
|
||||
from features import vmops
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||
DOMSTATS = (FIXTURES / "domstats.txt").read_text()
|
||||
GIB = 1024 ** 3
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""Записывает команды; отвечает по overrides {(префикс,): результат|функция}, иначе как fake_run_cmd."""
|
||||
|
||||
def __init__(self, overrides=None):
|
||||
self.calls: list[list[str]] = []
|
||||
self.overrides = overrides or {}
|
||||
|
||||
def __call__(self, cmd, timeout=12, **kwargs):
|
||||
self.calls.append(list(cmd))
|
||||
for prefix, result in self.overrides.items():
|
||||
if tuple(cmd[: len(prefix)]) == prefix:
|
||||
return result(cmd) if callable(result) else result
|
||||
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
|
||||
|
||||
def find(self, *prefix):
|
||||
return [call for call in self.calls if tuple(call[: len(prefix)]) == prefix]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def record(data_dirs, monkeypatch):
|
||||
"""Подменяет run_cmd и в core (vmops), и в app (обзор, страница машины).
|
||||
|
||||
Свободное место тоже подменено: временная папка тестов бывает маленькой (tmpfs),
|
||||
а тесты про нехватку места задают его сами."""
|
||||
import app
|
||||
|
||||
recorder = Recorder()
|
||||
for module in (core, app):
|
||||
monkeypatch.setattr(module, "run_cmd", recorder)
|
||||
monkeypatch.setattr(vmops, "_samples", {})
|
||||
monkeypatch.setattr(vmops, "free_space", lambda path: 500 * GIB)
|
||||
return recorder
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def inline_operations(monkeypatch):
|
||||
"""Фоновые операции выполняются сразу, в этом же потоке."""
|
||||
|
||||
def run_now(operation, worker):
|
||||
core.update_operation(operation, status="running")
|
||||
worker(operation)
|
||||
|
||||
monkeypatch.setattr(vmops, "run_operation", run_now)
|
||||
|
||||
|
||||
def shut_off(record):
|
||||
record.overrides[("virsh", "domstate")] = conftest.fake_result("shut off")
|
||||
|
||||
|
||||
def location(response) -> str:
|
||||
assert response.status_code == 303, response.text[:300]
|
||||
return unquote(response.headers["location"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- parsers
|
||||
def test_parse_domstats_fixture():
|
||||
stats = vmops.parse_domstats(DOMSTATS)
|
||||
assert list(stats) == ["web01"]
|
||||
assert stats["web01"]["cpu.time"] == "48123456789"
|
||||
assert stats["web01"]["balloon.rss"] == "1534208"
|
||||
assert stats["web01"]["vcpu.current"] == "2"
|
||||
|
||||
|
||||
def test_parse_domstats_several_domains():
|
||||
text = DOMSTATS + "Domain: 'db01'\n\nDomain: 'win 11'\n cpu.time=5\n vcpu.current=4\n"
|
||||
stats = vmops.parse_domstats(text)
|
||||
assert set(stats) == {"web01", "db01", "win 11"}
|
||||
assert stats["db01"] == {}
|
||||
assert stats["win 11"] == {"cpu.time": "5", "vcpu.current": "4"}
|
||||
|
||||
|
||||
def test_compute_stats_cpu_percent_from_two_samples():
|
||||
samples: dict = {}
|
||||
first = vmops.compute_stats(vmops.parse_domstats(DOMSTATS), 100.0, samples)
|
||||
assert first["web01"]["cpu_pct"] is None # первый замер — сравнивать не с чем
|
||||
assert first["web01"]["mem_used_mb"] == 1498 and first["web01"]["mem_total_mb"] == 2048
|
||||
later = DOMSTATS.replace("cpu.time=48123456789", "cpu.time=49123456789") # +1 с процессорного времени
|
||||
second = vmops.compute_stats(vmops.parse_domstats(later), 101.0, samples)
|
||||
assert second["web01"]["cpu_pct"] == 50.0 # 1 с на 2 ядра за 1 с стены
|
||||
burst = DOMSTATS.replace("cpu.time=48123456789", "cpu.time=99123456789")
|
||||
third = vmops.compute_stats(vmops.parse_domstats(burst), 101.5, samples)
|
||||
assert third["web01"]["cpu_pct"] == 100.0 # не больше 100 %
|
||||
|
||||
|
||||
def test_compute_stats_skips_shut_off_and_forgets_stale():
|
||||
samples = {"old": (1.0, 5)}
|
||||
stats = vmops.compute_stats({"db01": {}, "web01": {"cpu.time": "10", "balloon.current": "1024", "vcpu.current": "1"}}, 2.0, samples)
|
||||
assert set(stats) == {"web01"}
|
||||
assert stats["web01"]["mem_used_mb"] == 1 and stats["web01"]["mem_total_mb"] == 1
|
||||
assert set(samples) == {"web01"}
|
||||
|
||||
|
||||
def test_parse_qemu_img_info():
|
||||
info = vmops.parse_qemu_img_info((FIXTURES / "qemu-img-info.json").read_text())
|
||||
assert info == {"virtual_size": 42949672960, "actual_size": 6443499520, "format": "qcow2"}
|
||||
assert vmops.parse_qemu_img_info("not json") == {}
|
||||
assert vmops.parse_qemu_img_info("[1, 2]") == {}
|
||||
|
||||
|
||||
def test_parse_virsh_list_titles():
|
||||
titles = presenters.parse_virsh_list_titles((FIXTURES / "list-title.txt").read_text())
|
||||
assert titles == {"web01": "Сайт компании", "win11": "Бухгалтерия 1С"}
|
||||
assert presenters.parse_virsh_list_titles(" Id Name State\n---\n 1 web01 running") == {}
|
||||
assert presenters.parse_virsh_list_titles("") == {}
|
||||
|
||||
|
||||
def test_parse_desc_output():
|
||||
assert vmops.parse_desc_output(conftest.fake_result("Сайт компании\n")) == "Сайт компании"
|
||||
assert vmops.parse_desc_output(conftest.fake_result("No title for domain: web01")) == ""
|
||||
assert vmops.parse_desc_output(conftest.fake_result(ok=False, stderr="error: failed to get domain")) == ""
|
||||
|
||||
|
||||
def test_has_managed_save():
|
||||
assert vmops.has_managed_save(conftest.DOMINFO + "Managed save: yes\n")
|
||||
assert not vmops.has_managed_save(conftest.DOMINFO + "Managed save: no\n")
|
||||
assert not vmops.has_managed_save(conftest.DOMINFO)
|
||||
|
||||
|
||||
def test_clean_notes():
|
||||
assert vmops.clean_title(" Сайт \n компании ") == "Сайт компании"
|
||||
assert len(vmops.clean_title("x" * 200)) == vmops.TITLE_MAX
|
||||
assert vmops.clean_description("строка 1\r\nстрока 2 \r\n\r\n") == "строка 1\nстрока 2"
|
||||
|
||||
|
||||
def test_clone_names_and_targets(data_dirs):
|
||||
assert vmops.suggest_clone_name("web01", {"web01"}) == "web01-copy"
|
||||
assert vmops.suggest_clone_name("web01", {"web01", "web01-copy", "web01-copy2"}) == "web01-copy3"
|
||||
disks = [
|
||||
{"device": "disk", "source": "/var/lib/virtuality/images/web01.qcow2"},
|
||||
{"device": "cdrom", "source": ""},
|
||||
{"device": "disk", "source": "/var/lib/virtuality/images/data.img"},
|
||||
]
|
||||
targets = vmops.clone_targets("web02", disks)
|
||||
assert targets == [data_dirs["images"] / "web02.qcow2", data_dirs["images"] / "web02-disk2.img"]
|
||||
|
||||
|
||||
def test_explain_error():
|
||||
assert "снимки" in vmops.explain_error("qemu-img: Can't resize an image which has snapshots", "x")
|
||||
assert vmops.explain_error("", "запасной текст") == "запасной текст"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- live stats
|
||||
def test_live_stats_requires_auth(client):
|
||||
assert client.get("/live/stats").status_code == 401
|
||||
|
||||
|
||||
def test_live_stats_json(logged_in, record):
|
||||
first = logged_in.get("/live/stats").json()
|
||||
assert first["ok"] is True
|
||||
assert first["stats"]["web01"]["cpu_pct"] is None
|
||||
assert first["stats"]["web01"]["mem_used_mb"] == 1498 and first["stats"]["web01"]["mem_total_mb"] == 2048
|
||||
second = logged_in.get("/live/stats").json()
|
||||
assert second["stats"]["web01"]["cpu_pct"] == 0.0 # cpu.time в подделке не растёт
|
||||
assert record.find("virsh", "domstats") == [["virsh", "domstats", "--cpu-total", "--balloon", "--vcpu", "web01"]] * 2 # только активные машины, один вызов на запрос
|
||||
|
||||
|
||||
def test_live_stats_without_active_vms(logged_in, record):
|
||||
record.overrides[("virsh", "list")] = conftest.fake_result("")
|
||||
assert logged_in.get("/live/stats").json()["stats"] == {}
|
||||
assert record.find("virsh", "domstats") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pages
|
||||
def test_dashboard_shows_title_and_meters(logged_in, record):
|
||||
html = logged_in.get("/").text
|
||||
assert "Сайт компании" in html
|
||||
assert 'data-live-stats="web01"' in html and 'data-live-stats="db01"' in html
|
||||
assert "/vm/web01/ops/pause" in html and "/vm/web01/ops/hibernate" in html
|
||||
assert "/vm/web01/clone" in html and "/vm/db01/clone" in html
|
||||
assert record.find("virsh", "list", "--all", "--title")
|
||||
|
||||
|
||||
def test_dashboard_paused_vm_offers_resume(logged_in, record):
|
||||
record.overrides[("virsh", "list")] = conftest.fake_result(" Id Name State\n-----------------------\n 2 web01 paused\n - db01 shut off")
|
||||
html = logged_in.get("/").text
|
||||
assert 'action="/vm/web01/ops/resume"' in html
|
||||
assert 'action="/vm/web01/start"' not in html
|
||||
assert 'action="/vm/db01/start"' in html
|
||||
|
||||
|
||||
def test_vm_page_shows_disks_notes_and_ops(logged_in, record):
|
||||
html = logged_in.get("/vm/web01").text
|
||||
assert "40 ГБ" in html and "занято 6.0 ГБ" in html
|
||||
assert 'action="/vm/web01/disks/vda/resize"' in html and 'min="41"' in html
|
||||
assert 'action="/vm/web01/disks/sda/resize"' not in html # привод не увеличивают
|
||||
assert 'value="Сайт компании"' in html and "Nginx + база. Бэкап по пятницам." in html
|
||||
assert "Приостановить" in html and "Спящий режим" in html and "Клонировать" in html
|
||||
assert 'data-live-stats="web01"' in html # карточка «Нагрузка сейчас»
|
||||
assert record.find("qemu-img", "info") == [["qemu-img", "info", "--output=json", "-U", str(core.IMAGES_DIR / "web01.qcow2")]]
|
||||
|
||||
|
||||
def test_vm_page_paused_and_saved_states(logged_in, record):
|
||||
record.overrides[("virsh", "dominfo")] = conftest.fake_result(conftest.DOMINFO.replace("State: running", "State: paused"))
|
||||
html = logged_in.get("/vm/web01").text
|
||||
assert 'action="/vm/web01/ops/resume"' in html and "Продолжить" in html
|
||||
record.overrides[("virsh", "dominfo")] = conftest.fake_result(conftest.DOMINFO.replace("State: running", "State: shut off") + "Managed save: yes\n")
|
||||
html = logged_in.get("/vm/web01").text
|
||||
assert "Машина в спящем режиме" in html and 'action="/vm/web01/ops/forget-save"' in html
|
||||
assert 'data-live-stats="web01"' not in html # выключенная машина — нагрузки нет
|
||||
|
||||
|
||||
def test_vm_page_messages(logged_in, record):
|
||||
html = logged_in.get("/vm/web01?disk_message=Диск+увеличен¬es_error=Не+вышло").text
|
||||
assert "Диск увеличен" in html and "Не вышло" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- disk resize
|
||||
def test_disk_resize_shut_off_uses_qemu_img(logged_in, record):
|
||||
shut_off(record)
|
||||
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False)
|
||||
assert location(response).startswith("/vm/web01?disk_message=Диск web01.qcow2 увеличен до 50 ГБ")
|
||||
assert location(response).endswith("#storage")
|
||||
assert record.find("qemu-img", "resize") == [["qemu-img", "resize", str(core.IMAGES_DIR / "web01.qcow2"), "50G"]]
|
||||
assert record.find("virsh", "blockresize") == []
|
||||
|
||||
|
||||
def test_disk_resize_running_uses_blockresize(logged_in, record):
|
||||
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "41"}, follow_redirects=False)
|
||||
assert "disk_message" in location(response)
|
||||
assert record.find("virsh", "blockresize") == [["virsh", "blockresize", "web01", "vda", "41G"]]
|
||||
assert record.find("qemu-img", "resize") == []
|
||||
|
||||
|
||||
def test_disk_resize_only_grows(logged_in, record):
|
||||
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "40"}, follow_redirects=False)
|
||||
assert "не меньше 41 ГБ" in location(response)
|
||||
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "999999"}, follow_redirects=False)
|
||||
assert "Слишком большой" in location(response)
|
||||
assert record.find("virsh", "blockresize") == [] and record.find("qemu-img", "resize") == []
|
||||
|
||||
|
||||
def test_disk_resize_rejects_unknown_or_cdrom(logged_in, record):
|
||||
assert "Такого диска" in location(logged_in.post("/vm/web01/disks/vdz/resize", data={"size_gb": "50"}, follow_redirects=False))
|
||||
assert "нельзя увеличить" in location(logged_in.post("/vm/web01/disks/sda/resize", data={"size_gb": "50"}, follow_redirects=False))
|
||||
assert logged_in.post("/vm/web01/disks/../etc/resize", data={"size_gb": "50"}, follow_redirects=False).status_code in (400, 404)
|
||||
assert logged_in.post("/vm/-bad/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False).status_code == 400
|
||||
|
||||
|
||||
def test_disk_resize_outside_storage_refused(logged_in, record):
|
||||
record.overrides[("virsh", "domblklist")] = conftest.fake_result((FIXTURES / "domblklist-details.txt").read_text().replace("/var/lib/virtuality/images", "/root"))
|
||||
assert "нельзя увеличить" in location(logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False))
|
||||
|
||||
|
||||
def test_disk_resize_checks_free_space(logged_in, record, monkeypatch):
|
||||
monkeypatch.setattr(vmops, "free_space", lambda path: 2 * GIB)
|
||||
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False)
|
||||
assert "свободно только 2.0 ГБ" in location(response)
|
||||
assert record.find("virsh", "blockresize") == []
|
||||
|
||||
|
||||
def test_disk_resize_reports_tool_error(logged_in, record):
|
||||
record.overrides[("virsh", "blockresize")] = conftest.fake_result(ok=False, stderr="error: internal error: Can't resize an image which has snapshots")
|
||||
assert "удалите снимки" in location(logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pause / resume / hibernate
|
||||
def test_ops_pause_and_resume(logged_in, record):
|
||||
response = logged_in.post("/vm/web01/ops/pause", data={"next": "/"}, follow_redirects=False)
|
||||
assert location(response).startswith("/?ops_message=Машина web01 приостановлена")
|
||||
assert record.find("virsh", "suspend") == [["virsh", "suspend", "web01"]]
|
||||
response = logged_in.post("/vm/web01/ops/resume", follow_redirects=False)
|
||||
assert "ops_error=Продолжить можно только приостановленную" in location(response)
|
||||
record.overrides[("virsh", "domstate")] = conftest.fake_result("paused")
|
||||
response = logged_in.post("/vm/web01/ops/resume", data={"next": "http://evil.example/"}, follow_redirects=False)
|
||||
assert location(response).startswith("/vm/web01?ops_message=") # чужой next не используется
|
||||
assert record.find("virsh", "resume") == [["virsh", "resume", "web01"]]
|
||||
|
||||
|
||||
def test_ops_validation(logged_in, record):
|
||||
assert logged_in.post("/vm/web01/ops/explode", follow_redirects=False).status_code == 400
|
||||
assert logged_in.post("/vm/-bad/ops/pause", follow_redirects=False).status_code == 400
|
||||
assert location(logged_in.post("/vm/ghost/ops/pause", follow_redirects=False)) == "/"
|
||||
shut_off(record)
|
||||
assert "ops_error=Приостановить можно только работающую" in location(logged_in.post("/vm/web01/ops/pause", follow_redirects=False))
|
||||
record.overrides[("virsh", "suspend")] = conftest.fake_result(ok=False, stderr="error: Timed out during operation: cannot acquire state change lock")
|
||||
record.overrides[("virsh", "domstate")] = conftest.fake_result("running")
|
||||
assert "занята другой операцией" in location(logged_in.post("/vm/web01/ops/pause", follow_redirects=False))
|
||||
|
||||
|
||||
def test_ops_requires_login(client):
|
||||
assert client.post("/vm/web01/ops/pause", follow_redirects=False).headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_hibernate_runs_operation(logged_in, record, inline_operations, data_dirs):
|
||||
response = logged_in.post("/vm/web01/ops/hibernate", data={"next": "/"}, follow_redirects=False)
|
||||
assert location(response).startswith("/?ops_message=Машина web01 засыпает")
|
||||
assert record.find("virsh", "managedsave") == [["virsh", "managedsave", "web01"]]
|
||||
operations = core.list_operations(5)
|
||||
assert operations[0]["type"] == "vm_hibernate" and operations[0]["status"] == "success" and operations[0]["vm_name"] == "web01"
|
||||
|
||||
|
||||
def test_hibernate_blocked(logged_in, record, monkeypatch):
|
||||
shut_off(record)
|
||||
assert "ops_error=В спящий режим можно" in location(logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False))
|
||||
record.overrides[("virsh", "domstate")] = conftest.fake_result("running")
|
||||
monkeypatch.setattr(vmops, "free_space", lambda path: 0)
|
||||
assert "свободного места" in location(logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False))
|
||||
assert record.find("virsh", "managedsave") == []
|
||||
|
||||
|
||||
def test_hibernate_failure_is_reported(logged_in, record, inline_operations):
|
||||
record.overrides[("virsh", "managedsave")] = conftest.fake_result(ok=False, stderr="error: Requested operation is not valid: domain is not running")
|
||||
logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False)
|
||||
operation = core.list_operations(1)[0]
|
||||
assert operation["status"] == "error" and "выключена" in operation["message"]
|
||||
|
||||
|
||||
def test_forget_saved_state(logged_in, record):
|
||||
shut_off(record)
|
||||
response = logged_in.post("/vm/web01/ops/forget-save", follow_redirects=False)
|
||||
assert "ops_message=Сохранённое состояние удалено" in location(response)
|
||||
assert record.find("virsh", "managedsave-remove") == [["virsh", "managedsave-remove", "web01"]]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- notes
|
||||
def test_notes_save_running(logged_in, record):
|
||||
response = logged_in.post("/vm/web01/notes/save", data={"title": " Сайт компании ", "description": "строка 1\r\nстрока 2"}, follow_redirects=False)
|
||||
assert location(response) == "/vm/web01?notes_message=Заметки сохранены"
|
||||
assert record.find("virsh", "desc") == [
|
||||
["virsh", "desc", "web01", "--title", "--new-desc=Сайт компании", "--config", "--live"],
|
||||
["virsh", "desc", "web01", "--new-desc=строка 1\nстрока 2", "--config", "--live"],
|
||||
]
|
||||
|
||||
|
||||
def test_notes_save_shut_off_and_dashes(logged_in, record):
|
||||
shut_off(record)
|
||||
logged_in.post("/vm/web01/notes/save", data={"title": "--edit", "description": ""}, follow_redirects=False)
|
||||
calls = record.find("virsh", "desc")
|
||||
assert calls[0] == ["virsh", "desc", "web01", "--title", "--new-desc=--edit", "--config"] # текст одним аргументом, без --live
|
||||
assert calls[1] == ["virsh", "desc", "web01", "--new-desc=", "--config"] # пустое описание очищает
|
||||
|
||||
|
||||
def test_notes_save_error(logged_in, record):
|
||||
record.overrides[("virsh", "desc")] = conftest.fake_result(ok=False, stderr="error: failed to get domain 'web01'")
|
||||
assert "notes_error=" in location(logged_in.post("/vm/web01/notes/save", data={"title": "x"}, follow_redirects=False))
|
||||
assert logged_in.post("/vm/-bad/notes/save", data={"title": "x"}, follow_redirects=False).status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- clone
|
||||
def test_clone_page_blocked_while_running(logged_in, record):
|
||||
response = logged_in.get("/vm/web01/clone")
|
||||
assert response.status_code == 200
|
||||
assert "Сначала выключите машину" in response.text
|
||||
assert 'action="/vm/web01/shutdown"' in response.text
|
||||
assert "disabled" in response.text
|
||||
|
||||
|
||||
def test_clone_page_ready(logged_in, record):
|
||||
shut_off(record)
|
||||
html = logged_in.get("/vm/web01/clone").text
|
||||
assert "Сначала выключите" not in html
|
||||
assert 'value="web01-copy"' in html
|
||||
assert "6.0 ГБ" in html # нужно места — реальный размер диска
|
||||
assert "web01.qcow2" in html
|
||||
|
||||
|
||||
def test_clone_validation(logged_in, record):
|
||||
shut_off(record)
|
||||
assert "Имя копии" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "bad name!"}, follow_redirects=False))
|
||||
assert "уже есть" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "db01"}, follow_redirects=False))
|
||||
assert "уже есть" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "WEB01"}, follow_redirects=False))
|
||||
record.overrides[("virsh", "domstate")] = conftest.fake_result("running")
|
||||
assert "Сначала выключите" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False))
|
||||
assert record.find("virt-clone") == []
|
||||
assert logged_in.post("/vm/-bad/clone/start", data={"new_name": "web02"}, follow_redirects=False).status_code == 400
|
||||
|
||||
|
||||
def test_clone_checks_space_and_existing_file(logged_in, record, data_dirs, monkeypatch):
|
||||
shut_off(record)
|
||||
(data_dirs["images"] / "web02.qcow2").write_bytes(b"x")
|
||||
assert "уже есть в хранилище" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False))
|
||||
monkeypatch.setattr(vmops, "free_space", lambda path: GIB)
|
||||
assert "Не хватает места" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web03"}, follow_redirects=False))
|
||||
|
||||
|
||||
def test_clone_starts_operation(logged_in, record, inline_operations, data_dirs):
|
||||
shut_off(record)
|
||||
response = logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False)
|
||||
assert location(response).startswith("/operations/")
|
||||
operation = core.read_operation(location(response).split("/")[-1])
|
||||
assert operation["status"] == "success" and operation["type"] == "vm_clone"
|
||||
assert operation["vm_name"] == "web02" and operation["source_vm"] == "web01"
|
||||
assert "web02 создана" in operation["message"]
|
||||
assert record.find("virt-clone") == [["virt-clone", "--original", "web01", "--name", "web02", "--file", str(data_dirs["images"] / "web02.qcow2")]]
|
||||
assert record.find("virsh", "pool-refresh") == [["virsh", "pool-refresh", "virtuality-images"]]
|
||||
assert "virt-clone --original web01" in operation["log_tail"]
|
||||
|
||||
|
||||
def test_clone_failure_is_reported(logged_in, record, inline_operations):
|
||||
shut_off(record)
|
||||
record.overrides[("virt-clone",)] = conftest.fake_result(ok=False, stderr="ERROR Storage volume already exists")
|
||||
response = logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False)
|
||||
operation = core.read_operation(location(response).split("/")[-1])
|
||||
assert operation["status"] == "error" and "уже есть" in operation["message"]
|
||||
assert record.find("virsh", "pool-refresh") == []
|
||||
|
||||
|
||||
def test_clone_progress_watcher(data_dirs):
|
||||
target = data_dirs["images"] / "copy.qcow2"
|
||||
target.write_bytes(b"\0" * (512 * 1024))
|
||||
operation = core.new_operation("vm_clone", "test", vm_name="copy", source_vm="web01")
|
||||
|
||||
class OneTick:
|
||||
def __init__(self):
|
||||
self.ticks = 0
|
||||
|
||||
def wait(self, timeout):
|
||||
self.ticks += 1
|
||||
return self.ticks > 1
|
||||
|
||||
vmops._watch_clone_progress(operation, [target], 1024 * 1024, OneTick())
|
||||
fresh = core.read_operation(operation["id"])
|
||||
assert 3 <= fresh["progress"] <= 95 and "Копируем диски" in fresh["message"]
|
||||
assert vmops._allocated_bytes(data_dirs["images"] / "missing.qcow2") == 0
|
||||
|
||||
|
||||
def test_clone_worker_stops_watcher(data_dirs, monkeypatch):
|
||||
"""Даже если virt-clone упал, поток прогресса останавливается."""
|
||||
monkeypatch.setattr(core, "run_cmd", lambda cmd, timeout=12: conftest.fake_result(ok=False, stderr="boom"))
|
||||
operation = core.new_operation("vm_clone", "test", vm_name="web02", source_vm="web01", targets=[str(data_dirs["images"] / "web02.qcow2")], expected_bytes=10)
|
||||
before = threading.active_count()
|
||||
vmops.clone_worker(operation)
|
||||
assert threading.active_count() <= before
|
||||
assert core.read_operation(operation["id"])["status"] == "error"
|
||||
@@ -1,144 +1,5 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.12.5",
|
||||
"date": "2026-09-27",
|
||||
"title": "Удаление машины в любом состоянии",
|
||||
"summary": "Пункт «Удалить машину» теперь есть всегда, а не только у выключенной машины. Работающую или приостановленную машину панель сначала выключит, а подтверждение честно об этом предупреждает. Вместе с машиной удаляются её пробросы портов.",
|
||||
"features": [
|
||||
"«Удалить машину» доступно в меню машины и в списке машин при любом состоянии",
|
||||
"Работающая или приостановленная машина перед удалением выключается",
|
||||
"Пока с машиной идёт копия, клон или другая задача, удалить её нельзя: панель просит дождаться завершения",
|
||||
"Вместе с машиной удаляются её пробросы портов, чтобы они не вели на адрес, который достанется другой машине",
|
||||
"Версия обновлена до 0.12.5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.12.4",
|
||||
"date": "2026-09-27",
|
||||
"title": "Машина с установочного образа после установки перезагружается сама",
|
||||
"summary": "Раньше машина, созданная с ISO, после установки системы выключалась вместо перезагрузки, а при следующем запуске снова загружалась с установочного образа. Теперь она создаётся сразу в рабочем виде, диск стоит первым в порядке загрузки, и после установки машина перезагружается уже в установленную систему.",
|
||||
"features": [
|
||||
"После установки с ISO машина перезагружается сама, а не выключается",
|
||||
"Для машин с ISO диск стоит первым в порядке загрузки: пока он пустой, машина загружается с образа, а после установки с диска и повторно в установщик не попадает",
|
||||
"Версия обновлена до 0.12.4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.12.3",
|
||||
"date": "2026-09-27",
|
||||
"title": "Экран машины снова подключается",
|
||||
"summary": "На Ubuntu 26.04 экран машины в браузере не подключался: новая версия noVNC обращалась не по тому адресу. Теперь экран работает и с новой, и со старой версией noVNC.",
|
||||
"features": [
|
||||
"Экран машины в браузере подключается на Ubuntu 26.04 и Debian 13 (noVNC 1.6), как и на Ubuntu 24.04 (noVNC 1.3)",
|
||||
"Версия обновлена до 0.12.3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.12.2",
|
||||
"date": "2026-09-27",
|
||||
"title": "Понятный итог, если машину создать не удалось",
|
||||
"summary": "Если создание машины сорвалось, панель больше не предлагает открыть экран несуществующей машины. Она пишет, что на сервере от неё ничего не осталось, и даёт кнопку «Создать заново» с тем же образом. Недосозданный диск удаляется сам, поэтому то же название можно использовать сразу.",
|
||||
"features": [
|
||||
"Кнопки «Открыть экран машины» и «Перейти к машине» появляются только после успешного создания",
|
||||
"После неудачного создания: кнопка «Создать заново» с тем же установочным или готовым образом",
|
||||
"Недосозданный диск удаляется сам, повторное создание под тем же названием больше не упирается в «Диск уже существует»",
|
||||
"Версия обновлена до 0.12.2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.12.1",
|
||||
"date": "2026-09-27",
|
||||
"title": "Понятное предупреждение, когда диск машины больше свободного места",
|
||||
"summary": "Если диск новой машины больше, чем свободно на сервере, панель больше не запускает установку, которая падала с ошибкой на английском. Она объясняет, в чём риск, и создаёт такую машину только после подтверждения: диск занимает место постепенно, по мере заполнения.",
|
||||
"features": [
|
||||
"В шаге «Мощность» видно, сколько места свободно на сервере",
|
||||
"Диск больше свободного места создаётся только с подтверждением, раньше установка падала с ошибкой virt-install",
|
||||
"Версия обновлена до 0.12.1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.12.0",
|
||||
"date": "2026-09-27",
|
||||
"title": "Резервные копии по расписанию",
|
||||
"summary": "Сервер сам копирует машины каждый день или раз в неделю в выбранное время и хранит только последние копии, чтобы они не заняли весь диск. Копии делает отдельная служба сервера, поэтому ночное обновление панели их не прерывает. Исправлена загрузка ISO и образов дисков с компьютера.",
|
||||
"features": [
|
||||
"Загрузка ISO и образов дисков с компьютера снова работает: браузер отправлял пустой запрос, и сервер отвечал ошибкой 422",
|
||||
"Расписание копий: каждый день или в выбранный день недели, время, все машины или только отмеченные",
|
||||
"Сервер хранит последние N копий по расписанию для каждой машины и удаляет старые только после удачной новой",
|
||||
"Копии, сделанные вручную, расписание не удаляет",
|
||||
"Если сервер был выключен в назначенное время, копия делается в течение двух часов после включения, иначе в следующий раз",
|
||||
"На странице «Резервные копии» видно следующую копию и итог последнего запуска по каждой машине",
|
||||
"Перезапуск панели больше не объявляет прерванными задачи, которые ведёт служба копий",
|
||||
"Установочный образ не собирается с именем администратора, которое запрещает установщик Ubuntu (admin, root, kvm)",
|
||||
"Версия обновлена до 0.12.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.11.1",
|
||||
"date": "2026-09-27",
|
||||
"title": "Переезд обновлений на git.stackworks.ru",
|
||||
"summary": "Проект переехал с GitHub на собственный сервер git.stackworks.ru, и обновления теперь приходят оттуда. Сервер, который до сих пор получал обновления с GitHub, переключится на новый адрес сам при следующей проверке. Заодно исправлена редкая ошибка резервных копий: машина могла снова запуститься, хотя её только что выключили вручную.",
|
||||
"features": [
|
||||
"Обновления, установка одной командой и установочный ISO берут код с git.stackworks.ru",
|
||||
"Сервер со старым адресом GitHub сам переключается на новый при проверке обновлений",
|
||||
"Запасное обновление архивом ZIP скачивается с того же адреса, откуда сервер получает обновления",
|
||||
"Резервные копии: если машину выключили вручную, пока панель ждала её выключения, панель больше не запускает её снова",
|
||||
"Версия обновлена до 0.11.1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.11.0",
|
||||
"date": "2026-09-25",
|
||||
"title": "Мастер настройки, снимки, резервные копии и быстрые машины из готовых образов",
|
||||
"summary": "Virtuality стала полноценной заменой Proxmox для дома: после установки с ISO панель открывается сразу, а мастер настройки проводит через HTTPS, пароль, сеть машин, хранилище и обновления. Добавлены снимки, резервные копии, клонирование, увеличение диска, спящий режим, скачивание образов по ссылке и из каталога, создание машины из облачного образа с пользователем и паролем, установка Windows 11 с UEFI и TPM. Исправлено больше двадцати ошибок, найденных аудитом.",
|
||||
"features": [
|
||||
"Мастер первичной настройки: часовой пояс, пароль, HTTPS, сеть NAT или локальная сеть (с автооткатом), отдельный диск под машины, обновления",
|
||||
"Первая загрузка образа: панель ставится первой и показывает ход установки в браузере; при ошибке установка продолжается сама",
|
||||
"Страница «Настройки»: всё то же, что в мастере, плюс перезагрузка и выключение сервера, архив настроек, отчёт для поддержки",
|
||||
"HTTPS для панели с сертификатом сервера и перенаправлением с обычного адреса",
|
||||
"Снимки машин: создать, вернуться, удалить — до 8 на машину",
|
||||
"Резервные копии машин: создание, восстановление в новую или ту же машину, удаление",
|
||||
"Клонирование, увеличение диска, пауза и спящий режим, заметки, живая нагрузка процессора и памяти",
|
||||
"Скачивание ISO и образов по ссылке и из каталога: Ubuntu 26.04/24.04, Debian 13, Alpine — с проверкой контрольных сумм",
|
||||
"Машина из готового образа за минуту: пользователь, пароль и SSH-ключ через cloud-init, диск нужного размера",
|
||||
"Тип системы при установке с ISO: Linux, Windows 11 (UEFI + TPM), Windows 10, другая",
|
||||
"Постоянный адрес машины в сети NAT — пробросы портов не ломаются после перезагрузки",
|
||||
"Стабильный канал обновлений: только выпущенные версии; ранний доступ — по желанию",
|
||||
"Консоль машин доступна только через панель: VNC больше не открыт в сеть",
|
||||
"Операции, прерванные перезапуском панели, больше не блокируют машину",
|
||||
"Исправлены: формат облачных образов .img, CD-ROM, удаление UEFI-машин, правила firewall удалённых пробросов, русская локаль virsh, чужой IP у остановленных машин",
|
||||
"Убраны Cockpit, консольный dashboard, эмуляция ARM64 на x86, загрузка по PXE и устаревшие скрипты",
|
||||
"Команда virtuality-ctl: статус, настройки, пароль, часовой пояс, мост, диск, резервная копия настроек, откат, удаление",
|
||||
"Версия обновлена до 0.11.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.10.0",
|
||||
"date": "2026-09-25",
|
||||
"title": "Новый интерфейс, установочный образ и подготовка к продакшену",
|
||||
"summary": "Панель полностью перерисована: светлая и тёмная темы, понятный русский язык вместо технических терминов, мастер создания машины из трёх шагов, открытие доступа из интернета в пару кликов. Добавлен установочный ISO-образ Virtuality на базе Ubuntu Server 26.04 LTS. Исправлены ошибки создания VM, автозапуска и обновлений, усилена безопасность.",
|
||||
"features": [
|
||||
"Новый дизайн панели: светлая и тёмная темы, адаптивная вёрстка для телефона",
|
||||
"Главная страница: загрузка процессора, памяти и хранилища, карточки машин с быстрыми действиями",
|
||||
"Мастер создания машины: готовые конфигурации «Мини», «Стандарт», «Мощная»",
|
||||
"Страница машины: вкладки, переключатель автозапуска, порядок загрузки кнопками",
|
||||
"Раздел «Сеть и доступ»: открытие SSH, сайта, RDP и других сервисов из списка",
|
||||
"Загрузка образов перетаскиванием с прогрессом, скоростью и оставшимся временем",
|
||||
"Подтверждения опасных действий и уведомления о результате",
|
||||
"Раздел «Помощь» с инструкциями для частых задач",
|
||||
"Установочный ISO на базе Ubuntu Server 26.04 LTS: image/build-iso.sh",
|
||||
"Установщик ноды работает на Ubuntu 24.04, 26.04 и Debian 13",
|
||||
"Исправлено падение создания VM из-за отсутствующего импорта platform",
|
||||
"Исправлено: центр обновлений и автообновление предлагали откат на старую версию",
|
||||
"Проверка пароля работает на Python 3.13+ без модулей crypt и spwd",
|
||||
"Ограничение попыток входа, срок жизни сессии, защита от cross-origin POST",
|
||||
"Правила проброса портов восстанавливаются после перезагрузки сервера",
|
||||
"Настройки ноды сохраняются в /var/lib/virtuality/config/web.env и не сбрасываются обновлением",
|
||||
"Публичный /healthz для мониторинга, автотесты и GitHub Actions",
|
||||
"Версия обновлена до 0.10.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.8.61",
|
||||
"date": "2026-05-07",
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"""Linux password verification for the Virtuality panel.
|
||||
|
||||
The stdlib `crypt` and `spwd` modules are deprecated in Python 3.11+ and
|
||||
removed in 3.13 (Debian 13, Ubuntu 25.04+). This module uses them when they
|
||||
exist and otherwise falls back to libcrypt via ctypes and /etc/shadow.
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
SHADOW_FILE = Path("/etc/shadow")
|
||||
LOCKED_HASHES = {"", "!", "*", "!!"}
|
||||
|
||||
_crypt_lock = threading.Lock()
|
||||
_libcrypt = None
|
||||
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
import crypt as _stdlib_crypt # type: ignore[import-not-found]
|
||||
except ImportError:
|
||||
_stdlib_crypt = None
|
||||
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
import spwd as _stdlib_spwd # type: ignore[import-not-found]
|
||||
except ImportError:
|
||||
_stdlib_spwd = None
|
||||
|
||||
|
||||
def _load_libcrypt():
|
||||
global _libcrypt
|
||||
if _libcrypt is None:
|
||||
name = ctypes.util.find_library("crypt") or "libcrypt.so.1"
|
||||
lib = ctypes.CDLL(name)
|
||||
lib.crypt.restype = ctypes.c_char_p
|
||||
lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
||||
_libcrypt = lib
|
||||
return _libcrypt
|
||||
|
||||
|
||||
def crypt_hash(password: str, salt: str) -> str | None:
|
||||
# crypt(3) returns a pointer to a static buffer, so calls are serialized.
|
||||
with _crypt_lock:
|
||||
if _stdlib_crypt is not None:
|
||||
return _stdlib_crypt.crypt(password, salt)
|
||||
try:
|
||||
result = _load_libcrypt().crypt(password.encode(), salt.encode())
|
||||
except OSError:
|
||||
return None
|
||||
return result.decode() if result else None
|
||||
|
||||
|
||||
def shadow_hash(username: str) -> str | None:
|
||||
if _stdlib_spwd is not None:
|
||||
try:
|
||||
return _stdlib_spwd.getspnam(username).sp_pwdp
|
||||
except (KeyError, PermissionError):
|
||||
return None
|
||||
try:
|
||||
for line in SHADOW_FILE.read_text().splitlines():
|
||||
parts = line.split(":")
|
||||
if len(parts) > 1 and parts[0] == username:
|
||||
return parts[1]
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def verify_password(username: str, password: str) -> bool:
|
||||
if not username or not password:
|
||||
return False
|
||||
stored_hash = shadow_hash(username)
|
||||
if stored_hash is None or stored_hash in LOCKED_HASHES or stored_hash.startswith("!"):
|
||||
return False
|
||||
computed = crypt_hash(password, stored_hash)
|
||||
if not computed:
|
||||
return False
|
||||
return secrets.compare_digest(computed, stored_hash)
|
||||
|
||||
|
||||
class LoginThrottle:
|
||||
"""In-memory brute-force protection keyed by client address."""
|
||||
|
||||
def __init__(self, max_failures: int = 5, window_seconds: int = 300, lockout_seconds: int = 300):
|
||||
self.max_failures = max_failures
|
||||
self.window_seconds = window_seconds
|
||||
self.lockout_seconds = lockout_seconds
|
||||
self._failures: dict[str, list[float]] = {}
|
||||
self._locked_until: dict[str, float] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def retry_after(self, key: str) -> int:
|
||||
with self._lock:
|
||||
until = self._locked_until.get(key, 0.0)
|
||||
remaining = until - time.monotonic()
|
||||
if remaining <= 0:
|
||||
self._locked_until.pop(key, None)
|
||||
return 0
|
||||
return int(remaining) + 1
|
||||
|
||||
def record_failure(self, key: str) -> None:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
recent = [ts for ts in self._failures.get(key, []) if now - ts < self.window_seconds]
|
||||
recent.append(now)
|
||||
if len(recent) >= self.max_failures:
|
||||
self._locked_until[key] = now + self.lockout_seconds
|
||||
recent = []
|
||||
self._failures[key] = recent
|
||||
|
||||
def record_success(self, key: str) -> None:
|
||||
with self._lock:
|
||||
self._failures.pop(key, None)
|
||||
self._locked_until.pop(key, None)
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Бэкапы дисков VM: сжатые qcow2-копии в /var/lib/virtuality/backups.
|
||||
|
||||
Без внешних зависимостей — модуль используется и веб-панелью, и ночным
|
||||
systemd-таймером (запуск: python3 backup_core.py --scheduled).
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
BACKUPS_DIR = Path("/var/lib/virtuality/backups")
|
||||
SCHEDULE_FILE = Path("/var/lib/virtuality/config/backup_schedule.json")
|
||||
SCHEDULE_LOCK = threading.Lock()
|
||||
LOG_FILE = Path("/var/log/virtuality/backup.log")
|
||||
DEFAULT_KEEP = 5
|
||||
|
||||
|
||||
def valid_vm_name(name: str) -> bool:
|
||||
return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or ""))
|
||||
|
||||
|
||||
def valid_backup_filename(name: str) -> bool:
|
||||
return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,120}\.qcow2", name or ""))
|
||||
|
||||
|
||||
def backup_dir_for(vm_name: str) -> Path | None:
|
||||
if not valid_vm_name(vm_name):
|
||||
return None
|
||||
path = (BACKUPS_DIR / vm_name).resolve()
|
||||
if BACKUPS_DIR.resolve() not in path.parents:
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def backup_path(vm_name: str, filename: str) -> Path | None:
|
||||
vm_dir = backup_dir_for(vm_name)
|
||||
if vm_dir is None or not valid_backup_filename(filename):
|
||||
return None
|
||||
path = (vm_dir / filename).resolve()
|
||||
if vm_dir not in path.parents:
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def new_backup_path(vm_name: str) -> Path | None:
|
||||
vm_dir = backup_dir_for(vm_name)
|
||||
if vm_dir is None:
|
||||
return None
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return vm_dir / f"{vm_name}_{stamp}.qcow2"
|
||||
|
||||
|
||||
def list_backups() -> list[dict[str, Any]]:
|
||||
backups = []
|
||||
if not BACKUPS_DIR.exists():
|
||||
return backups
|
||||
for vm_dir in sorted(BACKUPS_DIR.iterdir()):
|
||||
if not vm_dir.is_dir() or not valid_vm_name(vm_dir.name):
|
||||
continue
|
||||
for item in sorted(vm_dir.glob("*.qcow2"), reverse=True):
|
||||
try:
|
||||
stat = item.stat()
|
||||
except OSError:
|
||||
continue
|
||||
backups.append({
|
||||
"vm": vm_dir.name,
|
||||
"name": item.name,
|
||||
"path": str(item),
|
||||
"size": f"{stat.st_size / 1024 / 1024:.0f} MB",
|
||||
"created": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"),
|
||||
})
|
||||
return backups
|
||||
|
||||
|
||||
def rotate_backups(vm_name: str, keep: int) -> list[str]:
|
||||
"""Удаляет старые бэкапы сверх keep. Возвращает имена удалённых файлов."""
|
||||
vm_dir = backup_dir_for(vm_name)
|
||||
if vm_dir is None or not vm_dir.exists() or keep < 1:
|
||||
return []
|
||||
files = sorted(vm_dir.glob("*.qcow2"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
removed = []
|
||||
for stale in files[keep:]:
|
||||
try:
|
||||
stale.unlink()
|
||||
removed.append(stale.name)
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
def load_schedule() -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(SCHEDULE_FILE.read_text())
|
||||
if isinstance(data, dict):
|
||||
return {
|
||||
"enabled": bool(data.get("enabled", False)),
|
||||
"vms": [name for name in data.get("vms", []) if isinstance(name, str) and valid_vm_name(name)],
|
||||
"keep": max(1, min(30, int(data.get("keep", DEFAULT_KEEP)))),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return {"enabled": False, "vms": [], "keep": DEFAULT_KEEP}
|
||||
|
||||
|
||||
def save_schedule(enabled: bool, vms: list[str], keep: int) -> dict[str, Any]:
|
||||
config = {
|
||||
"enabled": bool(enabled),
|
||||
"vms": sorted({name for name in vms if valid_vm_name(name)}),
|
||||
"keep": max(1, min(30, int(keep))),
|
||||
}
|
||||
with SCHEDULE_LOCK:
|
||||
SCHEDULE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = SCHEDULE_FILE.with_suffix(".json.tmp")
|
||||
tmp_path.write_text(json.dumps(config, ensure_ascii=False, indent=2))
|
||||
tmp_path.replace(SCHEDULE_FILE)
|
||||
return config
|
||||
|
||||
|
||||
def build_backup_script(vm_name: str, disk_path: str, target: Path) -> str:
|
||||
"""Скрипт бэкапа: пауза работающей VM на время копии, сжатый qcow2, resume.
|
||||
|
||||
Для выключенной VM virsh suspend/resume тихо провалятся — это нормально.
|
||||
"""
|
||||
part = f"{target}.part"
|
||||
return (
|
||||
"set -uo pipefail; "
|
||||
f"mkdir -p {target.parent}; "
|
||||
f"virsh suspend {vm_name} >/dev/null 2>&1 && RESUME=1 || RESUME=0; "
|
||||
f"qemu-img convert -p -c -O qcow2 {disk_path} {part}; STATUS=$?; "
|
||||
f"[ \"$RESUME\" = 1 ] && virsh resume {vm_name} >/dev/null 2>&1; "
|
||||
f"[ $STATUS -ne 0 ] && rm -f {part} && exit $STATUS; "
|
||||
f"mv {part} {target}"
|
||||
)
|
||||
|
||||
|
||||
def build_restore_script(disk_path: str, backup_file: Path) -> str:
|
||||
part = f"{disk_path}.restore"
|
||||
return (
|
||||
"set -euo pipefail; "
|
||||
f"qemu-img convert -p -O qcow2 {backup_file} {part}; "
|
||||
f"mv {part} {disk_path}"
|
||||
)
|
||||
|
||||
|
||||
# ---- CLI-режим для ночного systemd-таймера ----
|
||||
|
||||
def _log(message: str) -> None:
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with LOG_FILE.open("a", encoding="utf-8") as handle:
|
||||
handle.write(f"[{stamp}] {message}\n")
|
||||
|
||||
|
||||
def _run(cmd: list[str], timeout: int = 60) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
|
||||
|
||||
|
||||
def first_disk_of(vm_name: str) -> str | None:
|
||||
result = _run(["virsh", "domblklist", vm_name, "--details"])
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
for line in result.stdout.splitlines()[2:]:
|
||||
parts = line.split()
|
||||
if len(parts) >= 4 and parts[1] == "disk" and parts[3].endswith(".qcow2"):
|
||||
return parts[3]
|
||||
return None
|
||||
|
||||
|
||||
def run_scheduled_backups() -> int:
|
||||
config = load_schedule()
|
||||
if not config["enabled"]:
|
||||
_log("Расписание выключено — выходим.")
|
||||
return 0
|
||||
if not config["vms"]:
|
||||
_log("Расписание включено, но список VM пуст.")
|
||||
return 0
|
||||
failures = 0
|
||||
for vm_name in config["vms"]:
|
||||
disk = first_disk_of(vm_name)
|
||||
if not disk:
|
||||
_log(f"{vm_name}: qcow2-диск не найден, пропуск.")
|
||||
failures += 1
|
||||
continue
|
||||
target = new_backup_path(vm_name)
|
||||
if target is None:
|
||||
_log(f"{vm_name}: некорректное имя VM, пропуск.")
|
||||
failures += 1
|
||||
continue
|
||||
_log(f"{vm_name}: бэкап {disk} → {target}")
|
||||
result = subprocess.run(["bash", "-c", build_backup_script(vm_name, disk, target)], capture_output=True, text=True, check=False)
|
||||
if result.returncode == 0:
|
||||
removed = rotate_backups(vm_name, config["keep"])
|
||||
_log(f"{vm_name}: готово" + (f", ротация удалила: {', '.join(removed)}" if removed else ""))
|
||||
else:
|
||||
failures += 1
|
||||
_log(f"{vm_name}: ошибка бэкапа (код {result.returncode}): {result.stderr.strip()[:400]}")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--scheduled" in sys.argv:
|
||||
sys.exit(run_scheduled_backups())
|
||||
print("Использование: backup_core.py --scheduled", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
@@ -1,91 +0,0 @@
|
||||
"""Catalog of ready-to-run cloud images for the quick VM flow.
|
||||
|
||||
Each entry points to the vendor's official cloud image. Images boot in seconds
|
||||
and are configured through cloud-init (user, password, SSH key) — no installer.
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
# osinfo ids must exist in osinfo-db shipped with Ubuntu 24.04+/Debian 13.
|
||||
CLOUD_IMAGES: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": "ubuntu-26.04",
|
||||
"title": "Ubuntu Server 26.04 LTS",
|
||||
"vendor": "Ubuntu",
|
||||
"hint": "Самая свежая LTS, поддержка до 2031 года. Рекомендуем.",
|
||||
"osinfo": "ubuntu24.04",
|
||||
"recommended": True,
|
||||
"login": "ubuntu",
|
||||
"arches": {
|
||||
"x86_64": {"url": "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img", "sums": "https://cloud-images.ubuntu.com/resolute/current/SHA256SUMS", "algo": "sha256"},
|
||||
"aarch64": {"url": "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-arm64.img", "sums": "https://cloud-images.ubuntu.com/resolute/current/SHA256SUMS", "algo": "sha256"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "ubuntu-24.04",
|
||||
"title": "Ubuntu Server 24.04 LTS",
|
||||
"vendor": "Ubuntu",
|
||||
"hint": "Проверенная LTS, поддержка до 2029 года.",
|
||||
"osinfo": "ubuntu24.04",
|
||||
"login": "ubuntu",
|
||||
"arches": {
|
||||
"x86_64": {"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", "sums": "https://cloud-images.ubuntu.com/noble/current/SHA256SUMS", "algo": "sha256"},
|
||||
"aarch64": {"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img", "sums": "https://cloud-images.ubuntu.com/noble/current/SHA256SUMS", "algo": "sha256"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "debian-13",
|
||||
"title": "Debian 13",
|
||||
"vendor": "Debian",
|
||||
"hint": "Лёгкий и стабильный. Хорош для домашних сервисов.",
|
||||
"osinfo": "debian13",
|
||||
"login": "debian",
|
||||
"arches": {
|
||||
"x86_64": {"url": "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2", "sums": "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS", "algo": "sha512"},
|
||||
"aarch64": {"url": "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-arm64.qcow2", "sums": "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS", "algo": "sha512"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "alpine-3.23",
|
||||
"title": "Alpine Linux 3.23",
|
||||
"vendor": "Alpine",
|
||||
"hint": "Очень маленький: 200 МБ на диске, стартует мгновенно.",
|
||||
"osinfo": "alpinelinux3.21",
|
||||
"login": "alpine",
|
||||
"arches": {
|
||||
"x86_64": {"url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2", "sums": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512", "algo": "sha512", "uefi": True},
|
||||
"aarch64": {"url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-aarch64-uefi-cloudinit-r0.qcow2", "sums": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-aarch64-uefi-cloudinit-r0.qcow2.sha512", "algo": "sha512", "uefi": True},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# OS families for the "install from ISO" flow. virt-install picks the right
|
||||
# virtual hardware from osinfo: Windows gets SATA/e1000e (works without extra
|
||||
# drivers), win11 additionally gets UEFI + TPM.
|
||||
OS_TYPES: list[dict[str, Any]] = [
|
||||
{"id": "linux", "title": "Linux", "hint": "Ubuntu, Debian, Fedora и другие", "osinfo": "detect=on,require=off"},
|
||||
{"id": "windows11", "title": "Windows 11 / Server 2025", "hint": "Нужны UEFI и TPM — включим сами", "osinfo": "win11"},
|
||||
{"id": "windows10", "title": "Windows 10 / Server 2019–2022", "hint": "Классическая загрузка", "osinfo": "win10"},
|
||||
{"id": "other", "title": "Другая система", "hint": "FreeBSD, старые ОС, экзотика", "osinfo": "generic"},
|
||||
]
|
||||
|
||||
|
||||
def cloud_image(image_id: str) -> dict[str, Any] | None:
|
||||
return next((item for item in CLOUD_IMAGES if item["id"] == image_id), None)
|
||||
|
||||
|
||||
def os_type(type_id: str) -> dict[str, Any]:
|
||||
return next((item for item in OS_TYPES if item["id"] == type_id), OS_TYPES[0])
|
||||
|
||||
|
||||
def image_filename(entry: dict[str, Any], arch: str) -> str:
|
||||
url = entry["arches"][arch]["url"]
|
||||
return url.rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def entry_for_filename(name: str) -> dict[str, Any] | None:
|
||||
"""Catalog entry whose download has this file name (any architecture)."""
|
||||
for entry in CLOUD_IMAGES:
|
||||
for arch in entry["arches"].values():
|
||||
if arch["url"].rsplit("/", 1)[-1] == name:
|
||||
return entry
|
||||
return None
|
||||