2 Коммитов
Автор SHA1 Сообщение Дата
viktor ca5d49266c Версия была обновлена до 0.12.5
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 17:58:07 +09:00
viktor ec9428c2eb Удалить машину можно в любом состоянии: работающая выключается, пока идёт задача удаление ждёт, пробросы портов машины удаляются вместе с ней 2026-09-27 17:58:07 +09:00
9 изменённых файлов: 88 добавлений и 18 удалений
+3 -3
Просмотреть файл
@@ -2,7 +2,7 @@
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка. **Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
Текущая версия: **0.12.4** (файл `VERSION`, журнал изменений — `updates/versions.json`). Текущая версия: **0.12.5** (файл `VERSION`, журнал изменений — `updates/versions.json`).
Что умеет: Что умеет:
@@ -76,7 +76,7 @@
sudo apt install -y xorriso git curl python3-pip sudo apt install -y xorriso git curl python3-pip
git clone https://git.stackworks.ru/viktor/virtuality.git git clone https://git.stackworks.ru/viktor/virtuality.git
cd virtuality cd virtuality
make iso # dist/virtuality-0.12.4-ubuntu-26.04-amd64.iso make iso # dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso
``` ```
Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md). Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md).
@@ -168,7 +168,7 @@ sudo VIRTUALITY_AUTH_USER=admin bash scripts/install_web_panel.sh # панел
- Создание из установочного ISO: три шага — название и система, мощность (готовые профили «Мини», «Стандарт», «Мощная» или свои значения), сеть. - Создание из установочного ISO: три шага — название и система, мощность (готовые профили «Мини», «Стандарт», «Мощная» или свои значения), сеть.
- Тип системы для ISO: Linux, **Windows 11 / Server 2025** (UEFI и TPM 2.0 включаются сами), Windows 10 / Server 2019–2022, другая. Для Windows подбираются диск SATA и сетевая карта Intel — драйверы не нужны. - Тип системы для ISO: Linux, **Windows 11 / Server 2025** (UEFI и TPM 2.0 включаются сами), Windows 10 / Server 2019–2022, другая. Для Windows подбираются диск SATA и сетевая карта Intel — драйверы не нужны.
- Создание из готового образа за минуту: образ конвертируется в qcow2, диск увеличивается до нужного размера, а cloud-init настраивает первый вход — пользователя, пароль и/или SSH-ключ. Пароль хранится только как хеш. При первой загрузке в машину ставится гостевой агент, поэтому панель видит её адрес в любой сети и выключает её корректно. - Создание из готового образа за минуту: образ конвертируется в qcow2, диск увеличивается до нужного размера, а cloud-init настраивает первый вход — пользователя, пароль и/или SSH-ключ. Пароль хранится только как хеш. При первой загрузке в машину ставится гостевой агент, поэтому панель видит её адрес в любой сети и выключает её корректно.
- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками. - Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками и пробросами портов (работающая машина сначала выключается).
- Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места). - Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места).
- Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ). - Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ).
- **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти. - **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти.
+1 -1
Просмотреть файл
@@ -1 +1 @@
0.12.4 0.12.5
+7 -7
Просмотреть файл
@@ -44,8 +44,8 @@ make iso ARCH=arm64 # ARM64-серверы с UEFI
`image/build-iso.sh` сам находит последний Ubuntu 26.04.x live-server ISO, скачивает его (около 3 ГБ), проверяет SHA256 и кладёт результат в `dist/`: `image/build-iso.sh` сам находит последний Ubuntu 26.04.x live-server ISO, скачивает его (около 3 ГБ), проверяет SHA256 и кладёт результат в `dist/`:
```text ```text
dist/virtuality-0.12.4-ubuntu-26.04-amd64.iso dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso
dist/virtuality-0.12.4-ubuntu-26.04-amd64.iso.sha256 dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso.sha256
``` ```
Скачанный ISO Ubuntu кешируется в `.cache/iso` (или в `VIRTUALITY_ISO_CACHE`). Уже скачанный образ можно передать явно: Скачанный ISO Ubuntu кешируется в `.cache/iso` (или в `VIRTUALITY_ISO_CACHE`). Уже скачанный образ можно передать явно:
@@ -109,7 +109,7 @@ make iso ISO_ARGS="--unattended --username sysadmin --password-hash '$(openssl p
## Что спрашивает установщик ## Что спрашивает установщик
Меню загрузки: **Установить Virtuality 0.12.4** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти. Меню загрузки: **Установить Virtuality 0.12.5** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
Сценарий `image/autoinstall.yaml` оставляет интерактивными только три экрана установщика Ubuntu: Сценарий `image/autoinstall.yaml` оставляет интерактивными только три экрана установщика Ubuntu:
@@ -161,14 +161,14 @@ cat /var/log/virtuality/firstboot.log
Сверьте контрольную сумму скачанного или собранного образа: Сверьте контрольную сумму скачанного или собранного образа:
```bash ```bash
sha256sum -c virtuality-0.12.4-ubuntu-26.04-amd64.iso.sha256 sha256sum -c virtuality-0.12.5-ubuntu-26.04-amd64.iso.sha256
``` ```
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI): Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
```bash ```bash
xorriso -indev virtuality-0.12.4-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI 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.4-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml xorriso -osirrox on -indev virtuality-0.12.5-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
``` ```
--- ---
@@ -182,7 +182,7 @@ xorriso -osirrox on -indev virtuality-0.12.4-ubuntu-26.04-amd64.iso -extract /au
- **Linux**: balenaEtcher или `dd`, где `/dev/sdX` — устройство флешки (проверьте через `lsblk`): - **Linux**: balenaEtcher или `dd`, где `/dev/sdX` — устройство флешки (проверьте через `lsblk`):
```bash ```bash
sudo dd if=dist/virtuality-0.12.4-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync 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**. Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**.
+41 -1
Просмотреть файл
@@ -1,7 +1,7 @@
"""Regressions for the defects found by the 0.11 audit.""" """Regressions for the defects found by the 0.11 audit."""
import json import json
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote, unquote
import app import app
import conftest import conftest
@@ -69,6 +69,46 @@ def test_vm_delete_failure_is_reported(logged_in, data_dirs, monkeypatch):
assert response.headers["location"].startswith("/vm/web01?error=") 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) # ---------------------------------------------------------------- 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): def test_iso_mount_uses_change_media_for_existing_drive(logged_in, data_dirs, monkeypatch):
iso = data_dirs["iso"] / "debian.iso" iso = data_dirs["iso"] / "debian.iso"
+13
Просмотреть файл
@@ -1,5 +1,18 @@
{ {
"versions": [ "versions": [
{
"version": "0.12.5",
"date": "2026-09-27",
"title": "Удаление машины в любом состоянии",
"summary": "Пункт «Удалить машину» теперь есть всегда, а не только у выключенной машины. Работающую или приостановленную машину панель сначала выключит, а подтверждение честно об этом предупреждает. Вместе с машиной удаляются её пробросы портов.",
"features": [
"«Удалить машину» доступно в меню машины и в списке машин при любом состоянии",
"Работающая или приостановленная машина перед удалением выключается",
"Пока с машиной идёт копия, клон или другая задача, удалить её нельзя: панель просит дождаться завершения",
"Вместе с машиной удаляются её пробросы портов, чтобы они не вели на адрес, который достанется другой машине",
"Версия обновлена до 0.12.5"
]
},
{ {
"version": "0.12.4", "version": "0.12.4",
"date": "2026-09-27", "date": "2026-09-27",
+9 -2
Просмотреть файл
@@ -2094,14 +2094,21 @@ def vm_action(request: Request, name: str, action: str):
if action in ("shutdown", "destroy", "delete"): if action in ("shutdown", "destroy", "delete"):
backups.cancel_late_restart(name) backups.cancel_late_restart(name)
if action == "delete": if action == "delete":
if vm_state(name) == "running": import core
busy = core.active_operations_for(name)
if busy:
return redirect_with_message(f"/vm/{name}", "error", f"С машиной сейчас идёт задача «{busy[0].get('title') or 'операция'}». Дождитесь её завершения и удалите машину снова.")
if core.vm_is_running(name): # и на паузе тоже: undefine активной машины оставил бы её работать без настроек
run_cmd(["virsh", "destroy", name], timeout=20) run_cmd(["virsh", "destroy", name], timeout=20)
# --nvram: UEFI machines keep firmware variables that block a plain undefine. # --nvram: UEFI machines keep firmware variables that block a plain undefine.
result = run_cmd(["virsh", "undefine", name, "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata", "--checkpoints-metadata"], timeout=120) result = run_cmd(["virsh", "undefine", name, "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata", "--checkpoints-metadata"], timeout=120)
if not result["ok"]: if not result["ok"]:
return redirect_with_message(f"/vm/{name}", "error", f"Не удалось удалить машину: {virsh_error(result)}") return redirect_with_message(f"/vm/{name}", "error", f"Не удалось удалить машину: {virsh_error(result)}")
network_core.release_nat_address(name) network_core.release_nat_address(name)
return redirect_with_message("/", "message", f"Машина {name} удалена") removed = network_core.remove_vm_forwards(name)
message = f"Машина {name} удалена" + (f", вместе с ней убраны пробросы портов: {removed}" if removed else "")
return redirect_with_message("/", "message", message)
if action not in allowed: if action not in allowed:
return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400) return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400)
result = run_cmd(allowed[action], timeout=30) result = run_cmd(allowed[action], timeout=30)
+11
Просмотреть файл
@@ -436,6 +436,17 @@ def add_port_forward(vm_name: str, guest_ip: str, external_port: Any, guest_port
return normalize_forward(forward) return normalize_forward(forward)
def remove_vm_forwards(vm_name: str) -> int:
"""Машина удалена: её пробросы вели бы на адрес, который потом может достаться другой машине."""
items = load_port_forwards()
kept = [item for item in items if item.get('vm_name') != vm_name]
if len(kept) == len(items):
return 0
save_port_forwards(kept)
apply_port_forwards()
return len(items) - len(kept)
def delete_port_forward(forward_id: str) -> None: def delete_port_forward(forward_id: str) -> None:
items = [item for item in load_port_forwards() if item.get('id') != forward_id] items = [item for item in load_port_forwards() if item.get('id') != forward_id]
save_port_forwards(items) save_port_forwards(items)
+2 -2
Просмотреть файл
@@ -101,9 +101,9 @@
<form method="post" action="/vm/{{ vm.name }}/destroy" data-confirm="Машина будет выключена сразу, как при отключении питания. Несохранённые данные внутри неё пропадут." data-confirm-title="Выключить {{ vm.name }} принудительно?" data-confirm-button="Выключить" data-toast="Машина {{ vm.name }} выключена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('zap') }}Выключить принудительно</button></form> <form method="post" action="/vm/{{ vm.name }}/destroy" data-confirm="Машина будет выключена сразу, как при отключении питания. Несохранённые данные внутри неё пропадут." data-confirm-title="Выключить {{ vm.name }} принудительно?" data-confirm-button="Выключить" data-toast="Машина {{ vm.name }} выключена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('zap') }}Выключить принудительно</button></form>
{% elif vm.state != 'paused' %} {% elif vm.state != 'paused' %}
<form method="post" action="/vm/{{ vm.name }}/start" data-toast="Машина {{ vm.name }} запускается"><button class="menu-item">{{ icon('play') }}Запустить</button></form> <form method="post" action="/vm/{{ vm.name }}/start" data-toast="Машина {{ vm.name }} запускается"><button class="menu-item">{{ icon('play') }}Запустить</button></form>
<div class="menu-sep"></div>
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="Машина и все её диски будут удалены без возможности восстановления." data-confirm-title="Удалить {{ vm.name }}?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}Удалить машину</button></form>
{% endif %} {% endif %}
<div class="menu-sep"></div>
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="{% if vm.state != 'shut off' %}Машина {{ vm.name }} работает: она будет сразу выключена, как при отключении питания, а затем удалена вместе со всеми дисками.{% else %}Машина {{ vm.name }} будет удалена вместе со всеми дисками.{% endif %} Отменить это нельзя. Резервные копии машины останутся, из них её можно восстановить." data-confirm-title="Удалить {{ vm.name }}?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}Удалить машину</button></form>
</div> </div>
</details> </details>
</div> </div>
+1 -2
Просмотреть файл
@@ -33,9 +33,8 @@
{% if s.running %} {% if s.running %}
<form method="post" action="/vm/{{ vm.name }}/reboot" data-toast="Машина перезагружается"><button class="menu-item">{{ icon('rotate-cw') }}<span>Перезагрузить<small>Мягкая перезагрузка системы</small></span></button></form> <form method="post" action="/vm/{{ vm.name }}/reboot" data-toast="Машина перезагружается"><button class="menu-item">{{ icon('rotate-cw') }}<span>Перезагрузить<small>Мягкая перезагрузка системы</small></span></button></form>
<form method="post" action="/vm/{{ vm.name }}/destroy" data-confirm="Машина будет выключена сразу, как при отключении питания. Несохранённые данные внутри неё пропадут." data-confirm-title="Выключить принудительно?" data-confirm-button="Выключить" data-toast="Машина выключена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('zap') }}<span>Выключить принудительно<small>Если система зависла</small></span></button></form> <form method="post" action="/vm/{{ vm.name }}/destroy" data-confirm="Машина будет выключена сразу, как при отключении питания. Несохранённые данные внутри неё пропадут." data-confirm-title="Выключить принудительно?" data-confirm-button="Выключить" data-toast="Машина выключена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('zap') }}<span>Выключить принудительно<small>Если система зависла</small></span></button></form>
{% else %}
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="Машина {{ vm.name }} и все её диски будут удалены без возможности восстановления." data-confirm-title="Удалить машину?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}<span>Удалить машину<small>Вместе с дисками</small></span></button></form>
{% endif %} {% endif %}
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="{% if vm.state != 'shut off' %}Машина {{ vm.name }} работает: она будет сразу выключена, как при отключении питания, а затем удалена вместе со всеми дисками.{% else %}Машина {{ vm.name }} будет удалена вместе со всеми дисками.{% endif %} Отменить это нельзя. Резервные копии машины останутся, из них её можно восстановить." data-confirm-title="Удалить машину {{ vm.name }}?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}<span>Удалить машину<small>Вместе с дисками</small></span></button></form>
</div> </div>
</details> </details>
</div> </div>