diff --git a/tests/test_audit_fixes.py b/tests/test_audit_fixes.py index b4eb977..86b7f3d 100644 --- a/tests/test_audit_fixes.py +++ b/tests/test_audit_fixes.py @@ -1,7 +1,7 @@ """Regressions for the defects found by the 0.11 audit.""" import json from pathlib import Path -from urllib.parse import quote +from urllib.parse import quote, unquote import app import conftest @@ -69,6 +69,46 @@ def test_vm_delete_failure_is_reported(logged_in, data_dirs, monkeypatch): 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) + 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") + + +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) def test_iso_mount_uses_change_media_for_existing_drive(logged_in, data_dirs, monkeypatch): iso = data_dirs["iso"] / "debian.iso" diff --git a/web/app.py b/web/app.py index d21ba03..77d7b84 100644 --- a/web/app.py +++ b/web/app.py @@ -2094,14 +2094,21 @@ def vm_action(request: Request, name: str, action: str): if action in ("shutdown", "destroy", "delete"): backups.cancel_late_restart(name) 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) # --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)}") network_core.release_nat_address(name) - return redirect_with_message("/", "message", f"Машина {name} удалена") + removed = network_core.remove_vm_forwards(name) + message = f"Машина {name} удалена" + (f", вместе с ней убраны пробросы портов: {removed}" if removed else "") + return redirect_with_message("/", "message", message) if action not in allowed: return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400) result = run_cmd(allowed[action], timeout=30) diff --git a/web/network_core.py b/web/network_core.py index e806891..86310b3 100644 --- a/web/network_core.py +++ b/web/network_core.py @@ -436,6 +436,17 @@ def add_port_forward(vm_name: str, guest_ip: str, external_port: Any, guest_port 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: items = [item for item in load_port_forwards() if item.get('id') != forward_id] save_port_forwards(items) diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 17d6467..3c2026f 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -101,9 +101,9 @@
{% elif vm.state != 'paused' %} - - {% endif %} + + diff --git a/web/templates/vm_detail.html b/web/templates/vm_detail.html index 5f3aa13..662c2cd 100644 --- a/web/templates/vm_detail.html +++ b/web/templates/vm_detail.html @@ -33,9 +33,8 @@ {% if s.running %} - {% else %} - {% endif %} +