Версия проекта была обновлена до 0.10.0: тесты, CI и документация образа
- tests/: 55 тестов web-панели, авторизации, сети и обновлений, все страницы рендерятся на замоканных virsh/systemctl; - GitHub Actions: pyflakes + pytest на Python 3.11–3.13, shellcheck, сборка ISO amd64/arm64 по тегу v* или вручную; - Makefile: make check / make test / make iso; - docs/IMAGE.md и README: установка с ISO, продакшен-настройки, разработка; - updates/versions.json: описание версии 0.10.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
@@ -0,0 +1,35 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Tests (Python ${{ matrix.python }})
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
# 3.12: Ubuntu 24.04 nodes; 3.13: Debian 13 (no crypt/spwd modules).
|
||||||
|
python: ["3.11", "3.12", "3.13"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python }}
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: web/requirements.txt
|
||||||
|
- run: pip install -r web/requirements.txt -r requirements-dev.txt
|
||||||
|
- run: make lint-python test
|
||||||
|
|
||||||
|
shell:
|
||||||
|
name: Shell scripts
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: make lint-shell
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: Build ISO image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
arch:
|
||||||
|
description: "Architecture"
|
||||||
|
type: choice
|
||||||
|
options: [amd64, arm64, both]
|
||||||
|
default: amd64
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
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
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .cache/iso
|
||||||
|
key: ubuntu-iso-${{ matrix.arch }}-${{ hashFiles('image/build-iso.sh') }}
|
||||||
|
restore-keys: ubuntu-iso-${{ matrix.arch }}-
|
||||||
|
- run: make iso ARCH=${{ matrix.arch }}
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
PYTHON ?= python3
|
||||||
|
ARCH ?= amd64
|
||||||
|
ISO_ARGS ?=
|
||||||
|
SHELL_SCRIPTS := $(wildcard *.sh scripts/*.sh image/*.sh)
|
||||||
|
|
||||||
|
.PHONY: help test lint lint-python lint-shell check iso clean
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "make test - run the web panel test suite"
|
||||||
|
@echo "make lint - pyflakes + shellcheck"
|
||||||
|
@echo "make check - lint + test"
|
||||||
|
@echo "make iso - build the installer ISO (ARCH=amd64|arm64, ISO_ARGS='--unattended ...')"
|
||||||
|
@echo "make clean - remove build output"
|
||||||
|
|
||||||
|
test:
|
||||||
|
$(PYTHON) -m pytest -q
|
||||||
|
|
||||||
|
lint-python:
|
||||||
|
$(PYTHON) -m pyflakes web tests
|
||||||
|
|
||||||
|
lint-shell:
|
||||||
|
@for f in $(SHELL_SCRIPTS); do bash -n "$$f" || exit 1; done
|
||||||
|
shellcheck -S error $(SHELL_SCRIPTS)
|
||||||
|
|
||||||
|
lint: lint-python lint-shell
|
||||||
|
|
||||||
|
check: lint test
|
||||||
|
|
||||||
|
iso:
|
||||||
|
./image/build-iso.sh --arch $(ARCH) $(ISO_ARGS)
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf dist .pytest_cache
|
||||||
|
find . -name __pycache__ -type d -prune -exec rm -rf {} +
|
||||||
+57
-4
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Цель проекта — собрать понятную, компактную и расширяемую систему для управления виртуальными машинами, ISO-образами, NAT/bridge-сетью, пробросом портов, диагностикой, web-console/noVNC, backup/snapshot-функциями и будущей кластеризацией.
|
Цель проекта — собрать понятную, компактную и расширяемую систему для управления виртуальными машинами, ISO-образами, NAT/bridge-сетью, пробросом портов, диагностикой, web-console/noVNC, backup/snapshot-функциями и будущей кластеризацией.
|
||||||
|
|
||||||
> Текущий статус: ранняя стадия разработки. Уже есть one-command установка, автоопределение профиля хоста, web-панель, авторизация через Linux-пользователя, ISO-менеджер, создание VM из интерфейса, журнал операций, NAT Router для VPS/ARM edge nodes и port forwarding.
|
> Текущий статус: версия 0.10 — код панели покрыт тестами, есть установочный ISO-образ. Уже есть one-command установка, автоопределение профиля хоста, web-панель, авторизация через Linux-пользователя, ISO-менеджер, создание VM из интерфейса, журнал операций, NAT Router для VPS/ARM edge nodes и port forwarding.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -35,6 +35,19 @@ generic-arm64 # другая ARM64-плата
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Установка с ISO-образа
|
||||||
|
|
||||||
|
Для установки на «голое» железо собирается загрузочный ISO на базе Ubuntu Server 24.04: установщик спрашивает только сеть, диск и пользователя, а при первой загрузке нода настраивается сама.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make iso # dist/virtuality-<версия>-ubuntu-24.04-amd64.iso
|
||||||
|
make iso ARCH=arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
Подробно: [`docs/IMAGE.md`](docs/IMAGE.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Быстрая установка одной командой
|
## Быстрая установка одной командой
|
||||||
|
|
||||||
Одна команда работает и под `root`, и под обычным пользователем с `sudo`:
|
Одна команда работает и под `root`, и под обычным пользователем с `sudo`:
|
||||||
@@ -186,6 +199,8 @@ curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/instal
|
|||||||
/var/lib/virtuality/backups # backups
|
/var/lib/virtuality/backups # backups
|
||||||
/var/log/virtuality # логи установки и диагностики
|
/var/log/virtuality # логи установки и диагностики
|
||||||
/var/log/virtuality/operations # JSON/log фоновых операций
|
/var/log/virtuality/operations # JSON/log фоновых операций
|
||||||
|
/var/lib/virtuality/config/web.env # настройки ноды: порт, пользователь, автообновление
|
||||||
|
/var/lib/virtuality/disk-images # загруженные образы дисков
|
||||||
/etc/virtuality/nftables/virtuality.nft # nftables-правила Virtuality
|
/etc/virtuality/nftables/virtuality.nft # nftables-правила Virtuality
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -375,6 +390,8 @@ sudo virsh pool-refresh virtuality-iso
|
|||||||
|
|
||||||
## Обновление
|
## Обновление
|
||||||
|
|
||||||
|
Из web-панели: раздел `/update`. Вручную:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/virtuality/source
|
cd /opt/virtuality/source
|
||||||
sudo git pull
|
sudo git pull
|
||||||
@@ -382,6 +399,8 @@ sudo bash scripts/install_web_panel.sh
|
|||||||
sudo systemctl restart virtuality-web
|
sudo systemctl restart virtuality-web
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Порт панели, пользователь и режим автообновления сохраняются в `/var/lib/virtuality/config/web.env` и не сбрасываются при обновлениях.
|
||||||
|
|
||||||
Полная повторная установка компонентов ноды:
|
Полная повторная установка компонентов ноды:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -392,6 +411,41 @@ sudo bash scripts/install_web_panel.sh
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Продакшен-настройки
|
||||||
|
|
||||||
|
Настройки ноды — `/var/lib/virtuality/config/web.env`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VIRTUALITY_WEB_PORT=8088 # порт панели
|
||||||
|
VIRTUALITY_WEB_HOST=0.0.0.0 # 127.0.0.1, если панель стоит за reverse proxy
|
||||||
|
VIRTUALITY_AUTH_USER=admin # Linux-пользователь для входа
|
||||||
|
VIRTUALITY_AUTO_UPDATE=1 # 0 — отключить ночное автообновление с GitHub
|
||||||
|
VIRTUALITY_COOKIE_SECURE=0 # 1 — cookie только по HTTPS (за reverse proxy с TLS)
|
||||||
|
```
|
||||||
|
|
||||||
|
После изменения: `cd /opt/virtuality/source && sudo bash scripts/install_web_panel.sh`. Любой параметр можно передать и переменной окружения при установке, например `VIRTUALITY_AUTO_UPDATE=0`.
|
||||||
|
|
||||||
|
Рекомендации:
|
||||||
|
|
||||||
|
- для продакшена отключите автообновление (`VIRTUALITY_AUTO_UPDATE=0`) и обновляйтесь вручную через `/update` после проверки версии;
|
||||||
|
- открывайте панель наружу только через HTTPS reverse proxy (nginx/Caddy) с `VIRTUALITY_WEB_HOST=127.0.0.1` и `VIRTUALITY_COOKIE_SECURE=1`;
|
||||||
|
- мониторинг: `GET /healthz` без авторизации возвращает `{"ok": true, "version": "..."}`;
|
||||||
|
- после 5 неверных паролей вход с этого адреса блокируется на 5 минут; сессия живёт 12 часов;
|
||||||
|
- правила проброса портов восстанавливаются после перезагрузки сервисом `virtuality-network.service`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r web/requirements.txt -r requirements-dev.txt
|
||||||
|
make check # pyflakes + shellcheck + pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Код панели лежит в `web/` целиком и устанавливается как есть. Патчить `app.py` во время установки больше не нужно: изменения вносятся прямо в `web/` и покрываются тестами в `tests/`. GitHub Actions запускает проверки на Python 3.11–3.13 и собирает ISO по тегу `v*`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Полезные команды
|
## Полезные команды
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -416,14 +470,13 @@ journalctl -u virtuality-web -f
|
|||||||
|
|
||||||
- ARM64 cloud-image templates;
|
- ARM64 cloud-image templates;
|
||||||
- cloud-init для быстрых VM;
|
- cloud-init для быстрых VM;
|
||||||
- web-console/noVNC;
|
|
||||||
- управление storage pools;
|
- управление storage pools;
|
||||||
- backup/snapshot manager;
|
- backup/snapshot manager;
|
||||||
- сетевой менеджер bridge/VLAN;
|
- сетевой менеджер bridge/VLAN;
|
||||||
- роли и права пользователей;
|
- роли и права пользователей;
|
||||||
- журнал событий;
|
- журнал событий;
|
||||||
- автообновление;
|
- кластеризация;
|
||||||
- кластеризация.
|
- готовые образы для Raspberry Pi / Orange Pi 5.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.9.6
|
0.10.0
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Установочный образ Virtuality
|
||||||
|
|
||||||
|
Virtuality можно поставить двумя способами:
|
||||||
|
|
||||||
|
1. **Скрипт на готовый сервер** — `curl ... install.sh | bash` (см. README).
|
||||||
|
2. **Установочный ISO** — загрузочный образ «с нуля», как у Proxmox: записываете на флешку, загружаетесь, отвечаете на 3 экрана, после перезагрузки получаете готовую ноду.
|
||||||
|
|
||||||
|
Этот документ про второй способ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что внутри образа
|
||||||
|
|
||||||
|
Образ собирается из официального **Ubuntu Server 24.04 LTS live-server ISO** (amd64 или arm64) и дополняется:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/autoinstall.yaml # сценарий установщика Ubuntu (subiquity autoinstall)
|
||||||
|
/virtuality/source # исходники Virtuality (git-репозиторий нужной версии)
|
||||||
|
/virtuality/source/wheels # Python-колёса web-панели для офлайн-установки
|
||||||
|
/virtuality/image.env # параметры сборки: порт панели, автообновление, пользователь
|
||||||
|
/virtuality/virtuality-firstboot.service
|
||||||
|
/boot/grub/grub.cfg # меню «Install Virtuality X.Y.Z»
|
||||||
|
```
|
||||||
|
|
||||||
|
Загрузочные записи BIOS и UEFI берутся из оригинального ISO без изменений.
|
||||||
|
|
||||||
|
## Как проходит установка
|
||||||
|
|
||||||
|
1. Загрузка с ISO → пункт **Install Virtuality**.
|
||||||
|
2. Установщик Ubuntu спрашивает только **сеть**, **диск** и **пользователя** (имя, пароль, hostname). Язык, раскладка, SSH-сервер и остальное заданы автоматически.
|
||||||
|
3. Установщик копирует Virtuality в `/opt/virtuality/source` и включает `virtuality-firstboot.service`.
|
||||||
|
4. После перезагрузки `virtuality-firstboot` (нужен интернет) ставит KVM/QEMU/libvirt, Cockpit, web-панель, `vhealth` и консольный dashboard. Обычно это 5–15 минут. Если сети нет или apt упал, попытка повторяется каждую минуту до успеха.
|
||||||
|
5. Готово:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Web-панель: http://SERVER_IP:8088 (логин — пользователь, созданный в установщике)
|
||||||
|
Cockpit: https://SERVER_IP:9090
|
||||||
|
```
|
||||||
|
|
||||||
|
Адрес панели также показывается на экране входа консоли. Ход первой настройки:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo journalctl -fu virtuality-firstboot
|
||||||
|
cat /var/log/virtuality/firstboot.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сборка ISO
|
||||||
|
|
||||||
|
Нужна Linux-машина (root не нужен), ~10 GB свободного места и пакеты:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y xorriso git curl python3-pip
|
||||||
|
```
|
||||||
|
|
||||||
|
Сборка:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/viktor138irk/virtuality.git
|
||||||
|
cd virtuality
|
||||||
|
make iso # amd64
|
||||||
|
make iso ARCH=arm64 # ARM64-серверы с UEFI
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт сам скачает последний Ubuntu 24.04.x live-server ISO, проверит SHA256 и положит результат в `dist/`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/virtuality-0.10.0-ubuntu-24.04-amd64.iso
|
||||||
|
dist/virtuality-0.10.0-ubuntu-24.04-amd64.iso.sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
Скачанный ISO Ubuntu кешируется в `.cache/iso`. Уже скачанный образ можно передать явно:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./image/build-iso.sh --ubuntu-iso ~/Downloads/ubuntu-24.04.5-live-server-amd64.iso
|
||||||
|
```
|
||||||
|
|
||||||
|
### Параметры
|
||||||
|
|
||||||
|
```text
|
||||||
|
--arch amd64|arm64 архитектура
|
||||||
|
--ref GIT_REF какую версию Virtuality положить в образ (по умолчанию HEAD)
|
||||||
|
--web-port PORT порт web-панели (по умолчанию 8088)
|
||||||
|
--no-auto-update не включать ночное автообновление с GitHub
|
||||||
|
--locale / --keyboard / --timezone
|
||||||
|
--no-wheels не класть Python-колёса (тогда первая загрузка скачает их из PyPI)
|
||||||
|
--repo-url URL откуда нода будет получать обновления
|
||||||
|
```
|
||||||
|
|
||||||
|
Через make параметры передаются так:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make iso ISO_ARGS="--web-port 8089 --no-auto-update --timezone Europe/Moscow"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Полностью автоматическая установка
|
||||||
|
|
||||||
|
Для массовой раскатки можно собрать образ, который не задаёт вопросов:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make iso ISO_ARGS="--unattended --username admin --password-hash '$(openssl passwd -6)' --hostname node01"
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ Такой образ загружается в установку сам (через 10 секунд в меню) и **без подтверждения стирает самый большой диск** машины. Файл получает суффикс `-unattended`. Не оставляйте такую флешку в серверах.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Запись на флешку
|
||||||
|
|
||||||
|
Linux / macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dd if=dist/virtuality-0.10.0-ubuntu-24.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows: Rufus (режим DD) или balenaEtcher.
|
||||||
|
|
||||||
|
Проверка целостности:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dist && sha256sum -c virtuality-0.10.0-ubuntu-24.04-amd64.iso.sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сборка в GitHub Actions
|
||||||
|
|
||||||
|
Workflow `.github/workflows/image.yml` собирает ISO:
|
||||||
|
|
||||||
|
- автоматически при пуше тега `v*` (amd64 и arm64);
|
||||||
|
- вручную: Actions → **Build ISO image** → Run workflow.
|
||||||
|
|
||||||
|
Образы Ubuntu Server больше лимита GitHub Releases (2 GB на файл), поэтому ISO публикуются как **artifacts** workflow и хранятся 30 дней.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Целевая ОС образа — Ubuntu Server 24.04 LTS.
|
||||||
|
- Для первой загрузки нужен интернет: пакеты KVM/libvirt/Cockpit ставятся из репозиториев Ubuntu. Python-зависимости панели уже лежат в образе.
|
||||||
|
- ARM64-образ рассчитан на серверы с UEFI. Raspberry Pi и Orange Pi 5 грузятся иначе — для них используйте их штатный образ Ubuntu Server и `install.sh`.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
addopts = -p no:cacheprovider
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Development and CI tools
|
||||||
|
pytest==8.3.4
|
||||||
|
httpx==0.28.1
|
||||||
|
pyflakes==3.2.0
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
WEB_DIR = ROOT / "web"
|
||||||
|
sys.path.insert(0, str(WEB_DIR))
|
||||||
|
|
||||||
|
os.environ.setdefault("VIRTUALITY_AUTH_USER", "tester")
|
||||||
|
os.environ.setdefault("VIRTUALITY_SESSION_SECRET", "test-secret-" + "0" * 32)
|
||||||
|
|
||||||
|
DOMINFO = """Id: 1
|
||||||
|
Name: web01
|
||||||
|
UUID: 6c1b3c1e-0000-0000-0000-000000000001
|
||||||
|
OS Type: hvm
|
||||||
|
State: running
|
||||||
|
CPU(s): 2
|
||||||
|
Max memory: 2097152 KiB
|
||||||
|
Used memory: 2097152 KiB
|
||||||
|
Persistent: yes
|
||||||
|
Autostart: enable
|
||||||
|
"""
|
||||||
|
|
||||||
|
DUMPXML = """<domain type='kvm'>
|
||||||
|
<name>web01</name>
|
||||||
|
<memory unit='KiB'>2097152</memory>
|
||||||
|
<vcpu placement='static'>2</vcpu>
|
||||||
|
<os><type arch='x86_64' machine='pc-q35'>hvm</type><boot dev='hd'/></os>
|
||||||
|
<devices>
|
||||||
|
<disk type='file' device='disk'><target dev='vda' bus='virtio'/></disk>
|
||||||
|
</devices>
|
||||||
|
</domain>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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 fake_run_cmd(cmd, timeout=12, **_kwargs):
|
||||||
|
joined = " ".join(cmd)
|
||||||
|
if cmd[:2] == ["virsh", "list"]:
|
||||||
|
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)
|
||||||
|
if cmd[:2] == ["virsh", "domstate"]:
|
||||||
|
return fake_result("running")
|
||||||
|
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", "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()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def data_dirs(tmp_path, monkeypatch):
|
||||||
|
import app
|
||||||
|
import host_profile
|
||||||
|
import network_core
|
||||||
|
import update_core
|
||||||
|
|
||||||
|
dirs = {
|
||||||
|
"iso": tmp_path / "iso",
|
||||||
|
"images": tmp_path / "images",
|
||||||
|
"disk_images": tmp_path / "disk-images",
|
||||||
|
"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()
|
||||||
|
monkeypatch.setattr(app, "ISO_DIR", dirs["iso"])
|
||||||
|
monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"])
|
||||||
|
monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"])
|
||||||
|
monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"])
|
||||||
|
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, "NAT_XML_FILE", dirs["network"] / "virtuality-nat.xml")
|
||||||
|
monkeypatch.setattr(network_core, "NFT_FILE", dirs["nft"] / "virtuality.nft")
|
||||||
|
monkeypatch.setattr(network_core, "enable_ip_forward", lambda: None)
|
||||||
|
monkeypatch.setattr(network_core, "disable_rp_filter", lambda: None)
|
||||||
|
monkeypatch.setattr(host_profile, "PROFILE_FILE", dirs["config"] / "host_profile.json")
|
||||||
|
monkeypatch.setattr(update_core, "STATE_DIR", dirs["update"])
|
||||||
|
monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json")
|
||||||
|
monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log")
|
||||||
|
monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT)
|
||||||
|
for module in (app, network_core, host_profile):
|
||||||
|
monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
|
||||||
|
return dirs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(data_dirs):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import app
|
||||||
|
|
||||||
|
app.login_throttle = app.auth.LoginThrottle()
|
||||||
|
return TestClient(app.app, base_url="http://panel.test")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def logged_in(client, monkeypatch):
|
||||||
|
import app
|
||||||
|
|
||||||
|
monkeypatch.setattr(app.auth, "verify_password", lambda user, password: password == "correct")
|
||||||
|
response = client.post("/login", data={"username": "tester", "password": "correct"}, follow_redirects=False)
|
||||||
|
assert response.status_code == 303
|
||||||
|
return client
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import app
|
||||||
|
|
||||||
|
PAGES = ["/", "/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):
|
||||||
|
assert "autostart: enabled" in logged_in.get("/vm/web01").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 "network=virtuality-nat,model=virtio" in cmd
|
||||||
|
assert captured["operation"]["boot_order"] == "cdrom_disk"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_create_validation_error(logged_in):
|
||||||
|
form = {"name": "x", "memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "network_mode": "nat"}
|
||||||
|
response = logged_in.post("/vm/create", data=form)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_iso_upload_and_delete(logged_in, data_dirs, monkeypatch):
|
||||||
|
response = logged_in.post("/iso/upload", files={"iso_file": ("My Distro 1.0.iso", b"data", "application/octet-stream")}, follow_redirects=False)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert (data_dirs["iso"] / "My-Distro-1.0.iso").read_bytes() == b"data"
|
||||||
|
logged_in.post("/iso/My-Distro-1.0.iso/delete", follow_redirects=False)
|
||||||
|
assert not (data_dirs["iso"] / "My-Distro-1.0.iso").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_iso_upload_rejects_other_types(logged_in):
|
||||||
|
response = logged_in.post("/iso/upload", files={"iso_file": ("evil.sh", b"#!/bin/sh", "text/plain")})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_iso_delete_path_traversal(logged_in, data_dirs, tmp_path):
|
||||||
|
victim = tmp_path / "victim.iso"
|
||||||
|
victim.write_bytes(b"keep")
|
||||||
|
logged_in.post("/iso/..%2Fvictim.iso/delete", follow_redirects=False)
|
||||||
|
assert victim.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_port_forward_add(logged_in, data_dirs):
|
||||||
|
form = {"vm_name": "web01", "guest_ip": "192.168.100.51", "external_port": "2222", "guest_port": "22", "protocol": "tcp"}
|
||||||
|
response = logged_in.post("/network/forward/add", data=form, follow_redirects=False)
|
||||||
|
assert response.status_code == 303
|
||||||
|
rules = (data_dirs["nft"] / "virtuality.nft").read_text()
|
||||||
|
assert "tcp dport 2222 dnat to 192.168.100.51:22" in rules
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_requires_auth(client):
|
||||||
|
for path in ("/api/health", "/api/operations", "/live/status", "/api/logs"):
|
||||||
|
assert client.get(path).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_status(logged_in):
|
||||||
|
data = logged_in.get("/live/status").json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert {vm["name"] for vm in data["vms"]} == {"web01", "db01"}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
import auth
|
||||||
|
|
||||||
|
# SHA-512 crypt hash of "s3cret" with salt "virtualitysalt".
|
||||||
|
SHA512_HASH = "$6$virtualitysalt$"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def password_hash():
|
||||||
|
computed = auth.crypt_hash("s3cret", SHA512_HASH)
|
||||||
|
assert computed and computed.startswith(SHA512_HASH)
|
||||||
|
return computed
|
||||||
|
|
||||||
|
|
||||||
|
def write_shadow(tmp_path, monkeypatch, entries):
|
||||||
|
shadow = tmp_path / "shadow"
|
||||||
|
shadow.write_text("".join(f"{user}:{pw}:19000:0:99999:7:::\n" for user, pw in entries))
|
||||||
|
monkeypatch.setattr(auth, "SHADOW_FILE", shadow)
|
||||||
|
monkeypatch.setattr(auth, "_stdlib_spwd", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_libcrypt_fallback_matches(password_hash, monkeypatch):
|
||||||
|
monkeypatch.setattr(auth, "_stdlib_crypt", None)
|
||||||
|
assert auth.crypt_hash("s3cret", password_hash) == password_hash
|
||||||
|
assert auth.crypt_hash("wrong", password_hash) != password_hash
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_password_with_shadow_file(password_hash, tmp_path, monkeypatch):
|
||||||
|
write_shadow(tmp_path, monkeypatch, [("root", "*"), ("admin", password_hash), ("locked", "!" + password_hash)])
|
||||||
|
monkeypatch.setattr(auth, "_stdlib_crypt", None)
|
||||||
|
assert auth.verify_password("admin", "s3cret")
|
||||||
|
assert not auth.verify_password("admin", "wrong")
|
||||||
|
assert not auth.verify_password("admin", "")
|
||||||
|
assert not auth.verify_password("root", "anything")
|
||||||
|
assert not auth.verify_password("locked", "s3cret")
|
||||||
|
assert not auth.verify_password("missing", "s3cret")
|
||||||
|
|
||||||
|
|
||||||
|
def test_throttle_locks_and_resets():
|
||||||
|
throttle = auth.LoginThrottle(max_failures=3, window_seconds=60, lockout_seconds=60)
|
||||||
|
for _ in range(2):
|
||||||
|
throttle.record_failure("1.2.3.4")
|
||||||
|
assert throttle.retry_after("1.2.3.4") == 0
|
||||||
|
throttle.record_failure("1.2.3.4")
|
||||||
|
assert throttle.retry_after("1.2.3.4") > 0
|
||||||
|
assert throttle.retry_after("5.6.7.8") == 0
|
||||||
|
throttle.record_success("1.2.3.4")
|
||||||
|
assert throttle.retry_after("1.2.3.4") == 0
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import network_core
|
||||||
|
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"]]
|
||||||
@@ -1,5 +1,27 @@
|
|||||||
{
|
{
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.10.0",
|
||||||
|
"date": "2026-09-25",
|
||||||
|
"title": "Система была подготовлена к продакшену и сборке образа",
|
||||||
|
"summary": "Web-панель больше не собирается патчами во время установки: итоговый код хранится в репозитории и покрыт тестами. Исправлены ошибки создания VM и отображения автозапуска, усилена безопасность входа, настройки ноды сохраняются между обновлениями. Добавлена сборка установочного ISO-образа Virtuality на базе Ubuntu Server 24.04.",
|
||||||
|
"features": [
|
||||||
|
"Исправлено падение создания VM из-за отсутствующего импорта platform",
|
||||||
|
"На странице VM автозапуск теперь показывается корректно",
|
||||||
|
"Сообщения об операциях с VM корректно кодируются в адресе страницы",
|
||||||
|
"Проверка пароля работает на Python 3.13+, где удалены модули crypt и spwd",
|
||||||
|
"Добавлены ограничение попыток входа, срок жизни сессии, защита от cross-origin POST и заголовки безопасности",
|
||||||
|
"Добавлен публичный /healthz для мониторинга",
|
||||||
|
"Правила проброса портов восстанавливаются после перезагрузки сервера",
|
||||||
|
"Порт, пользователь панели и режим автообновления сохраняются в /var/lib/virtuality/config/web.env и больше не сбрасываются при обновлении",
|
||||||
|
"Обновление из панели больше не прерывается перезапуском virtuality-web",
|
||||||
|
"Автообновление можно отключить: VIRTUALITY_AUTO_UPDATE=0",
|
||||||
|
"Python-зависимости закреплены полностью и могут ставиться офлайн",
|
||||||
|
"Добавлена сборка установочного ISO: image/build-iso.sh",
|
||||||
|
"Добавлены автотесты и GitHub Actions",
|
||||||
|
"Версия обновлена до 0.10.0"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.61",
|
"version": "0.8.61",
|
||||||
"date": "2026-05-07",
|
"date": "2026-05-07",
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user