Операции, прерванные перезапуском панели, закрываются при старте; снимки: исправления по ревью
- core.interrupt_orphaned_operations() при запуске панели, active_operations_for() по всем задачам - снимки: имена с пробелами, снимки вне панели без кнопок, --force только на «revert requires force», память на паузе учитывается, защита от двойного нажатия, надпись «Создаём снимок…» Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
@@ -122,3 +122,25 @@ def test_ufw_rules_follow_forwards(data_dirs, monkeypatch):
|
|||||||
assert find(calls, "ufw", "delete", "allow", "2222/tcp")
|
assert find(calls, "ufw", "delete", "allow", "2222/tcp")
|
||||||
assert find(calls, "ufw", "route", "delete", "allow", "in", "on", "eth0")
|
assert find(calls, "ufw", "route", "delete", "allow", "in", "on", "eth0")
|
||||||
assert json.loads(network_core.UFW_STATE_FILE.read_text()) == []
|
assert json.loads(network_core.UFW_STATE_FILE.read_text()) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- operations orphaned by a restart
|
||||||
|
def test_orphaned_operations_are_closed_on_startup(data_dirs):
|
||||||
|
import core
|
||||||
|
|
||||||
|
running = core.new_operation("snapshot_create", "Снимок web01", vm_name="web01")
|
||||||
|
core.update_operation(running, status="running")
|
||||||
|
done = core.new_operation("backup", "Копия web01", vm_name="web01")
|
||||||
|
core.finish_operation(done, True, "Готово")
|
||||||
|
assert core.active_operations_for("web01")
|
||||||
|
assert core.interrupt_orphaned_operations() == 1
|
||||||
|
assert core.read_operation(running["id"])["status"] == "error" and core.read_operation(running["id"])["interrupted"]
|
||||||
|
assert core.read_operation(done["id"])["status"] == "success"
|
||||||
|
assert core.active_operations_for("web01") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_names_with_spaces_are_listed():
|
||||||
|
from features import snapshots
|
||||||
|
|
||||||
|
rows = snapshots.parse_snapshot_list(" Name Creation Time State\n------\n before update 2026-09-25 10:12:03 +0000 shutoff\n clean 2026-09-10 12:05:00 +0000 running\n")
|
||||||
|
assert [row["name"] for row in rows] == ["before update", "clean"]
|
||||||
|
|||||||
@@ -273,7 +273,8 @@ def test_delete_snapshot_error(logged_in, data_dirs, recorder, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_snapshot_routes_validate_names(logged_in, data_dirs, recorder):
|
def test_snapshot_routes_validate_names(logged_in, data_dirs, recorder):
|
||||||
assert logged_in.post("/vm/web01/snapshots/-bad/revert", follow_redirects=False).status_code == 400
|
bad = logged_in.post("/vm/web01/snapshots/-bad/revert", follow_redirects=False)
|
||||||
|
assert bad.status_code == 303 and "snapshot_error=" in bad.headers["location"]
|
||||||
assert logged_in.post("/vm/web01/snapshots/..%2Fx/delete", follow_redirects=False).status_code in (400, 404)
|
assert logged_in.post("/vm/web01/snapshots/..%2Fx/delete", follow_redirects=False).status_code in (400, 404)
|
||||||
assert logged_in.post("/vm/-bad/snapshots/create", data={}, follow_redirects=False).status_code == 400
|
assert logged_in.post("/vm/-bad/snapshots/create", data={}, follow_redirects=False).status_code == 400
|
||||||
assert not find(recorder, "virsh", "snapshot-delete") and not find(recorder, "virsh", "snapshot-revert")
|
assert not find(recorder, "virsh", "snapshot-delete") and not find(recorder, "virsh", "snapshot-revert")
|
||||||
|
|||||||
@@ -2007,6 +2007,13 @@ def api_health(request: Request):
|
|||||||
# Feature modules register template globals into app.templates, so they are imported once it exists.
|
# Feature modules register template globals into app.templates, so they are imported once it exists.
|
||||||
from features import backups, downloads, settings, setup, snapshots, vmops # noqa: E402
|
from features import backups, downloads, settings, setup, snapshots, vmops # noqa: E402
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def close_orphaned_operations() -> None:
|
||||||
|
import core
|
||||||
|
|
||||||
|
core.interrupt_orphaned_operations()
|
||||||
|
|
||||||
|
|
||||||
app.include_router(setup.router)
|
app.include_router(setup.router)
|
||||||
app.include_router(settings.router)
|
app.include_router(settings.router)
|
||||||
app.include_router(snapshots.router)
|
app.include_router(snapshots.router)
|
||||||
|
|||||||
@@ -271,6 +271,41 @@ def list_operations(limit: int = 25) -> list[dict[str, Any]]:
|
|||||||
return operations
|
return operations
|
||||||
|
|
||||||
|
|
||||||
|
def interrupt_orphaned_operations() -> int:
|
||||||
|
"""Operations run in threads of the panel process: after a restart the ones still marked
|
||||||
|
queued/running will never finish. Close them so they do not block the next attempt."""
|
||||||
|
if not OPERATIONS_DIR.exists():
|
||||||
|
return 0
|
||||||
|
count = 0
|
||||||
|
for path in OPERATIONS_DIR.glob("*.json"):
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
if data.get("status") not in ("queued", "running"):
|
||||||
|
continue
|
||||||
|
data.pop("log_tail", None)
|
||||||
|
message = "Прервано: панель перезапускалась во время операции. Проверьте состояние машины и запустите действие снова."
|
||||||
|
update_operation(data, status="error", progress=100, message=message, finished_at=utc_now(), interrupted=True)
|
||||||
|
append_operation_log(data["id"], message)
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def active_operations_for(vm_name: str, kinds: tuple[str, ...] | set[str] | None = None) -> list[dict[str, Any]]:
|
||||||
|
"""Unfinished operations of one machine, over the whole operations directory (not only the latest N)."""
|
||||||
|
ensure_operations_dir()
|
||||||
|
found = []
|
||||||
|
for path in OPERATIONS_DIR.glob("*.json"):
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
if data.get("vm_name") == vm_name and data.get("status") in ("queued", "running") and (kinds is None or data.get("type") in kinds):
|
||||||
|
found.append(data)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
def running_operations(kind: str | None = None) -> list[dict[str, Any]]:
|
def running_operations(kind: str | None = None) -> list[dict[str, Any]]:
|
||||||
return [op for op in list_operations(50) if op.get("status") in ("queued", "running") and (kind is None or op.get("type") == kind)]
|
return [op for op in list_operations(50) if op.get("status") in ("queued", "running") and (kind is None or op.get("type") == kind)]
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ libvirt через `virsh snapshot-*`.
|
|||||||
только диск. Создание и возврат выполняются как фоновые операции.
|
только диск. Создание и возврат выполняются как фоновые операции.
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -15,6 +16,7 @@ from fastapi.responses import JSONResponse, RedirectResponse
|
|||||||
|
|
||||||
import presenters
|
import presenters
|
||||||
from core import (
|
from core import (
|
||||||
|
active_operations_for,
|
||||||
append_operation_log,
|
append_operation_log,
|
||||||
cmd_error,
|
cmd_error,
|
||||||
finish_operation,
|
finish_operation,
|
||||||
@@ -62,7 +64,7 @@ KNOWN_ERRORS = [
|
|||||||
("is not running", "Машина выключена, поэтому это действие сейчас недоступно."),
|
("is not running", "Машина выключена, поэтому это действие сейчас недоступно."),
|
||||||
]
|
]
|
||||||
|
|
||||||
_ROW_RE = re.compile(r"^\s*(\S+)\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})(?:\s+([+-]\d{4}))?\s+(\S+)\s*$")
|
_ROW_RE = re.compile(r"^\s*(\S.*?)\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})(?:\s+([+-]\d{4}))?\s+(\S+)\s*$")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- parsers
|
# ---------------------------------------------------------------- parsers
|
||||||
@@ -181,6 +183,8 @@ def list_snapshots(vm: str) -> list[dict[str, Any]]:
|
|||||||
row["state_info"] = snapshot_state(row["state"])
|
row["state_info"] = snapshot_state(row["state"])
|
||||||
row["when"] = format_when(row["created_at"])
|
row["when"] = format_when(row["created_at"])
|
||||||
row["title"] = row["description"] or row["name"]
|
row["title"] = row["description"] or row["name"]
|
||||||
|
# Snapshots made outside the panel (virsh, virt-manager) may have names the panel's URLs do not accept.
|
||||||
|
row["manageable"] = valid_label(row["name"])
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@@ -194,7 +198,7 @@ def vm_operations(vm: str) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
def active_operations(vm: str) -> list[dict[str, Any]]:
|
def active_operations(vm: str) -> list[dict[str, Any]]:
|
||||||
return [op for op in vm_operations(vm) if op.get("status") in ("queued", "running")]
|
return active_operations_for(vm, OPERATION_TYPES)
|
||||||
|
|
||||||
|
|
||||||
def create_blockers(vm: str, snapshots: list[dict[str, Any]], checks: dict[str, Any]) -> list[str]:
|
def create_blockers(vm: str, snapshots: list[dict[str, Any]], checks: dict[str, Any]) -> list[str]:
|
||||||
@@ -239,7 +243,7 @@ def revert_worker(operation: dict[str, Any]) -> None:
|
|||||||
update_operation(operation, progress=15, message="Возвращаем машину к сохранённому состоянию…", cmd=" ".join(cmd))
|
update_operation(operation, progress=15, message="Возвращаем машину к сохранённому состоянию…", cmd=" ".join(cmd))
|
||||||
result = run_cmd(cmd, timeout=SNAPSHOT_TIMEOUT)
|
result = run_cmd(cmd, timeout=SNAPSHOT_TIMEOUT)
|
||||||
_log_result(operation, result)
|
_log_result(operation, result)
|
||||||
if not result["ok"] and "force" in (result.get("stderr") or "").lower():
|
if not result["ok"] and "revert requires force" in (result.get("stderr") or "").lower():
|
||||||
# libvirt просит --force, когда конфигурация машины изменилась после снимка: пользователь уже подтвердил возврат.
|
# libvirt просит --force, когда конфигурация машины изменилась после снимка: пользователь уже подтвердил возврат.
|
||||||
append_operation_log(operation["id"], "libvirt требует подтверждения (--force): повторяем принудительно.")
|
append_operation_log(operation["id"], "libvirt требует подтверждения (--force): повторяем принудительно.")
|
||||||
cmd.append("--force")
|
cmd.append("--force")
|
||||||
@@ -275,7 +279,7 @@ def snapshots_page(request: Request, name: str):
|
|||||||
info = presenters.parse_dominfo(dominfo)
|
info = presenters.parse_dominfo(dominfo)
|
||||||
snapshots = list_snapshots(name)
|
snapshots = list_snapshots(name)
|
||||||
checks = domain_checks(name)
|
checks = domain_checks(name)
|
||||||
running = presenters.vm_state(info["state"])["running"]
|
running = vm_active(info["state"])
|
||||||
op_id = request.query_params.get("op", "")
|
op_id = request.query_params.get("op", "")
|
||||||
operation = read_operation(op_id) if op_id else None
|
operation = read_operation(op_id) if op_id else None
|
||||||
if operation and (operation.get("vm_name") != name or operation.get("type") not in OPERATION_TYPES):
|
if operation and (operation.get("vm_name") != name or operation.get("type") not in OPERATION_TYPES):
|
||||||
@@ -297,8 +301,21 @@ def snapshots_page(request: Request, name: str):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def vm_active(state: str) -> bool:
|
||||||
|
"""Running or paused: libvirt saves the memory of both into the snapshot."""
|
||||||
|
return presenters.vm_state(state)["running"] or "paus" in (state or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
_create_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/vm/{name}/snapshots/create")
|
@router.post("/vm/{name}/snapshots/create")
|
||||||
def snapshot_create(request: Request, name: str, description: str = Form("")):
|
def snapshot_create(request: Request, name: str, description: str = Form("")):
|
||||||
|
with _create_lock: # two quick clicks must not both pass the checks below
|
||||||
|
return _snapshot_create(request, name, description)
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_create(request: Request, name: str, description: str):
|
||||||
blocked, dominfo = _guard(request, name)
|
blocked, dominfo = _guard(request, name)
|
||||||
if blocked:
|
if blocked:
|
||||||
return blocked
|
return blocked
|
||||||
@@ -306,7 +323,7 @@ def snapshot_create(request: Request, name: str, description: str = Form("")):
|
|||||||
blockers = create_blockers(name, snapshots, domain_checks(name))
|
blockers = create_blockers(name, snapshots, domain_checks(name))
|
||||||
if blockers:
|
if blockers:
|
||||||
return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", blockers[0])
|
return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", blockers[0])
|
||||||
running = presenters.vm_state(presenters.parse_dominfo(dominfo)["state"])["running"]
|
running = vm_active(presenters.parse_dominfo(dominfo)["state"])
|
||||||
snap = next_snapshot_name([s["name"] for s in snapshots])
|
snap = next_snapshot_name([s["name"] for s in snapshots])
|
||||||
if not valid_label(snap):
|
if not valid_label(snap):
|
||||||
return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Не удалось подобрать имя снимка.")
|
return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Не удалось подобрать имя снимка.")
|
||||||
@@ -321,7 +338,7 @@ def snapshot_revert(request: Request, name: str, snap: str):
|
|||||||
if blocked:
|
if blocked:
|
||||||
return blocked
|
return blocked
|
||||||
if not valid_label(snap):
|
if not valid_label(snap):
|
||||||
return JSONResponse({"ok": False, "error": "Invalid snapshot name"}, status_code=400)
|
return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Этот снимок создан вне панели и имеет необычное имя — управляйте им через virsh.")
|
||||||
if active_operations(name):
|
if active_operations(name):
|
||||||
return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Дождитесь окончания предыдущей операции со снимками этой машины.")
|
return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Дождитесь окончания предыдущей операции со снимками этой машины.")
|
||||||
operation = new_operation("snapshot_revert", f"Возврат машины {name} к снимку {snap}", vm_name=name, snapshot=snap)
|
operation = new_operation("snapshot_revert", f"Возврат машины {name} к снимку {snap}", vm_name=name, snapshot=snap)
|
||||||
@@ -335,7 +352,7 @@ def snapshot_delete(request: Request, name: str, snap: str):
|
|||||||
if blocked:
|
if blocked:
|
||||||
return blocked
|
return blocked
|
||||||
if not valid_label(snap):
|
if not valid_label(snap):
|
||||||
return JSONResponse({"ok": False, "error": "Invalid snapshot name"}, status_code=400)
|
return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Этот снимок создан вне панели и имеет необычное имя — управляйте им через virsh.")
|
||||||
if active_operations(name):
|
if active_operations(name):
|
||||||
return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Дождитесь окончания предыдущей операции со снимками этой машины.")
|
return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Дождитесь окончания предыдущей операции со снимками этой машины.")
|
||||||
result = run_cmd(["virsh", "snapshot-delete", "--domain", name, "--snapshotname", snap], timeout=180)
|
result = run_cmd(["virsh", "snapshot-delete", "--domain", name, "--snapshotname", snap], timeout=180)
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="meta">{{ snap.when }} · {{ snap.state_info.label }}{% if snap.with_memory %} — сохранена вместе с памятью{% endif %}{% if snap.description %} · <span class="mono">{{ snap.name }}</span>{% endif %}</div>
|
<div class="meta">{{ snap.when }} · {{ snap.state_info.label }}{% if snap.with_memory %} — сохранена вместе с памятью{% endif %}{% if snap.description %} · <span class="mono">{{ snap.name }}</span>{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if snap.manageable %}
|
||||||
<form method="post" action="/vm/{{ vm.name }}/snapshots/{{ snap.name }}/revert" class="inline-form"
|
<form method="post" action="/vm/{{ vm.name }}/snapshots/{{ snap.name }}/revert" class="inline-form"
|
||||||
data-confirm="Машина {{ vm.name }} вернётся в состояние на момент «{{ snap.when|lower }}». Всё, что изменилось после этого — файлы, программы, настройки — будет потеряно без возможности восстановления. {% if snap.with_memory %}После возврата машина будет работать, как в момент снимка.{% else %}После возврата машина будет выключена.{% endif %}"
|
data-confirm="Машина {{ vm.name }} вернётся в состояние на момент «{{ snap.when|lower }}». Всё, что изменилось после этого — файлы, программы, настройки — будет потеряно без возможности восстановления. {% if snap.with_memory %}После возврата машина будет работать, как в момент снимка.{% else %}После возврата машина будет выключена.{% endif %}"
|
||||||
data-confirm-title="Вернуться к этому снимку?" data-confirm-button="Вернуться">
|
data-confirm-title="Вернуться к этому снимку?" data-confirm-button="Вернуться">
|
||||||
@@ -79,6 +80,9 @@
|
|||||||
data-confirm-title="Удалить снимок?" data-confirm-button="Удалить" data-toast="Снимок удалён" data-toast-tone="warning">
|
data-confirm-title="Удалить снимок?" data-confirm-button="Удалить" data-toast="Снимок удалён" data-toast-tone="warning">
|
||||||
<button class="btn btn-sm btn-icon btn-ghost" aria-label="Удалить снимок" title="Удалить снимок">{{ icon('trash-2', 'icon-sm') }}</button>
|
<button class="btn btn-sm btn-icon btn-ghost" aria-label="Удалить снимок" title="Удалить снимок">{{ icon('trash-2', 'icon-sm') }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="subtle small" title="Снимок создан вне панели — управляйте им через virsh">Создан вне панели</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +109,7 @@
|
|||||||
<aside class="stack">
|
<aside class="stack">
|
||||||
<article class="card" id="create">
|
<article class="card" id="create">
|
||||||
<div class="card-header"><div><h2>Создать снимок</h2><p>Запомнить состояние машины прямо сейчас</p></div></div>
|
<div class="card-header"><div><h2>Создать снимок</h2><p>Запомнить состояние машины прямо сейчас</p></div></div>
|
||||||
<form method="post" action="/vm/{{ vm.name }}/snapshots/create" data-busy="Создаём снимок…">
|
<form method="post" action="/vm/{{ vm.name }}/snapshots/create">
|
||||||
<div class="card-body stack-sm">
|
<div class="card-body stack-sm">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="description">Описание</label>
|
<label for="description">Описание</label>
|
||||||
@@ -125,7 +129,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<span class="subtle small" style="margin-right:auto">{{ count }} из {{ max_snapshots }}</span>
|
<span class="subtle small" style="margin-right:auto">{{ count }} из {{ max_snapshots }}</span>
|
||||||
<button type="submit" class="btn btn-primary" {% if blockers %}disabled{% endif %}>{{ icon('camera') }}Создать снимок</button>
|
<button type="submit" class="btn btn-primary" data-busy="Создаём снимок…" {% if blockers %}disabled{% endif %}>{{ icon('camera') }}Создать снимок</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user