Неудачное создание машины: панель больше не предлагает экран несуществующей машины, даёт «Создать заново» с тем же образом и удаляет недосозданный диск
Этот коммит содержится в:
@@ -1,3 +1,4 @@
|
|||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import unquote
|
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)
|
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]
|
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
|
||||||
|
|||||||
+31
-3
@@ -294,6 +294,17 @@ def progress_from_line(current: int, line: str) -> int:
|
|||||||
return current
|
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:
|
def run_operation_worker(operation_id: str, cmd: list[str]) -> None:
|
||||||
operation = read_operation(operation_id)
|
operation = read_operation(operation_id)
|
||||||
if not operation:
|
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}")
|
append_operation_log(operation_id, f"virt-install завершился с ошибкой. Exit code: {exit_code}")
|
||||||
if fresh.get("vm_name") and fresh.get("network_mode") == "nat":
|
if fresh.get("vm_name") and fresh.get("network_mode") == "nat":
|
||||||
network_core.release_nat_address(fresh["vm_name"])
|
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:
|
except Exception as exc:
|
||||||
fresh = read_operation(operation_id) or operation
|
fresh = read_operation(operation_id) or operation
|
||||||
append_operation_log(operation_id, f"Ошибка запуска операции: {exc}")
|
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)
|
operation = read_operation(operation_id)
|
||||||
if not operation:
|
if not operation:
|
||||||
return RedirectResponse(url="/operations", status_code=303)
|
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")
|
@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 "")
|
success_message = "Машина создана" + (f". {login_hint}" if login_hint else "") + (f". Адрес в сети машин: {nat_ip}" if nat_ip else "")
|
||||||
operation_id = str(uuid.uuid4())
|
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)
|
start_background_operation(operation, cmd)
|
||||||
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
|
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
|
||||||
|
|
||||||
|
|||||||
@@ -383,7 +383,7 @@
|
|||||||
messageEl.textContent = op.message || '';
|
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; }
|
if (logEl) { const atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40; logEl.textContent = op.log_tail || ''; if (atBottom) logEl.scrollTop = logEl.scrollHeight; }
|
||||||
render();
|
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 (_) {}
|
} catch (_) {}
|
||||||
};
|
};
|
||||||
const timer = setInterval(poll, 1500);
|
const timer = setInterval(poll, 1500);
|
||||||
|
|||||||
@@ -25,10 +25,17 @@
|
|||||||
<div class="progress lg {{ 'success' if operation.status == 'success' else 'danger' if operation.status == 'error' else '' }}" data-op="progress" style="height:10px"><span data-op="bar" style="width:{{ operation.progress|default(0) }}%"></span></div>
|
<div class="progress lg {{ 'success' if operation.status == 'success' else 'danger' if operation.status == 'error' else '' }}" data-op="progress" style="height:10px"><span data-op="bar" style="width:{{ operation.progress|default(0) }}%"></span></div>
|
||||||
<p class="muted" data-op="message">{{ operation.message }}</p>
|
<p class="muted" data-op="message">{{ operation.message }}</p>
|
||||||
{% if operation.vm_name %}
|
{% if operation.vm_name %}
|
||||||
<div class="row" data-op-done {% if operation.status != 'success' %}hidden{% endif %}>
|
<div class="row" data-op-done="success" {% if operation.status != 'success' %}hidden{% endif %}>
|
||||||
<a class="btn btn-primary" href="/vm/{{ operation.vm_name }}/console" target="_blank" rel="noopener">{{ icon('monitor') }}Открыть экран машины</a>
|
<a class="btn btn-primary" href="/vm/{{ operation.vm_name }}/console" target="_blank" rel="noopener">{{ icon('monitor') }}Открыть экран машины</a>
|
||||||
<a class="btn" href="/vm/{{ operation.vm_name }}">Перейти к машине</a>
|
<a class="btn" href="/vm/{{ operation.vm_name }}">Перейти к машине</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row" data-op-done="error" {% if operation.type == 'vm_create' %}data-op-reload{% endif %} {% if operation.status != 'error' %}hidden{% endif %}>
|
||||||
|
{% if operation.type == 'vm_create' and not vm_present %}
|
||||||
|
<a class="btn btn-primary" href="{{ retry_url }}">{{ icon('rotate-cw') }}Создать заново</a>
|
||||||
|
{% elif vm_present %}
|
||||||
|
<a class="btn" href="/vm/{{ operation.vm_name }}">Перейти к машине</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user