2 Коммитов
Автор SHA1 Сообщение Дата
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
10 изменённых файлов: 116 добавлений и 45 удалений
+3 -3
Просмотреть файл
@@ -2,7 +2,7 @@
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка. **Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
Текущая версия: **0.12.5** (файл `VERSION`, журнал изменений — `updates/versions.json`). Текущая версия: **0.12.6** (файл `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.5-ubuntu-26.04-amd64.iso make iso # dist/virtuality-0.12.6-ubuntu-26.04-amd64.iso
``` ```
Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md). Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md).
@@ -168,7 +168,7 @@ sudo VIRTUALITY_AUTH_USER=admin bash scripts/install_web_panel.sh # панел
- Создание из установочного ISO: три шага — название и система, мощность (готовые профили «Мини», «Стандарт», «Мощная» или свои значения), сеть. - Создание из установочного ISO: три шага — название и система, мощность (готовые профили «Мини», «Стандарт», «Мощная» или свои значения), сеть.
- Тип системы для ISO: Linux, **Windows 11 / Server 2025** (UEFI и TPM 2.0 включаются сами), Windows 10 / Server 2019–2022, другая. Для Windows подбираются диск SATA и сетевая карта Intel — драйверы не нужны. - Тип системы для ISO: Linux, **Windows 11 / Server 2025** (UEFI и TPM 2.0 включаются сами), Windows 10 / Server 2019–2022, другая. Для Windows подбираются диск SATA и сетевая карта Intel — драйверы не нужны.
- Создание из готового образа за минуту: образ конвертируется в qcow2, диск увеличивается до нужного размера, а cloud-init настраивает первый вход — пользователя, пароль и/или SSH-ключ. Пароль хранится только как хеш. При первой загрузке в машину ставится гостевой агент, поэтому панель видит её адрес в любой сети и выключает её корректно. - Создание из готового образа за минуту: образ конвертируется в qcow2, диск увеличивается до нужного размера, а cloud-init настраивает первый вход — пользователя, пароль и/или SSH-ключ. Пароль хранится только как хеш. При первой загрузке в машину ставится гостевой агент, поэтому панель видит её адрес в любой сети и выключает её корректно.
- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками и пробросами портов (работающая машина сначала выключается). - Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками и пробросами портов (работающая машина сначала выключается, установочные ISO остаются).
- Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места). - Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места).
- Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ). - Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ).
- **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти. - **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти.
+1 -1
Просмотреть файл
@@ -1 +1 @@
0.12.5 0.12.6
+7 -7
Просмотреть файл
@@ -44,8 +44,8 @@ make iso ARCH=arm64 # ARM64-серверы с UEFI
`image/build-iso.sh` сам находит последний Ubuntu 26.04.x live-server ISO, скачивает его (около 3 ГБ), проверяет SHA256 и кладёт результат в `dist/`: `image/build-iso.sh` сам находит последний Ubuntu 26.04.x live-server ISO, скачивает его (около 3 ГБ), проверяет SHA256 и кладёт результат в `dist/`:
```text ```text
dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso dist/virtuality-0.12.6-ubuntu-26.04-amd64.iso
dist/virtuality-0.12.5-ubuntu-26.04-amd64.iso.sha256 dist/virtuality-0.12.6-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.5** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти. Меню загрузки: **Установить Virtuality 0.12.6** (выбирается сам через 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.5-ubuntu-26.04-amd64.iso.sha256 sha256sum -c virtuality-0.12.6-ubuntu-26.04-amd64.iso.sha256
``` ```
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI): Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
```bash ```bash
xorriso -indev virtuality-0.12.5-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI xorriso -indev virtuality-0.12.6-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 -osirrox on -indev virtuality-0.12.6-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`): - **Linux**: balenaEtcher или `dd`, где `/dev/sdX` — устройство флешки (проверьте через `lsblk`):
```bash ```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.6-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
``` ```
Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**. Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**.
+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): 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=")
@@ -73,9 +101,9 @@ def test_vm_delete_stops_paused_machine_first(logged_in, data_dirs, monkeypatch)
import core import core
calls = recording(monkeypatch, app) 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) 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): 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" 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):
+4 -1
Просмотреть файл
@@ -146,7 +146,10 @@ 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")
+12
Просмотреть файл
@@ -1,5 +1,17 @@
{ {
"versions": [ "versions": [
{
"version": "0.12.6",
"date": "2026-09-27",
"title": "Установочные образы больше не пропадают при удалении машины",
"summary": "При удалении машины вместе с ней удалялся и вставленный в её привод установочный ISO, поэтому загруженные образы исчезали. Теперь удаляются только диски самой машины, а образы остаются для следующих установок, как в Proxmox.",
"features": [
"Удаление машины больше не удаляет установочный ISO из её привода",
"Удаляются только диски машины из папки машин, диски в других местах остаются, панель называет их в сообщении",
"То же при восстановлении из копии с заменой машины",
"Версия обновлена до 0.12.6"
]
},
{ {
"version": "0.12.5", "version": "0.12.5",
"date": "2026-09-27", "date": "2026-09-27",
+8 -6
Просмотреть файл
@@ -2101,13 +2101,15 @@ def vm_action(request: Request, name: str, action: str):
return redirect_with_message(f"/vm/{name}", "error", f"С машиной сейчас идёт задача «{busy[0].get('title') or 'операция'}». Дождитесь её завершения и удалите машину снова.") return redirect_with_message(f"/vm/{name}", "error", f"С машиной сейчас идёт задача «{busy[0].get('title') or 'операция'}». Дождитесь её завершения и удалите машину снова.")
if core.vm_is_running(name): # и на паузе тоже: undefine активной машины оставил бы её работать без настроек 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)
removed = network_core.remove_vm_forwards(name) forwards = network_core.remove_vm_forwards(name)
message = f"Машина {name} удалена" + (f", вместе с ней убраны пробросы портов: {removed}" if removed else "") message = f"Машина {name} удалена" + (f", вместе с ней убраны пробросы портов: {forwards}" if forwards else "")
if kept:
message += ". Не удалены диски вне папки машин: " + ", ".join(str(path) for path in kept)
return redirect_with_message("/", "message", message) 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)
+37
Просмотреть файл
@@ -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"
+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"]] 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: