diff --git a/tests/test_app.py b/tests/test_app.py index a2436ec..398b286 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,3 +1,4 @@ +import uuid from pathlib import Path from urllib.parse import unquote @@ -220,3 +221,36 @@ def test_vm_create_disk_bigger_than_free_space_needs_confirmation(logged_in, dat response = logged_in.post("/vm/create", data={**form, "name": "small", "disk_size": 20}, follow_redirects=False) assert response.status_code == 303 and "--check" not in started[1] + + +def failed_create(data_dirs, name, disk=True, **extra): + """Операция создания машины, у которой virt-install завершился с ошибкой.""" + disk_path = data_dirs["images"] / f"{name}.qcow2" + if disk: + disk_path.write_bytes(b"QFI\xfb") + operation = {"id": str(uuid.uuid4()), "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "", "created_at": app.utc_now(), "updated_at": app.utc_now(), "vm_name": name, "disk_path": str(disk_path), "network_mode": "bridge", **extra} + app.write_operation(operation) + app.run_operation_worker(operation["id"], ["bash", "-c", "echo 'ERROR The requested volume capacity will exceed the available pool space'; exit 1"]) + return app.read_operation(operation["id"]), disk_path + + +def test_failed_vm_create_cleans_up_and_offers_retry(logged_in, data_dirs): + """Страница упавшей задачи предлагала «Открыть экран машины», хотя машины нет; недосозданный диск мешал повтору.""" + operation, disk_path = failed_create(data_dirs, "smk", iso_path=str(data_dirs["iso"] / "smk-server-_1.iso")) + assert operation["status"] == "error" and "ничего не осталось" in operation["message"] + assert not disk_path.exists() and "Недосозданный диск smk.qcow2 удалён" in operation["log_tail"] + html = logged_in.get(f"/operations/{operation['id']}").text + assert 'data-op-done="success" hidden' in html + assert 'href="/vm/create?iso=smk-server-_1.iso"' in html and "Создать заново" in html + + +def test_failed_create_of_existing_vm_keeps_its_disk(logged_in, data_dirs): + operation, disk_path = failed_create(data_dirs, "web01") # машина определена в libvirt: её диск не трогаем + assert disk_path.exists() and "ничего не осталось" not in operation["message"] + html = logged_in.get(f"/operations/{operation['id']}").text + assert "Создать заново" not in html and 'href="/vm/web01">Перейти к машине' in html + + +def test_operation_page_reveals_only_buttons_for_its_result(): + script = (Path(__file__).resolve().parents[1] / "web" / "static" / "panel.js").read_text() + assert '$$(`[data-op-done="${status}"]`)' in script and "$$('[data-op-done]')" not in script diff --git a/web/app.py b/web/app.py index 99c971c..87557af 100644 --- a/web/app.py +++ b/web/app.py @@ -294,6 +294,17 @@ def progress_from_line(current: int, line: str) -> int: return current +def remove_unfinished_disk(operation_id: str, disk_path: str) -> None: + """Машина не появилась, а её диск (из готового образа он создаётся до virt-install) остался: + удаляем его, иначе повторное создание под тем же названием упрётся в «Диск уже существует».""" + disk = Path(disk_path) + if not disk_path or disk.parent.resolve() != IMAGES_DIR.resolve() or not disk.is_file(): + return + disk.unlink(missing_ok=True) + run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20) + append_operation_log(operation_id, f"Недосозданный диск {disk.name} удалён.") + + def run_operation_worker(operation_id: str, cmd: list[str]) -> None: operation = read_operation(operation_id) if not operation: @@ -322,7 +333,13 @@ def run_operation_worker(operation_id: str, cmd: list[str]) -> None: append_operation_log(operation_id, f"virt-install завершился с ошибкой. Exit code: {exit_code}") if fresh.get("vm_name") and fresh.get("network_mode") == "nat": network_core.release_nat_address(fresh["vm_name"]) - update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=f"virt-install завершился с ошибкой: {exit_code}", finished_at=utc_now()) + vm_left = bool(fresh.get("vm_name")) and vm_exists(fresh["vm_name"]) + if not vm_left: + remove_unfinished_disk(operation_id, fresh.get("disk_path") or "") + message = "Машину создать не удалось, причина в журнале ниже." + if not vm_left: + message += " На сервере от неё ничего не осталось, её можно создать заново под тем же названием." + update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=message, finished_at=utc_now()) except Exception as exc: fresh = read_operation(operation_id) or operation append_operation_log(operation_id, f"Ошибка запуска операции: {exc}") @@ -1737,7 +1754,18 @@ def operation_detail_page(request: Request, operation_id: str): operation = read_operation(operation_id) if not operation: return RedirectResponse(url="/operations", status_code=303) - return templates.TemplateResponse(request, "operation_detail.html", {"app_name": APP_NAME, "user": AUTH_USER, "operation": operation, "operation_css": operation_css}) + vm_name = str(operation.get("vm_name") or "") + vm_present = valid_vm_name(vm_name) and vm_exists(vm_name) + return templates.TemplateResponse(request, "operation_detail.html", {"app_name": APP_NAME, "user": AUTH_USER, "operation": operation, "operation_css": operation_css, "vm_present": vm_present, "retry_url": vm_create_retry_url(operation)}) + + +def vm_create_retry_url(operation: dict[str, Any]) -> str: + """Форма создания машины с тем же установочным образом или готовым образом, что у неудачной попытки.""" + if operation.get("iso_path"): + return "/vm/create?iso=" + quote(Path(operation["iso_path"]).name) + if operation.get("disk_image_path"): + return "/vm/create?image=" + quote(Path(operation["disk_image_path"]).name) + return "/vm/create" @app.get("/api/operations") @@ -1924,7 +1952,7 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form success_message = "Машина создана" + (f". {login_hint}" if login_hint else "") + (f". Адрес в сети машин: {nat_ip}" if nat_ip else "") operation_id = str(uuid.uuid4()) - operation = {"id": operation_id, "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": name, "disk_path": str(disk_path), "iso_path": iso_path, "host_profile": profile.get("profile"), "guest_arch": selected_arch, "boot_order": selected_boot_order, "network_mode": network_mode, "network": network_arg, "bridge": bridge, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "os_type": os_type, "cloud_init_user": ci_user if use_cloud_init else "", "nat_ip": nat_ip or "", "seed_file": seed_file, "success_message": success_message, "cmd": " ".join(cmd)} + operation = {"id": operation_id, "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": name, "disk_path": str(disk_path), "iso_path": iso_path, "disk_image_path": disk_image_path if source_type == "disk_image" else "", "host_profile": profile.get("profile"), "guest_arch": selected_arch, "boot_order": selected_boot_order, "network_mode": network_mode, "network": network_arg, "bridge": bridge, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "os_type": os_type, "cloud_init_user": ci_user if use_cloud_init else "", "nat_ip": nat_ip or "", "seed_file": seed_file, "success_message": success_message, "cmd": " ".join(cmd)} start_background_operation(operation, cmd) return RedirectResponse(url=f"/operations/{operation_id}", status_code=303) diff --git a/web/static/panel.js b/web/static/panel.js index 049c878..95022eb 100644 --- a/web/static/panel.js +++ b/web/static/panel.js @@ -383,7 +383,7 @@ messageEl.textContent = op.message || ''; if (logEl) { const atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40; logEl.textContent = op.log_tail || ''; if (atBottom) logEl.scrollTop = logEl.scrollHeight; } render(); - if (done()) { clearInterval(timer); clearInterval(tick); if (!wasDone) { toast(status === 'success' ? 'Операция завершена' : 'Операция завершилась с ошибкой', status === 'success' ? 'success' : 'danger'); $$('[data-op-done]').forEach((el) => { el.hidden = false; }); } } + if (done()) { clearInterval(timer); clearInterval(tick); if (!wasDone) { toast(status === 'success' ? 'Операция завершена' : 'Операция завершилась с ошибкой', status === 'success' ? 'success' : 'danger'); $$(`[data-op-done="${status}"]`).forEach((el) => { el.hidden = false; }); if (status === 'error' && $('[data-op-done="error"][data-op-reload]')) setTimeout(() => location.reload(), 1500); } } } catch (_) {} }; const timer = setInterval(poll, 1500); diff --git a/web/templates/operation_detail.html b/web/templates/operation_detail.html index 784430b..8c12fce 100644 --- a/web/templates/operation_detail.html +++ b/web/templates/operation_detail.html @@ -25,10 +25,17 @@
{{ operation.message }}
{% if operation.vm_name %} -