Сравнить коммиты
8
Коммитов
v0.12.3
...
c04e5285e7
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
c04e5285e7 | ||
|
|
5d02cc81ff | ||
|
|
ff3b09aba4 | ||
|
|
2bf4ef35c2 | ||
|
|
ca5d49266c | ||
|
|
ec9428c2eb | ||
|
|
f31b4ade1d | ||
|
|
2474ad3dc2 |
+4
-4
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
|
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
|
||||||
|
|
||||||
Текущая версия: **0.12.3** (файл `VERSION`, журнал изменений — `updates/versions.json`).
|
Текущая версия: **0.12.7** (файл `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.3-ubuntu-26.04-amd64.iso
|
make iso # dist/virtuality-0.12.7-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).
|
||||||
@@ -166,9 +166,9 @@ 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 (установщик Windows узнаётся по образу и выбирается сам): 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-ключ. Пароль хранится только как хеш. При первой загрузке в машину ставится гостевой агент, поэтому панель видит её адрес в любой сети и выключает её корректно.
|
||||||
- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками.
|
- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками и пробросами портов (работающая машина сначала выключается, установочные ISO остаются).
|
||||||
- Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места).
|
- Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места).
|
||||||
- Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ).
|
- Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ).
|
||||||
- **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти.
|
- **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.12.3
|
0.12.7
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
|
|
||||||
1. На странице машины нажмите **Открыть экран**. Сразу после запуска Windows показывает «Press any key to boot from CD or DVD» — нажмите любую клавишу в окне экрана, иначе загрузка с диска пропустится. Если пропустили — **Перезагрузить** на странице машины и попробовать снова.
|
1. На странице машины нажмите **Открыть экран**. Сразу после запуска Windows показывает «Press any key to boot from CD or DVD» — нажмите любую клавишу в окне экрана, иначе загрузка с диска пропустится. Если пропустили — **Перезагрузить** на странице машины и попробовать снова.
|
||||||
2. Дальше установка идёт как на обычном компьютере. Диск в установщике будет пустым — выберите его целиком.
|
2. Дальше установка идёт как на обычном компьютере. Диск в установщике будет пустым — выберите его целиком.
|
||||||
3. После установки на странице машины откройте вкладку **Диски и загрузка** → **Привод CD/DVD** и извлеките установочный образ, чтобы машина больше не предлагала загрузку с него.
|
3. После установки машина перезагрузится уже с диска: для машин из ISO панель ставит диск первым в порядке загрузки, пока он пустой, машина загружается с образа. Сам образ можно извлечь во вкладке **Диски и загрузка** → **Привод CD/DVD**.
|
||||||
|
|
||||||
Для других систем выбирайте тип **Linux** (Ubuntu, Debian, Fedora и другие), **Windows 10 / Server 2019–2022** (классическая загрузка) или **Другая система**.
|
Для других систем выбирайте тип **Linux** (Ubuntu, Debian, Fedora и другие), **Windows 10 / Server 2019–2022** (классическая загрузка) или **Другая система**.
|
||||||
|
|
||||||
|
|||||||
@@ -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.3-ubuntu-26.04-amd64.iso
|
dist/virtuality-0.12.7-ubuntu-26.04-amd64.iso
|
||||||
dist/virtuality-0.12.3-ubuntu-26.04-amd64.iso.sha256
|
dist/virtuality-0.12.7-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.3** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
|
Меню загрузки: **Установить Virtuality 0.12.7** (выбирается сам через 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.3-ubuntu-26.04-amd64.iso.sha256
|
sha256sum -c virtuality-0.12.7-ubuntu-26.04-amd64.iso.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
|
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xorriso -indev virtuality-0.12.3-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
|
xorriso -indev virtuality-0.12.7-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
|
||||||
xorriso -osirrox on -indev virtuality-0.12.3-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
|
xorriso -osirrox on -indev virtuality-0.12.7-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -182,7 +182,7 @@ xorriso -osirrox on -indev virtuality-0.12.3-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.3-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
|
sudo dd if=dist/virtuality-0.12.7-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
|
||||||
```
|
```
|
||||||
|
|
||||||
Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**.
|
Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**.
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ def test_dashboard_uses_plain_language(logged_in):
|
|||||||
def test_vm_create_preselects_image(logged_in, data_dirs):
|
def test_vm_create_preselects_image(logged_in, data_dirs):
|
||||||
(data_dirs["iso"] / "debian.iso").write_bytes(b"iso")
|
(data_dirs["iso"] / "debian.iso").write_bytes(b"iso")
|
||||||
html = logged_in.get("/vm/create?iso=debian.iso").text
|
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
|
assert f'value="{data_dirs["iso"] / "debian.iso"}" data-label="debian.iso" data-os="" selected' in html
|
||||||
|
|
||||||
|
|
||||||
def test_vm_create_disk_image_without_disk_size(logged_in, data_dirs, monkeypatch):
|
def test_vm_create_disk_image_without_disk_size(logged_in, data_dirs, monkeypatch):
|
||||||
@@ -142,7 +142,10 @@ def test_vm_create_iso_builds_virt_install(logged_in, data_dirs, monkeypatch):
|
|||||||
assert cmd[0] == "virt-install"
|
assert cmd[0] == "virt-install"
|
||||||
assert "--cdrom" in cmd and str(iso) in cmd
|
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 any(part.startswith("network=virtuality-nat,model=virtio,mac=52:54:00:") for part in cmd)
|
||||||
assert captured["operation"]["boot_order"] == "cdrom_disk"
|
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):
|
def test_vm_create_validation_error(logged_in):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -55,20 +55,88 @@ def test_vm_action_success_redirects_without_error(logged_in, data_dirs, monkeyp
|
|||||||
|
|
||||||
|
|
||||||
def test_vm_delete_undefines_uefi_machines(logged_in, data_dirs, monkeypatch):
|
def test_vm_delete_undefines_uefi_machines(logged_in, data_dirs, monkeypatch):
|
||||||
|
import core
|
||||||
|
|
||||||
calls = recording(monkeypatch, app)
|
calls = recording(monkeypatch, app)
|
||||||
|
core_calls = recording(monkeypatch, core)
|
||||||
|
disk = data_dirs["images"] / "web01.qcow2"
|
||||||
|
disk.write_bytes(b"QFI\xfb")
|
||||||
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
|
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
|
||||||
assert response.headers["location"].startswith("/?message=")
|
assert response.headers["location"].startswith("/?message=")
|
||||||
assert find(calls, "virsh", "destroy", "web01")
|
assert find(calls, "virsh", "destroy", "web01")
|
||||||
undefine = find(calls, "virsh", "undefine", "web01")[0]
|
undefine = find(core_calls, "virsh", "undefine", "web01")[0]
|
||||||
assert "--nvram" in undefine and "--remove-all-storage" in undefine and "--snapshots-metadata" in undefine
|
assert "--nvram" in undefine and "--snapshots-metadata" in undefine
|
||||||
|
assert not disk.exists() and find(core_calls, "virsh", "pool-refresh", "virtuality-images")
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_delete_keeps_installer_iso_and_foreign_disks(logged_in, data_dirs, monkeypatch):
|
||||||
|
"""--remove-all-storage удалял вставленный ISO: загруженные образы пропадали вместе с машиной."""
|
||||||
|
import core
|
||||||
|
|
||||||
|
iso = data_dirs["iso"] / "smk-server-_1.iso"
|
||||||
|
iso.write_bytes(b"ISO")
|
||||||
|
disk = data_dirs["images"] / "web01.qcow2"
|
||||||
|
disk.write_bytes(b"QFI\xfb")
|
||||||
|
foreign = data_dirs["disk_images"] / "shared.raw"
|
||||||
|
foreign.write_bytes(b"raw")
|
||||||
|
listing = f" Type Device Target Source\n----\n file disk vda {disk}\n file disk vdb {foreign}\n file cdrom sda {iso}\n"
|
||||||
|
recording(monkeypatch, app)
|
||||||
|
core_calls = recording(monkeypatch, core, [(["virsh", "domblklist"], conftest.fake_result(listing))])
|
||||||
|
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
|
||||||
|
assert not disk.exists() and iso.exists() and foreign.exists()
|
||||||
|
assert "--remove-all-storage" not in find(core_calls, "virsh", "undefine", "web01")[0]
|
||||||
|
assert str(foreign) in unquote(response.headers["location"])
|
||||||
|
|
||||||
|
|
||||||
def test_vm_delete_failure_is_reported(logged_in, data_dirs, monkeypatch):
|
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"))])
|
import core
|
||||||
|
|
||||||
|
recording(monkeypatch, app)
|
||||||
|
recording(monkeypatch, core, [(["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)
|
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
|
||||||
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)
|
||||||
|
core_calls = 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(core_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"
|
||||||
|
|||||||
@@ -420,7 +420,7 @@ def test_restore_requires_replace_confirmation(fast_worker):
|
|||||||
assert exc.value.code == "exists"
|
assert exc.value.code == "exists"
|
||||||
|
|
||||||
|
|
||||||
UNDEFINE = ["virsh", "undefine", "web01", "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"]
|
UNDEFINE = ["virsh", "undefine", "web01", "--nvram", "--managed-save", "--snapshots-metadata", "--checkpoints-metadata"]
|
||||||
|
|
||||||
|
|
||||||
def test_restore_replaces_existing_vm_only_after_disks_are_ready(fast_worker, monkeypatch):
|
def test_restore_replaces_existing_vm_only_after_disks_are_ready(fast_worker, monkeypatch):
|
||||||
@@ -464,23 +464,23 @@ def test_restore_refuses_when_space_is_tight_even_if_old_vm_would_free_it(fast_w
|
|||||||
assert exc.value.code == "space" and "рядом со старой машиной" not in str(exc.value)
|
assert exc.value.code == "space" and "рядом со старой машиной" not in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
def test_restore_ejects_iso_before_undefine(fast_worker, monkeypatch):
|
def test_restore_keeps_inserted_iso(fast_worker, monkeypatch):
|
||||||
make_backup(fast_worker)
|
make_backup(fast_worker)
|
||||||
|
iso = fast_worker["iso"] / "debian.iso"
|
||||||
|
iso.write_bytes(b"ISO")
|
||||||
virsh = FakeVirsh(running=False)
|
virsh = FakeVirsh(running=False)
|
||||||
base = virsh.__call__
|
base = virsh.__call__
|
||||||
|
|
||||||
def with_iso(cmd, timeout=12, **kw):
|
def with_iso(cmd, timeout=12, **kw):
|
||||||
if cmd[:2] == ["virsh", "domblklist"]:
|
if cmd[:2] == ["virsh", "domblklist"]:
|
||||||
virsh.calls.append(list(cmd))
|
virsh.calls.append(list(cmd))
|
||||||
return fake_result(DOMBLKLIST_DETAILS.replace("sda -", "sda /var/lib/virtuality/iso/debian.iso"))
|
return fake_result(DOMBLKLIST_DETAILS.replace("/var/lib/virtuality/images", str(core.IMAGES_DIR)).replace("sda -", f"sda {iso}"))
|
||||||
return base(cmd, timeout, **kw)
|
return base(cmd, timeout, **kw)
|
||||||
|
|
||||||
monkeypatch.setattr(core, "run_cmd", with_iso)
|
monkeypatch.setattr(core, "run_cmd", with_iso)
|
||||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||||
backups.start_restore("web01", "20260924-0300", "web01", True)
|
backups.start_restore("web01", "20260924-0300", "web01", True)
|
||||||
eject = ["virsh", "change-media", "web01", "sda", "--eject", "--config"]
|
assert UNDEFINE in virsh.calls and iso.exists()
|
||||||
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):
|
def test_restore_rejects_bad_name_and_incomplete_backup(fast_worker):
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"""Quick machines from cloud images (cloud-init), OS types for installers, fixed NAT addresses."""
|
"""Quick machines from cloud images (cloud-init), OS types for installers, fixed NAT addresses."""
|
||||||
import shlex
|
import shlex
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
import app
|
import app
|
||||||
|
import catalog
|
||||||
import cloudinit
|
import cloudinit
|
||||||
import conftest
|
import conftest
|
||||||
import network_core
|
import network_core
|
||||||
@@ -118,7 +121,7 @@ def test_windows11_gets_uefi_tpm_sata_and_e1000e(logged_in, data_dirs, monkeypat
|
|||||||
assert response.status_code == 303
|
assert response.status_code == 303
|
||||||
cmd = captured["cmd"]
|
cmd = captured["cmd"]
|
||||||
assert "--osinfo" in cmd and cmd[cmd.index("--osinfo") + 1] == "win11"
|
assert "--osinfo" in cmd and cmd[cmd.index("--osinfo") + 1] == "win11"
|
||||||
assert cmd[cmd.index("--boot") + 1] == "uefi,cdrom,hd"
|
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 "--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 "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
|
assert "--channel" not in cmd # Windows has no guest agent without extra drivers
|
||||||
@@ -129,7 +132,7 @@ def test_linux_installer_detects_os_and_uses_virtio(logged_in, data_dirs, monkey
|
|||||||
iso.write_bytes(b"iso")
|
iso.write_bytes(b"iso")
|
||||||
response, captured = create(logged_in, monkeypatch, iso_form(iso))
|
response, captured = create(logged_in, monkeypatch, iso_form(iso))
|
||||||
cmd = captured["cmd"]
|
cmd = captured["cmd"]
|
||||||
assert cmd[cmd.index("--osinfo") + 1] == "detect=on,require=off" and cmd[cmd.index("--boot") + 1] == "cdrom,hd"
|
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 "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 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
|
assert create(logged_in, monkeypatch, iso_form(iso, os_type="bsd"))[0].status_code == 400
|
||||||
@@ -146,7 +149,44 @@ def test_reservation_is_reused_and_released(data_dirs, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_vm_delete_releases_address(logged_in, data_dirs, monkeypatch):
|
def test_vm_delete_releases_address(logged_in, data_dirs, monkeypatch):
|
||||||
calls = recording(monkeypatch, app, [])
|
import core
|
||||||
|
|
||||||
|
recording(monkeypatch, app, [])
|
||||||
|
calls = recording(monkeypatch, core, [])
|
||||||
net_calls = recording(monkeypatch, network_core, [(["virsh", "net-dumpxml"], conftest.fake_result((conftest.FIXTURES / "net-dumpxml.xml").read_text()))])
|
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 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")
|
assert find(calls, "virsh", "undefine") and find(net_calls, "virsh", "net-update", "virtuality-nat", "delete")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- тип системы по установочному ISO
|
||||||
|
def fake_iso(path, label):
|
||||||
|
"""Минимальный ISO 9660: первичный дескриптор тома с меткой по смещению 0x8028."""
|
||||||
|
descriptor = bytearray(0x800)
|
||||||
|
descriptor[0], descriptor[1:6], descriptor[6] = 1, b"CD001", 1
|
||||||
|
descriptor[0x28:0x48] = label.encode().ljust(32)
|
||||||
|
path.write_bytes(bytes(0x8000) + bytes(descriptor))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("label, name, expected", [
|
||||||
|
("19044_4046___WIN10_IOT_LTSC", "19044.4046---Win10-IoT-LTSC.iso", "windows10"),
|
||||||
|
("CCCOMA_X64FRE_RU-RU_DV9", "Win11_24H2_Russian_x64.iso", "windows11"),
|
||||||
|
("CCCOMA_X64FRE_EN-US_DV9", "windows.iso", "windows11"), # версия неизвестна: UEFI + TPM подходят и для 10
|
||||||
|
("SSS_X64FREE_EN-US_DV9", "20348.169.210806-2348.fe_release_SERVER_EVAL_x64FRE_en-us.iso", "windows10"),
|
||||||
|
("SSS_X64FREV_EN-US_DV9", "26100.1742.240906-0331.ge_release_SERVER_EVAL_x64FRE_en-us.iso", "windows11"),
|
||||||
|
("Ubuntu-Server 26.04 LTS amd64", "ubuntu-26.04-live-server-amd64.iso", ""),
|
||||||
|
("", "debian-13.1.0-amd64-netinst.iso", ""),
|
||||||
|
("smk-server", "smk-server-_1.iso", ""),
|
||||||
|
])
|
||||||
|
def test_guess_os_type_from_iso(label, name, expected):
|
||||||
|
assert catalog.guess_os_type(label, name) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_windows_iso_preselects_windows_type(logged_in, data_dirs):
|
||||||
|
"""Машина с Windows 10 создалась как Linux: диск virtio, установщик Windows его не видел."""
|
||||||
|
fake_iso(data_dirs["iso"] / "19044.4046---Win10-IoT-LTSC.iso", "19044_4046___WIN10_IOT_LTSC")
|
||||||
|
fake_iso(data_dirs["iso"] / "ubuntu.iso", "Ubuntu-Server 26.04 LTS amd64")
|
||||||
|
assert app.iso_volume_label(data_dirs["iso"] / "ubuntu.iso") == "Ubuntu-Server 26.04 LTS amd64"
|
||||||
|
html = logged_in.get("/vm/create?iso=19044.4046---Win10-IoT-LTSC.iso").text
|
||||||
|
assert 'data-os="windows10" selected' in html and 'data-label="ubuntu.iso" data-os=""' in html
|
||||||
|
assert 'value="windows10" checked' in html and 'value="linux" checked' not in html
|
||||||
|
|||||||
@@ -1,5 +1,52 @@
|
|||||||
{
|
{
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.12.7",
|
||||||
|
"date": "2026-09-27",
|
||||||
|
"title": "Тип Windows выбирается сам по установочному образу",
|
||||||
|
"summary": "Если в форме создания машины выбран установщик Windows, панель узнаёт его по метке образа и имени файла и сама ставит тип Windows. Раньше по умолчанию оставался Linux, диск подключался как virtio, и установщик Windows его не видел.",
|
||||||
|
"features": [
|
||||||
|
"Установщик Windows 10, 11 и Server узнаётся по установочному образу, тип системы выбирается сам",
|
||||||
|
"Выбор можно поменять вручную, панель его больше не перебивает",
|
||||||
|
"Версия обновлена до 0.12.7"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.12.6",
|
||||||
|
"date": "2026-09-27",
|
||||||
|
"title": "Установочные образы больше не пропадают при удалении машины",
|
||||||
|
"summary": "При удалении машины вместе с ней удалялся и вставленный в её привод установочный ISO, поэтому загруженные образы исчезали. Теперь удаляются только диски самой машины, а образы остаются для следующих установок, как в Proxmox.",
|
||||||
|
"features": [
|
||||||
|
"Удаление машины больше не удаляет установочный ISO из её привода",
|
||||||
|
"Удаляются только диски машины из папки машин, диски в других местах остаются, панель называет их в сообщении",
|
||||||
|
"То же при восстановлении из копии с заменой машины",
|
||||||
|
"Версия обновлена до 0.12.6"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.12.3",
|
||||||
"date": "2026-09-27",
|
"date": "2026-09-27",
|
||||||
|
|||||||
+37
-11
@@ -553,6 +553,19 @@ def parse_pool_list() -> list[dict[str, str]]:
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def iso_volume_label(path: Path) -> str:
|
||||||
|
"""Метка тома ISO 9660: 32 байта в первичном дескрипторе по смещению 0x8028."""
|
||||||
|
try:
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
handle.seek(0x8000)
|
||||||
|
descriptor = handle.read(0x48)
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
if descriptor[1:6] != b"CD001":
|
||||||
|
return ""
|
||||||
|
return descriptor[0x28:0x48].decode("ascii", "replace").strip()
|
||||||
|
|
||||||
|
|
||||||
def list_iso_files() -> list[dict[str, str]]:
|
def list_iso_files() -> list[dict[str, str]]:
|
||||||
ISO_DIR.mkdir(parents=True, exist_ok=True)
|
ISO_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
files = []
|
files = []
|
||||||
@@ -564,7 +577,7 @@ def list_iso_files() -> list[dict[str, str]]:
|
|||||||
except OSError:
|
except OSError:
|
||||||
size_mb = 0
|
size_mb = 0
|
||||||
updated = "unknown"
|
updated = "unknown"
|
||||||
files.append({"name": item.name, "path": str(item), "size": f"{size_mb:.1f} MB", "updated": updated})
|
files.append({"name": item.name, "path": str(item), "size": f"{size_mb:.1f} MB", "updated": updated, "os_type": catalog.guess_os_type(iso_volume_label(item), item.name)})
|
||||||
return files
|
return files
|
||||||
|
|
||||||
|
|
||||||
@@ -1272,12 +1285,12 @@ def vm_boot_order_options() -> list[dict[str, str]]:
|
|||||||
|
|
||||||
|
|
||||||
def normalize_boot_order(value: str, source_type: str) -> str:
|
def normalize_boot_order(value: str, source_type: str) -> str:
|
||||||
|
"""auto для ISO — сначала диск, потом CD, как в Proxmox: пустой диск не загрузочный, и машина стартует
|
||||||
|
с установщика, а после установки — уже с диска, и повторно в установщик не попадает."""
|
||||||
value = (value or "auto").strip()
|
value = (value or "auto").strip()
|
||||||
if value == "auto":
|
|
||||||
return "cdrom_disk" if source_type == "iso" else "disk"
|
|
||||||
if value in ("disk", "cdrom_disk", "disk_cdrom"):
|
if value in ("disk", "cdrom_disk", "disk_cdrom"):
|
||||||
return value
|
return value
|
||||||
return "cdrom_disk" if source_type == "iso" else "disk"
|
return "disk_cdrom" if source_type == "iso" else "disk"
|
||||||
|
|
||||||
|
|
||||||
def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
|
def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
|
||||||
@@ -1819,6 +1832,7 @@ def vm_create_page(request: Request, iso: str = "", image: str = ""):
|
|||||||
form = {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "disk_image" if image else "iso", "boot_order": "auto", "network_mode": profile.get("recommended_network", "nat"), "bridge": DEFAULT_BRIDGE}
|
form = {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "disk_image" if image else "iso", "boot_order": "auto", "network_mode": profile.get("recommended_network", "nat"), "bridge": DEFAULT_BRIDGE}
|
||||||
if iso:
|
if iso:
|
||||||
form["iso_path"] = str(ISO_DIR / Path(iso).name)
|
form["iso_path"] = str(ISO_DIR / Path(iso).name)
|
||||||
|
form["os_type"] = catalog.guess_os_type(iso_volume_label(ISO_DIR / Path(iso).name), Path(iso).name) or "linux"
|
||||||
if image:
|
if image:
|
||||||
form["disk_image_path"] = str(DISK_IMAGES_DIR / Path(image).name)
|
form["disk_image_path"] = str(DISK_IMAGES_DIR / Path(image).name)
|
||||||
return vm_form_context(request, form=form)
|
return vm_form_context(request, form=form)
|
||||||
@@ -1948,7 +1962,10 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form
|
|||||||
steps.append(" ".join(shlex.quote(part) for part in virt))
|
steps.append(" ".join(shlex.quote(part) for part in virt))
|
||||||
cmd = ["bash", "-lc", "set -euo pipefail; " + "; ".join(steps)]
|
cmd = ["bash", "-lc", "set -euo pipefail; " + "; ".join(steps)]
|
||||||
else:
|
else:
|
||||||
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus={disk_bus}", "--cdrom", iso_path, "--osinfo", os_info["osinfo"], "--network", network_arg, *graphics]
|
# no_install: машина сразу создаётся в рабочем виде, с образом в приводе. Без этого virt-install ставит
|
||||||
|
# на время установки «перезагрузка = выключение» и сам должен перезапустить машину, а он не ждёт
|
||||||
|
# (--noautoconsole) — после установки машина просто выключалась.
|
||||||
|
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus={disk_bus}", "--cdrom", iso_path, "--install", "no_install=yes", "--osinfo", os_info["osinfo"], "--network", network_arg, *graphics]
|
||||||
if too_big:
|
if too_big:
|
||||||
cmd += ["--check", "disk_size=off"] # подтверждено пользователем: virt-install иначе откажется
|
cmd += ["--check", "disk_size=off"] # подтверждено пользователем: virt-install иначе откажется
|
||||||
|
|
||||||
@@ -2091,14 +2108,23 @@ 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.
|
# Установочные ISO и образы систем остаются: удаляются только диски самой машины.
|
||||||
result = run_cmd(["virsh", "undefine", name, "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata", "--checkpoints-metadata"], timeout=120)
|
ok, error, _removed, kept = core.undefine_vm(name)
|
||||||
if not result["ok"]:
|
if not ok:
|
||||||
return redirect_with_message(f"/vm/{name}", "error", f"Не удалось удалить машину: {virsh_error(result)}")
|
return redirect_with_message(f"/vm/{name}", "error", f"Не удалось удалить машину: {error}")
|
||||||
network_core.release_nat_address(name)
|
network_core.release_nat_address(name)
|
||||||
return redirect_with_message("/", "message", f"Машина {name} удалена")
|
forwards = network_core.remove_vm_forwards(name)
|
||||||
|
message = f"Машина {name} удалена" + (f", вместе с ней убраны пробросы портов: {forwards}" if forwards else "")
|
||||||
|
if kept:
|
||||||
|
message += ". Не удалены диски вне папки машин: " + ", ".join(str(path) for path in kept)
|
||||||
|
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)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Each entry points to the vendor's official cloud image. Images boot in seconds
|
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.
|
and are configured through cloud-init (user, password, SSH key) — no installer.
|
||||||
"""
|
"""
|
||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
# osinfo ids must exist in osinfo-db shipped with Ubuntu 24.04+/Debian 13.
|
# osinfo ids must exist in osinfo-db shipped with Ubuntu 24.04+/Debian 13.
|
||||||
@@ -69,6 +70,24 @@ OS_TYPES: list[dict[str, Any]] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Установщик Windows узнаём по метке тома ISO (у Microsoft вида CCCOMA_X64FRE_RU-RU_DV9) или по имени файла.
|
||||||
|
WINDOWS_RE = re.compile(r"\bWIN(?:DOWS)?\s?(?:1[01]|7|8|SERVER)\b|\bWINDOWS\b|\b(?:X64|X86|A64|ARM64)FRE[EV]?\b")
|
||||||
|
WINDOWS11_RE = re.compile(r"\bWIN(?:DOWS)?\s?11\b|\bSERVER\s?2025\b|\b(?:22000|22621|22631|26100|26200)\b")
|
||||||
|
WINDOWS10_RE = re.compile(r"\bWIN(?:DOWS)?\s?(?:10|7|8)\b|\bLTSC\b|\bSERVER\s?20(?:16|19|22)\b|\b1\d{4}\b|\b20348\b")
|
||||||
|
|
||||||
|
|
||||||
|
def guess_os_type(volume_label: str, filename: str) -> str:
|
||||||
|
"""Тип системы для установочного ISO: windows11 / windows10, если это установщик Windows, иначе "".
|
||||||
|
|
||||||
|
Windows неизвестной версии — windows11: UEFI и TPM подходят и для Windows 10, а без них не встанет 11."""
|
||||||
|
text = re.sub(r"[^A-Z0-9]+", " ", f"{volume_label} {filename}".upper())
|
||||||
|
if not WINDOWS_RE.search(text):
|
||||||
|
return ""
|
||||||
|
if WINDOWS11_RE.search(text):
|
||||||
|
return "windows11"
|
||||||
|
return "windows10" if WINDOWS10_RE.search(text) else "windows11"
|
||||||
|
|
||||||
|
|
||||||
def cloud_image(image_id: str) -> dict[str, Any] | None:
|
def cloud_image(image_id: str) -> dict[str, Any] | None:
|
||||||
return next((item for item in CLOUD_IMAGES if item["id"] == image_id), None)
|
return next((item for item in CLOUD_IMAGES if item["id"] == image_id), None)
|
||||||
|
|
||||||
|
|||||||
@@ -227,6 +227,43 @@ def vm_is_running(name: str) -> bool:
|
|||||||
return vm_state(name) in ("running", "idle", "blocked", "paused", "pmsuspended", "in shutdown")
|
return vm_state(name) in ("running", "idle", "blocked", "paused", "pmsuspended", "in shutdown")
|
||||||
|
|
||||||
|
|
||||||
|
def vm_disk_files(name: str) -> tuple[list[Path], list[Path]]:
|
||||||
|
"""Файлы дисков машины: (свои — в IMAGES_DIR, чужие — где-то ещё). Приводы CD/DVD сюда не входят."""
|
||||||
|
own: list[Path] = []
|
||||||
|
foreign: list[Path] = []
|
||||||
|
result = run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)
|
||||||
|
for disk in presenters.parse_domblklist(result.get("stdout") or ""):
|
||||||
|
if disk["device"] == "disk" and disk["source"]:
|
||||||
|
path = Path(disk["source"])
|
||||||
|
(own if IMAGES_DIR.resolve() in path.resolve().parents else foreign).append(path)
|
||||||
|
return own, foreign
|
||||||
|
|
||||||
|
|
||||||
|
def undefine_vm(name: str) -> tuple[bool, str, list[Path], list[Path]]:
|
||||||
|
"""Удалить машину и файлы её дисков из IMAGES_DIR. Возвращает (ok, ошибка, удалённые, оставленные).
|
||||||
|
|
||||||
|
Без `--remove-all-storage`: он удаляет все тома libvirt, подключённые к машине, в том числе
|
||||||
|
вставленный установочный ISO из пула virtuality-iso. Диски вне IMAGES_DIR не трогаем."""
|
||||||
|
own, foreign = vm_disk_files(name) # после undefine libvirt о дисках машины уже не расскажет
|
||||||
|
# --nvram: UEFI machines keep firmware variables that block a plain undefine.
|
||||||
|
result = run_cmd(["virsh", "undefine", name, "--nvram", "--managed-save", "--snapshots-metadata", "--checkpoints-metadata"], timeout=120)
|
||||||
|
if not result["ok"]:
|
||||||
|
return False, cmd_error(result, "virsh undefine"), [], []
|
||||||
|
removed = []
|
||||||
|
for disk in own:
|
||||||
|
try:
|
||||||
|
disk.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
foreign.append(disk)
|
||||||
|
continue
|
||||||
|
removed.append(disk)
|
||||||
|
if removed:
|
||||||
|
run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20)
|
||||||
|
return True, "", removed, foreign
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- operations
|
# ---------------------------------------------------------------- operations
|
||||||
def operation_meta_path(operation_id: str) -> Path:
|
def operation_meta_path(operation_id: str) -> Path:
|
||||||
return OPERATIONS_DIR / f"{operation_id}.json"
|
return OPERATIONS_DIR / f"{operation_id}.json"
|
||||||
|
|||||||
@@ -140,13 +140,6 @@ def vm_disks(name: str) -> list[dict[str, str]]:
|
|||||||
return [disk for disk in presenters.parse_domblklist(result["stdout"]) if disk["device"] == "disk" and disk["source"]]
|
return [disk for disk in presenters.parse_domblklist(result["stdout"]) if disk["device"] == "disk" and disk["source"]]
|
||||||
|
|
||||||
|
|
||||||
def vm_cdroms_with_media(name: str) -> list[dict[str, str]]:
|
|
||||||
result = run(["virsh", "domblklist", name, "--details"], timeout=10)
|
|
||||||
if not result["ok"]:
|
|
||||||
return []
|
|
||||||
return [disk for disk in presenters.parse_domblklist(result["stdout"]) if disk["device"] == "cdrom" and disk["source"]]
|
|
||||||
|
|
||||||
|
|
||||||
def existing_parent(path: Path) -> Path:
|
def existing_parent(path: Path) -> Path:
|
||||||
"""Сам путь или ближайший существующий родитель — чтобы спросить у ФС о разделе."""
|
"""Сам путь или ближайший существующий родитель — чтобы спросить у ФС о разделе."""
|
||||||
target = path
|
target = path
|
||||||
@@ -691,16 +684,12 @@ def remove_vm(operation: dict[str, Any], name: str) -> None:
|
|||||||
result = run(["virsh", "destroy", name], timeout=30)
|
result = run(["virsh", "destroy", name], timeout=30)
|
||||||
if not result["ok"]:
|
if not result["ok"]:
|
||||||
raise BackupError("Не удалось выключить старую машину: " + core.cmd_error(result, "virsh destroy"))
|
raise BackupError("Не удалось выключить старую машину: " + core.cmd_error(result, "virsh destroy"))
|
||||||
for cdrom in vm_cdroms_with_media(name):
|
ok, error, _removed, kept = core.undefine_vm(name) # вставленный ISO остаётся на месте
|
||||||
# Иначе `undefine --remove-all-storage` удалит и вставленный ISO-образ.
|
if not ok:
|
||||||
result = run(["virsh", "change-media", name, cdrom["target"], "--eject", "--config"], timeout=20)
|
raise BackupError("Не удалось удалить старую машину: " + error)
|
||||||
if not result["ok"]:
|
|
||||||
raise BackupError(f"Не удалось извлечь образ из привода {cdrom['target']}: " + core.cmd_error(result, "virsh change-media"))
|
|
||||||
log(f"Образ извлечён из привода {cdrom['target']}, чтобы он не был удалён вместе с машиной.")
|
|
||||||
result = run(["virsh", "undefine", name, "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"], timeout=180)
|
|
||||||
if not result["ok"]:
|
|
||||||
raise BackupError("Не удалось удалить старую машину: " + core.cmd_error(result, "virsh undefine"))
|
|
||||||
log(f"Старая машина {name} удалена вместе с дисками.")
|
log(f"Старая машина {name} удалена вместе с дисками.")
|
||||||
|
if kept:
|
||||||
|
log("Диски вне папки машин не удалены: " + ", ".join(str(path) for path in kept))
|
||||||
|
|
||||||
|
|
||||||
def restore_worker(operation: dict[str, Any]) -> None:
|
def restore_worker(operation: dict[str, Any]) -> None:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -478,8 +478,24 @@
|
|||||||
$$('[data-create-button]').forEach((btn) => { btn.disabled = !hasSource || !value('name'); });
|
$$('[data-create-button]').forEach((btn) => { btn.disabled = !hasSource || !value('name'); });
|
||||||
$$('[data-missing-source]').forEach((el) => { el.hidden = hasSource || el.dataset.missingSource !== source; });
|
$$('[data-missing-source]').forEach((el) => { el.hidden = hasSource || el.dataset.missingSource !== source; });
|
||||||
}
|
}
|
||||||
|
// Установщик Windows панель узнаёт по ISO (data-os) и сама выбирает тип, пока пользователь не выбрал его руками.
|
||||||
|
const guessOs = () => {
|
||||||
|
const select = field('iso_path');
|
||||||
|
const option = select && select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null;
|
||||||
|
const guess = option?.dataset.os || '';
|
||||||
|
const hint = $('[data-os-hint]', form);
|
||||||
|
if (form.dataset.osManual) return;
|
||||||
|
const radio = $(`input[name=os_type][value="${guess || 'linux'}"]`, form);
|
||||||
|
if (radio) radio.checked = true;
|
||||||
|
if (hint) hint.hidden = !guess;
|
||||||
|
};
|
||||||
form.addEventListener('input', (event) => { if (event.target.name === 'ci_user') event.target.dataset.autoLogin = event.target.value ? '0' : '1'; sync(); });
|
form.addEventListener('input', (event) => { if (event.target.name === 'ci_user') event.target.dataset.autoLogin = event.target.value ? '0' : '1'; sync(); });
|
||||||
form.addEventListener('change', sync);
|
form.addEventListener('change', (event) => {
|
||||||
|
if (event.target.name === 'os_type') { form.dataset.osManual = '1'; const hint = $('[data-os-hint]', form); if (hint) hint.hidden = true; }
|
||||||
|
if (event.target.name === 'iso_path') guessOs();
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
guessOs();
|
||||||
sync();
|
sync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li>Экран открывается в новой вкладке браузера — это как монитор, подключённый к машине.</li>
|
<li>Экран открывается в новой вкладке браузера — это как монитор, подключённый к машине.</li>
|
||||||
<li>Если курсор «застрял» внутри экрана, нажмите <code>Ctrl + Alt</code>.</li>
|
<li>Если курсор «застрял» внутри экрана, нажмите <code>Ctrl + Alt</code>.</li>
|
||||||
<li>После установки системы извлеките установочный диск на странице машины → «Диски и загрузка».</li>
|
<li>После установки машина перезагрузится уже с диска: он стоит первым в порядке загрузки. Установочный образ можно извлечь на странице машины → «Диски и загрузка».</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div></article>
|
</div></article>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<form method="post" action="/vm/create" class="grid grid-main" data-wizard data-has-isos="{{ '1' if isos else '0' }}" data-has-disks="{{ '1' if disk_images else '0' }}">
|
<form method="post" action="/vm/create" class="grid grid-main" data-wizard {% if form.name %}data-os-manual="1"{% endif %} data-has-isos="{{ '1' if isos else '0' }}" data-has-disks="{{ '1' if disk_images else '0' }}">
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<article class="card step-card">
|
<article class="card step-card">
|
||||||
<div class="step-head"><span class="step-num">1</span><div><h2>Название и система</h2><p>Как назовём машину и что на неё установить</p></div></div>
|
<div class="step-head"><span class="step-num">1</span><div><h2>Название и система</h2><p>Как назовём машину и что на неё установить</p></div></div>
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
<label for="iso_path">Установочный образ</label>
|
<label for="iso_path">Установочный образ</label>
|
||||||
{% if isos %}
|
{% if isos %}
|
||||||
<select class="select" id="iso_path" name="iso_path">
|
<select class="select" id="iso_path" name="iso_path">
|
||||||
{% for iso in isos %}<option value="{{ iso.path }}" data-label="{{ iso.name }}" {% if form.iso_path == iso.path %}selected{% endif %}>{{ iso.name }} · {{ iso.size }}</option>{% endfor %}
|
{% for iso in isos %}<option value="{{ iso.path }}" data-label="{{ iso.name }}" data-os="{{ iso.os_type }}" {% if form.iso_path == iso.path %}selected{% endif %}>{{ iso.name }} · {{ iso.size }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<span class="hint">Нужен другой? <a href="/iso">Загрузите образ</a>.</span>
|
<span class="hint">Нужен другой? <a href="/iso">Загрузите образ</a>.</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -65,6 +65,7 @@
|
|||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
<span class="hint" data-os-hint hidden>{{ icon('sparkles', 'icon-sm') }} Это установщик Windows, тип выбран сам. Если ошиблись, выберите другой.</span>
|
||||||
<span class="hint">От этого зависят виртуальные устройства: Windows получит совместимые диск и сеть, Windows 11 — UEFI и TPM.</span>
|
<span class="hint">От этого зависят виртуальные устройства: Windows получит совместимые диск и сеть, Windows 11 — UEFI и TPM.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" data-show-when="source_type=disk_image">
|
<div class="field" data-show-when="source_type=disk_image">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user