Операции, прерванные перезапуском панели, закрываются при старте; снимки: исправления по ревью

- 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
Этот коммит содержится в:
Claude
2026-09-25 15:05:49 +00:00
родитель 2b7223f311
Коммит eb7b71f478
6 изменённых файлов: 96 добавлений и 10 удалений
+22
Просмотреть файл
@@ -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"]
+2 -1
Просмотреть файл
@@ -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")
+7
Просмотреть файл
@@ -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)
+35
Просмотреть файл
@@ -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)]
+24 -7
Просмотреть файл
@@ -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)
+6 -2
Просмотреть файл
@@ -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>