6 Коммитов
Автор SHA1 Сообщение Дата
viktor a840e0c828 Версия была обновлена до 0.12.8
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 22:42:23 +09:00
viktor c19d8db6e7 Выбор образа при создании машины карточками: название системы, размер, дата загрузки и поиск, когда образов много 2026-09-27 22:42:23 +09:00
viktor c04e5285e7 Версия была обновлена до 0.12.7
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 21:24:19 +09:00
viktor 5d02cc81ff Установщик Windows узнаётся по метке ISO и имени файла: форма создания машины сама выбирает тип Windows, иначе диск был virtio и установщик его не видел 2026-09-27 21:24:19 +09:00
viktor ff3b09aba4 Версия была обновлена до 0.12.6
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 21:08:17 +09:00
viktor 2bf4ef35c2 Удаление машины больше не стирает вставленный установочный ISO: вместо --remove-all-storage удаляются только диски машины из папки машин 2026-09-27 21:08:17 +09:00
18 изменённых файлов: 342 добавлений и 65 удалений
+4 -4
Просмотреть файл
@@ -2,7 +2,7 @@
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
Текущая версия: **0.12.5** (файл `VERSION`, журнал изменений — `updates/versions.json`).
Текущая версия: **0.12.8** (файл `VERSION`, журнал изменений — `updates/versions.json`).
Что умеет:
@@ -76,7 +76,7 @@
sudo apt install -y xorriso git curl python3-pip
git clone https://git.stackworks.ru/viktor/virtuality.git
cd virtuality
make iso # dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso
make iso # dist/virtuality-0.12.8-ubuntu-26.04-amd64.iso
```
Дальше: записать образ на флешку (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: 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-ключ. Пароль хранится только как хеш. При первой загрузке в машину ставится гостевой агент, поэтому панель видит её адрес в любой сети и выключает её корректно.
- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками и пробросами портов (работающая машина сначала выключается).
- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками и пробросами портов (работающая машина сначала выключается, установочные ISO остаются).
- Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места).
- Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ).
- **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти.
+1 -1
Просмотреть файл
@@ -1 +1 @@
0.12.5
0.12.8
+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/`:
```text
dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso
dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso.sha256
dist/virtuality-0.12.8-ubuntu-26.04-amd64.iso
dist/virtuality-0.12.8-ubuntu-26.04-amd64.iso.sha256
```
Скачанный 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.5** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
Меню загрузки: **Установить Virtuality 0.12.8** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
Сценарий `image/autoinstall.yaml` оставляет интерактивными только три экрана установщика Ubuntu:
@@ -161,14 +161,14 @@ cat /var/log/virtuality/firstboot.log
Сверьте контрольную сумму скачанного или собранного образа:
```bash
sha256sum -c virtuality-0.12.5-ubuntu-26.04-amd64.iso.sha256
sha256sum -c virtuality-0.12.8-ubuntu-26.04-amd64.iso.sha256
```
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
```bash
xorriso -indev virtuality-0.12.5-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
xorriso -osirrox on -indev virtuality-0.12.5-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
xorriso -indev virtuality-0.12.8-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
xorriso -osirrox on -indev virtuality-0.12.8-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
```
---
@@ -182,7 +182,7 @@ xorriso -osirrox on -indev virtuality-0.12.5-ubuntu-26.04-amd64.iso -extract /au
- **Linux**: balenaEtcher или `dd`, где `/dev/sdX` — устройство флешки (проверьте через `lsblk`):
```bash
sudo dd if=dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
sudo dd if=dist/virtuality-0.12.8-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
```
Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**.
Двоичные данные
Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 203 KiB

После

Ширина:  |  Высота:  |  Размер: 266 KiB

+1 -1
Просмотреть файл
@@ -102,7 +102,7 @@ def test_dashboard_uses_plain_language(logged_in):
def test_vm_create_preselects_image(logged_in, data_dirs):
(data_dirs["iso"] / "debian.iso").write_bytes(b"iso")
html = logged_in.get("/vm/create?iso=debian.iso").text
assert f'value="{data_dirs["iso"] / "debian.iso"}" data-label="debian.iso" selected' in html
assert f'value="{data_dirs["iso"] / "debian.iso"}" data-label="debian.iso" data-os="" data-login="" checked' in html
def test_vm_create_disk_image_without_disk_size(logged_in, data_dirs, monkeypatch):
+33 -5
Просмотреть файл
@@ -55,16 +55,44 @@ 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):
import core
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)
assert response.headers["location"].startswith("/?message=")
assert find(calls, "virsh", "destroy", "web01")
undefine = find(calls, "virsh", "undefine", "web01")[0]
assert "--nvram" in undefine and "--remove-all-storage" in undefine and "--snapshots-metadata" in undefine
undefine = find(core_calls, "virsh", "undefine", "web01")[0]
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):
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)
assert response.headers["location"].startswith("/vm/web01?error=")
@@ -73,9 +101,9 @@ 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"))])
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(calls, "virsh", "undefine", "web01")
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):
+6 -6
Просмотреть файл
@@ -420,7 +420,7 @@ def test_restore_requires_replace_confirmation(fast_worker):
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):
@@ -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)
def test_restore_ejects_iso_before_undefine(fast_worker, monkeypatch):
def test_restore_keeps_inserted_iso(fast_worker, monkeypatch):
make_backup(fast_worker)
iso = fast_worker["iso"] / "debian.iso"
iso.write_bytes(b"ISO")
virsh = FakeVirsh(running=False)
base = virsh.__call__
def with_iso(cmd, timeout=12, **kw):
if cmd[:2] == ["virsh", "domblklist"]:
virsh.calls.append(list(cmd))
return fake_result(DOMBLKLIST_DETAILS.replace("sda -", "sda /var/lib/virtuality/iso/debian.iso"))
return fake_result(DOMBLKLIST_DETAILS.replace("/var/lib/virtuality/images", str(core.IMAGES_DIR)).replace("sda -", f"sda {iso}"))
return base(cmd, timeout, **kw)
monkeypatch.setattr(core, "run_cmd", with_iso)
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
backups.start_restore("web01", "20260924-0300", "web01", True)
eject = ["virsh", "change-media", "web01", "sda", "--eject", "--config"]
assert eject in virsh.calls
assert virsh.calls.index(eject) < virsh.calls.index(UNDEFINE)
assert UNDEFINE in virsh.calls and iso.exists()
def test_restore_rejects_bad_name_and_incomplete_backup(fast_worker):
+52 -1
Просмотреть файл
@@ -1,7 +1,10 @@
"""Quick machines from cloud images (cloud-init), OS types for installers, fixed NAT addresses."""
import shlex
import pytest
import app
import catalog
import cloudinit
import conftest
import network_core
@@ -146,7 +149,55 @@ def test_reservation_is_reused_and_released(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()))])
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")
# ---------------------------------------------------------------- тип системы по установочному 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" data-login="" checked' in html and 'data-label="ubuntu.iso" data-os="" data-login="" >' in html
assert 'value="windows10" checked' in html and 'value="linux" checked' not in html
def test_images_are_offered_as_cards_with_system_size_and_date(logged_in, data_dirs):
fake_iso(data_dirs["iso"] / "19044.4046---Win10-IoT-LTSC.iso", "19044_4046___WIN10_IOT_LTSC")
fake_iso(data_dirs["iso"] / "ubuntu-24.04.1-live-server-amd64.iso", "Ubuntu-Server 24.04.1 LTS amd64")
html = logged_in.get("/vm/create").text
assert html.count('name="iso_path"') == 2 and '<span class="tag">Windows 10</span>' in html and '<span class="tag">Ubuntu</span>' in html
assert "34 КБ · загружен" in html and "data-image-search" not in html # поиск только когда образов больше шести
for number in range(5):
fake_iso(data_dirs["iso"] / f"extra-{number}.iso", "")
assert "data-image-search" in logged_in.get("/vm/create").text
+35
Просмотреть файл
@@ -1,5 +1,40 @@
{
"versions": [
{
"version": "0.12.8",
"date": "2026-09-27",
"title": "Удобный выбор образа при создании машины",
"summary": "Установочные образы и готовые системы в форме создания машины теперь показаны карточками: какая система внутри, размер и когда образ загружен. Когда образов больше шести, появляется поиск по названию.",
"features": [
"Образы в форме создания машины показаны карточками вместо выпадающего списка",
"На карточке видно систему (Windows 10, Windows 11, Ubuntu, Debian и другие), размер и дату загрузки",
"Поиск по названию, когда образов больше шести",
"Версия обновлена до 0.12.8"
]
},
{
"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",
+30 -8
Просмотреть файл
@@ -553,6 +553,19 @@ def parse_pool_list() -> list[dict[str, str]]:
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]]:
ISO_DIR.mkdir(parents=True, exist_ok=True)
files = []
@@ -564,7 +577,12 @@ def list_iso_files() -> list[dict[str, str]]:
except OSError:
size_mb = 0
updated = "unknown"
files.append({"name": item.name, "path": str(item), "size": f"{size_mb:.1f} MB", "updated": updated})
label = iso_volume_label(item)
files.append({
"name": item.name, "path": str(item), "size": f"{size_mb:.1f} MB", "updated": updated,
"os_type": catalog.guess_os_type(label, item.name), "system": catalog.iso_system_name(label, item.name),
"size_label": presenters.format_bytes(stat.st_size) if size_mb else "", "date_label": presenters.short_date(stat.st_mtime) if size_mb else "",
})
return files
@@ -622,7 +640,8 @@ def list_disk_image_files() -> list[dict[str, str]]:
size_gb = 0
updated = "unknown"
entry = catalog.entry_for_filename(item.name)
files.append({"name": item.name, "path": str(item), "format": item.suffix.lower().lstrip('.'), "size": f"{size_gb:.2f} GB", "updated": updated, "login": cloudinit.default_user(item.name), "title": entry["title"] if entry else "", "catalog_id": entry["id"] if entry else ""})
files.append({"name": item.name, "path": str(item), "format": item.suffix.lower().lstrip('.'), "size": f"{size_gb:.2f} GB", "updated": updated, "login": cloudinit.default_user(item.name), "title": entry["title"] if entry else "", "catalog_id": entry["id"] if entry else "",
"size_label": presenters.format_bytes(stat.st_size) if size_gb else "", "date_label": presenters.short_date(stat.st_mtime) if size_gb else ""})
return files
@@ -1819,6 +1838,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}
if iso:
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:
form["disk_image_path"] = str(DISK_IMAGES_DIR / Path(image).name)
return vm_form_context(request, form=form)
@@ -2101,13 +2121,15 @@ def vm_action(request: Request, name: str, action: str):
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)
# --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)
if not result["ok"]:
return redirect_with_message(f"/vm/{name}", "error", f"Не удалось удалить машину: {virsh_error(result)}")
# Установочные ISO и образы систем остаются: удаляются только диски самой машины.
ok, error, _removed, kept = core.undefine_vm(name)
if not ok:
return redirect_with_message(f"/vm/{name}", "error", f"Не удалось удалить машину: {error}")
network_core.release_nat_address(name)
removed = network_core.remove_vm_forwards(name)
message = f"Машина {name} удалена" + (f", вместе с ней убраны пробросы портов: {removed}" if removed else "")
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:
return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400)
+38
Просмотреть файл
@@ -3,6 +3,7 @@
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.
"""
import re
from typing import Any
# osinfo ids must exist in osinfo-db shipped with Ubuntu 24.04+/Debian 13.
@@ -69,6 +70,43 @@ 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"
# Название системы для карточки образа в форме создания машины.
DISTROS = (
("UBUNTU", "Ubuntu"), ("DEBIAN", "Debian"), ("FEDORA", "Fedora"), ("ALPINE", "Alpine"), ("CENTOS", "CentOS"),
("ROCKY", "Rocky Linux"), ("ALMALINUX", "AlmaLinux"), ("OPENSUSE", "openSUSE"), ("ARCHLINUX", "Arch Linux"),
("LINUXMINT", "Linux Mint"), ("KALI", "Kali Linux"), ("ASTRA", "Astra Linux"), ("REDOS", "РЕД ОС"),
("PROXMOX", "Proxmox"), ("TRUENAS", "TrueNAS"), ("FREEBSD", "FreeBSD"), ("PFSENSE", "pfSense"),
("OPNSENSE", "OPNsense"), ("OPENWRT", "OpenWrt"),
)
def iso_system_name(volume_label: str, filename: str) -> str:
"""«Windows 10», «Ubuntu»… или "", если систему по образу не узнать."""
guess = guess_os_type(volume_label, filename)
if guess:
return "Windows 11" if guess == "windows11" else "Windows 10"
words = set(re.sub(r"[^A-Z0-9]+", " ", f"{volume_label} {filename}".upper()).split())
return next((title for key, title in DISTROS if key in words), "")
def cloud_image(image_id: str) -> dict[str, Any] | None:
return next((item for item in CLOUD_IMAGES if item["id"] == image_id), None)
+37
Просмотреть файл
@@ -227,6 +227,43 @@ def vm_is_running(name: str) -> bool:
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
def operation_meta_path(operation_id: str) -> Path:
return OPERATIONS_DIR / f"{operation_id}.json"
+5 -16
Просмотреть файл
@@ -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"]]
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:
"""Сам путь или ближайший существующий родитель — чтобы спросить у ФС о разделе."""
target = path
@@ -691,16 +684,12 @@ def remove_vm(operation: dict[str, Any], name: str) -> None:
result = run(["virsh", "destroy", name], timeout=30)
if not result["ok"]:
raise BackupError("Не удалось выключить старую машину: " + core.cmd_error(result, "virsh destroy"))
for cdrom in vm_cdroms_with_media(name):
# Иначе `undefine --remove-all-storage` удалит и вставленный ISO-образ.
result = run(["virsh", "change-media", name, cdrom["target"], "--eject", "--config"], timeout=20)
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"))
ok, error, _removed, kept = core.undefine_vm(name) # вставленный ISO остаётся на месте
if not ok:
raise BackupError("Не удалось удалить старую машину: " + error)
log(f"Старая машина {name} удалена вместе с дисками.")
if kept:
log("Диски вне папки машин не удалены: " + ", ".join(str(path) for path in kept))
def restore_worker(operation: dict[str, Any]) -> None:
+11
Просмотреть файл
@@ -242,6 +242,17 @@ def port_service(port: int) -> str:
return WELL_KNOWN_PORTS.get(int(port or 0), "")
MONTHS_GENITIVE = ("января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря")
def short_date(timestamp: float, now: datetime | None = None) -> str:
"""«27 сентября», для прошлых лет — «27 сентября 2025»."""
moment = datetime.fromtimestamp(timestamp)
now = now or datetime.now()
text = f"{moment.day} {MONTHS_GENITIVE[moment.month - 1]}"
return text if moment.year == now.year else f"{text} {moment.year}"
def format_bytes(value: int | float) -> str:
value = float(value or 0)
for unit in ("Б", "КБ", "МБ", "ГБ", "ТБ"):
+19
Просмотреть файл
@@ -333,6 +333,25 @@ hr { border: 0; border-top: 1px solid var(--border); margin: 20px 0; }
.option input:checked + .option-body .option-check { border: 5px solid var(--brand); }
.option-title { font-weight: 650; font-size: 14px; }
.option-text { color: var(--text-2); font-size: 12.5px; }
/* Выбор образа в форме создания машины */
.image-picker { display: grid; gap: 10px; }
.image-list { display: grid; gap: 8px; max-height: 360px; overflow-y: auto; padding: 2px; }
.image-option { position: relative; display: block; cursor: pointer; }
.image-option[hidden] { display: none; }
.image-option input { position: absolute; opacity: 0; pointer-events: none; }
.image-option-body { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border: 1.5px solid var(--border); border-radius: var(--radius); background: var(--surface); transition: border-color .15s, background .15s; }
.image-option:hover .image-option-body { border-color: var(--border-strong); }
.image-option input:checked + .image-option-body { border-color: var(--brand); background: color-mix(in srgb, var(--brand-soft) 55%, var(--surface)); box-shadow: 0 0 0 4px var(--focus); }
.image-option input:focus-visible + .image-option-body { box-shadow: 0 0 0 4px var(--focus); }
.image-option input:checked + .image-option-body .option-check { border: 5px solid var(--brand); }
.image-option .grow { flex: 1; display: grid; gap: 2px; min-width: 0; }
.image-title { font-weight: 650; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-meta { color: var(--text-3); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 560px) {
.image-option .list-icon { display: none; }
.image-title, .image-meta { white-space: normal; overflow-wrap: anywhere; }
}
/* Расписание копий: день недели и список машин видны, только когда нужны (без :has — видны всегда). */
.schedule-form:has(input[name=frequency][value=daily]:checked) [data-when=weekly],
.schedule-form:has(input[name=machines][value=all]:checked) [data-when=chosen] { display: none; }
+30 -6
Просмотреть файл
@@ -435,7 +435,8 @@
const field = (name) => form.elements[name];
const value = (name) => { const el = field(name); if (!el) return ''; if (el instanceof RadioNodeList) return el.value; return el.value; };
const setSummary = (key, text) => $$(`[data-summary="${key}"]`).forEach((el) => { el.textContent = text || '—'; });
const optionText = (select) => select && select.selectedIndex >= 0 ? select.options[select.selectedIndex].dataset.label || select.options[select.selectedIndex].text : '';
// Образы выбираются карточками (радиокнопки): берём отмеченную.
const picked = (name) => $(`input[name="${name}"]:checked`, form);
function sync() {
const source = value('source_type');
@@ -454,9 +455,7 @@
// Default login of the chosen cloud image (ubuntu, debian, …) until the user types a name.
const login = field('ci_user');
if (login && login.dataset.autoLogin === '1') {
const image = field('disk_image_path');
const option = image && image.selectedIndex >= 0 ? image.options[image.selectedIndex] : null;
login.value = option?.dataset.login || 'admin';
login.value = picked('disk_image_path')?.dataset.login || 'admin';
}
const preset = value('preset');
const custom = preset === 'custom';
@@ -469,7 +468,7 @@
$$('[data-custom-resources] input', form).forEach((input) => { input.readOnly = !custom; });
const memory = Number(value('memory')) || 0;
setSummary('name', value('name'));
setSummary('source', source === 'disk_image' ? optionText(field('disk_image_path')) : optionText(field('iso_path')));
setSummary('source', picked(source === 'disk_image' ? 'disk_image_path' : 'iso_path')?.dataset.label || '');
setSummary('cpu', `${value('vcpus')} ${Number(value('vcpus')) === 1 ? 'ядро' : 'ядра'}`);
setSummary('memory', memory >= 1024 ? `${+(memory / 1024).toFixed(1)} ГБ` : `${memory} МБ`);
setSummary('disk', `${value('disk_size')} ГБ`);
@@ -478,8 +477,33 @@
$$('[data-create-button]').forEach((btn) => { btn.disabled = !hasSource || !value('name'); });
$$('[data-missing-source]').forEach((el) => { el.hidden = hasSource || el.dataset.missingSource !== source; });
}
// Установщик Windows панель узнаёт по ISO (data-os) и сама выбирает тип, пока пользователь не выбрал его руками.
const guessOs = () => {
const guess = picked('iso_path')?.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('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();
});
// Поиск по образам, когда их много.
$$('[data-image-picker]', form).forEach((picker) => {
const search = $('[data-image-search]', picker);
if (!search) return;
search.addEventListener('input', () => {
const query = search.value.trim().toLowerCase();
let shown = 0;
$$('.image-option', picker).forEach((item) => { item.hidden = Boolean(query) && !item.dataset.search.includes(query); shown += item.hidden ? 0 : 1; });
$('[data-image-empty]', picker).hidden = shown > 0;
});
});
guessOs();
sync();
}
+26
Просмотреть файл
@@ -0,0 +1,26 @@
{% from "_ui.html" import icon %}
{#- Выбор образа карточками. Ожидает: picker_name (имя поля), picker_items (list_iso_files или list_disk_image_files),
picker_selected (путь выбранного), picker_icon. Поиск появляется, когда образов больше шести. -#}
<div class="image-picker" data-image-picker>
{% if picker_items|length > 6 %}
<input class="input" type="search" placeholder="Найти образ по названию" aria-label="Найти образ" data-image-search>
{% endif %}
<div class="image-list" role="radiogroup">
{% for item in picker_items %}
{% set title = item.title or item.name %}
<label class="image-option" data-search="{{ (title ~ ' ' ~ item.name ~ ' ' ~ (item.system or ''))|lower }}">
<input type="radio" name="{{ picker_name }}" value="{{ item.path }}" data-label="{{ title }}" data-os="{{ item.os_type or '' }}" data-login="{{ item.login or '' }}" {% if picker_selected == item.path or (not picker_selected and loop.first) %}checked{% endif %}>
<span class="image-option-body">
<span class="list-icon brand">{{ icon(picker_icon) }}</span>
<span class="grow">
<span class="image-title">{{ title }}</span>
<span class="image-meta">{% if item.title %}{{ item.name }} · {% endif %}{{ item.size_label or item.size }}{% if item.date_label %} · загружен {{ item.date_label }}{% endif %}</span>
</span>
{% if item.system %}<span class="tag">{{ item.system }}</span>{% endif %}
<span class="option-check"></span>
</span>
</label>
{% endfor %}
</div>
<p class="subtle small" data-image-empty hidden>Образов с таким названием нет.</p>
</div>
+7 -10
Просмотреть файл
@@ -16,7 +16,7 @@
{% endblock %}
{% 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">
<article class="card step-card">
<div class="step-head"><span class="step-num">1</span><div><h2>Название и система</h2><p>Как назовём машину и что на неё установить</p></div></div>
@@ -47,12 +47,10 @@
</div>
<div class="field" data-show-when="source_type=iso">
<label for="iso_path">Установочный образ</label>
<span class="label">Установочный образ</span>
{% if isos %}
<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 %}
</select>
<span class="hint">Нужен другой? <a href="/iso">Загрузите образ</a>.</span>
{% with picker_name = 'iso_path', picker_items = isos, picker_selected = form.iso_path, picker_icon = 'disc-3' %}{% include "_image_picker.html" %}{% endwith %}
<span class="hint">Образы хранятся на сервере отдельно от машин, один образ можно ставить сколько угодно раз. Нужен другой? <a href="/iso">Загрузите образ</a>.</span>
{% endif %}
</div>
<div class="field" data-show-when="source_type=iso">
@@ -65,14 +63,13 @@
</label>
{% endfor %}
</div>
<span class="hint" data-os-hint hidden>{{ icon('sparkles', 'icon-sm') }} Это установщик Windows, тип выбран сам. Если ошиблись, выберите другой.</span>
<span class="hint">От этого зависят виртуальные устройства: Windows получит совместимые диск и сеть, Windows 11 — UEFI и TPM.</span>
</div>
<div class="field" data-show-when="source_type=disk_image">
<label for="disk_image_path">Образ системы</label>
<span class="label">Образ системы</span>
{% if disk_images %}
<select class="select" id="disk_image_path" name="disk_image_path">
{% for image in disk_images %}<option value="{{ image.path }}" data-label="{{ image.title or image.name }}" data-login="{{ image.login }}" {% if form.disk_image_path == image.path %}selected{% endif %}>{{ image.title or image.name }} · {{ image.size }}</option>{% endfor %}
</select>
{% with picker_name = 'disk_image_path', picker_items = disk_images, picker_selected = form.disk_image_path, picker_icon = 'hard-drive' %}{% include "_image_picker.html" %}{% endwith %}
<span class="hint">Образ будет скопирован — оригинал останется для других машин.</span>
{% endif %}
</div>