Резервные копии: копию нельзя удалить, пока из неё идёт любое восстановление; наблюдатель позднего выключения отменяется
- защита от удаления проверяет все незавершённые восстановления этой копии (под любым именем машины) - наблюдатель, который запускает машину после позднего выключения, снимается при «Выключить»/«Удалить» в панели и при новой копии, и не мешает другим задачам с этой машиной Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
@@ -401,7 +401,6 @@ def test_restore_under_new_name(fast_worker, monkeypatch):
|
||||
assert "<name>web02</name>" in virsh.defined_xml and str(target) in virsh.defined_xml and "<mac" not in virsh.defined_xml
|
||||
assert not any(cmd[:2] == ["virsh", "undefine"] for cmd in virsh.calls)
|
||||
assert not list((fast_worker["backups"] / "web01" / "20260924-0300").glob("restore-*.xml"))
|
||||
assert not (fast_worker["backups"] / "web01" / "20260924-0300" / backups.RESTORE_MARKER).exists()
|
||||
|
||||
|
||||
def test_restore_requires_replace_confirmation(fast_worker):
|
||||
@@ -613,10 +612,34 @@ def test_delete_backup_refused_while_copy_is_in_use(fast_worker, monkeypatch):
|
||||
|
||||
path = make_backup(fast_worker)
|
||||
restore = backups.start_restore("web01", "20260924-0300", "web02", False) # под другим именем: задача записана на web02
|
||||
assert (path / backups.RESTORE_MARKER).read_text() == restore["id"]
|
||||
second = backups.start_restore("web01", "20260924-0300", "web03", False) # две распаковки одной копии сразу
|
||||
with pytest.raises(backups.BackupError) as exc:
|
||||
backups.delete_backup("web01", "20260924-0300")
|
||||
assert "идёт работа" in str(exc.value)
|
||||
core.finish_operation(restore, False, "прервано") # устаревшая метка после перезапуска панели не мешает
|
||||
core.finish_operation(second, False, "прервано") # первая ещё идёт — копию по-прежнему нельзя удалить
|
||||
with pytest.raises(backups.BackupError):
|
||||
backups.delete_backup("web01", "20260924-0300")
|
||||
core.finish_operation(restore, False, "прервано")
|
||||
backups.delete_backup("web01", "20260924-0300")
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_late_restart_watch_is_cancelled_by_user_action(fast_worker, monkeypatch):
|
||||
import threading
|
||||
|
||||
virsh = FakeVirsh(running=True, shuts_down=False)
|
||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
monkeypatch.setattr(backups, "LATE_SHUTDOWN_WATCH", 5.0)
|
||||
watchers = []
|
||||
monkeypatch.setattr(backups, "start_thread", lambda target, *args: watchers.append(threading.Thread(target=target, args=args)) or watchers[-1].start())
|
||||
operation = backups.create_backup("web01")
|
||||
for _ in range(200): # наблюдатель запущен и ждёт
|
||||
if "web01" in backups.LATE_WATCHES:
|
||||
break
|
||||
__import__("time").sleep(0.01)
|
||||
backups.cancel_late_restart("web01") # пользователь нажал «Выключить» в панели
|
||||
virsh.running = False
|
||||
watchers[0].join(timeout=5)
|
||||
assert not watchers[0].is_alive() and ["virsh", "start", "web01"] not in virsh.calls
|
||||
assert "Наблюдение снято" in core.read_operation(operation["id"])["log_tail"]
|
||||
|
||||
@@ -2027,6 +2027,8 @@ def vm_action(request: Request, name: str, action: str):
|
||||
if not valid_vm_name(name):
|
||||
return JSONResponse({"ok": False, "error": "Invalid VM name"}, status_code=400)
|
||||
allowed = {"start": ["virsh", "start", name], "shutdown": ["virsh", "shutdown", name], "reboot": ["virsh", "reboot", name], "destroy": ["virsh", "destroy", name], "autostart": ["virsh", "autostart", name], "autostart-disable": ["virsh", "autostart", "--disable", name]}
|
||||
if action in ("shutdown", "destroy", "delete"):
|
||||
backups.cancel_late_restart(name)
|
||||
if action == "delete":
|
||||
if vm_state(name) == "running":
|
||||
run_cmd(["virsh", "destroy", name], timeout=20)
|
||||
|
||||
@@ -42,7 +42,6 @@ COPY_TIMEOUT = 12 * 3600.0 # секунд на копирование одно
|
||||
POLL_INTERVAL = 2.0
|
||||
SPACE_MARGIN = 256 * 1024 * 1024 # запас, чтобы не забить диск под завязку
|
||||
NOTE_MAX = 120
|
||||
RESTORE_MARKER = ".restoring" # файл в папке копии с id задачи восстановления, которая её читает
|
||||
MONTHS = ("января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря")
|
||||
BACKUP_ID_LOCK = threading.Lock()
|
||||
|
||||
@@ -305,17 +304,24 @@ def remove_backup_dir(path: Path) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def unfinished_restores_of(vm: str, backup_id: str) -> list[dict[str, Any]]:
|
||||
"""Все незавершённые восстановления из этой копии — под любым именем машины (их может быть несколько)."""
|
||||
found = []
|
||||
for path in core.OPERATIONS_DIR.glob("*.json") if core.OPERATIONS_DIR.exists() else []:
|
||||
try:
|
||||
operation = json.loads(path.read_text())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if operation.get("type") == "restore" and operation.get("status") in ("queued", "running") and operation.get("source_vm") == vm and operation.get("backup_id") == backup_id:
|
||||
found.append(operation)
|
||||
return found
|
||||
|
||||
|
||||
def backup_in_use(vm: str, backup_id: str, path: Path) -> bool:
|
||||
"""Пишет ли сейчас эту копию задача копирования или читает задача восстановления."""
|
||||
if any(operation.get("backup_id") == backup_id for operation in core.active_operations_for(vm, OPERATION_TYPES)):
|
||||
return True
|
||||
# Восстановление под другим именем записано на новую машину, поэтому его id лежит меткой в папке копии.
|
||||
try:
|
||||
marker = (path / RESTORE_MARKER).read_text().strip()
|
||||
except OSError:
|
||||
return False
|
||||
operation = core.read_operation(marker)
|
||||
return bool(operation and operation.get("status") in ("queued", "running"))
|
||||
return bool(unfinished_restores_of(vm, backup_id))
|
||||
|
||||
|
||||
def delete_backup(vm: str, backup_id: str) -> None:
|
||||
@@ -358,6 +364,7 @@ def create_backup(vm_name: str, note: str = "", wait: bool = False) -> dict[str,
|
||||
Проверки (машина есть, диски есть, места хватает) выполняются сразу и
|
||||
поднимают BackupError с понятным текстом. С wait=True копия делается
|
||||
в текущем потоке — удобно для расписания."""
|
||||
cancel_late_restart(vm_name)
|
||||
if not core.valid_vm_name(vm_name):
|
||||
raise BackupError("Некорректное имя машины")
|
||||
note = clean_note(note)
|
||||
@@ -445,16 +452,37 @@ def shutdown_and_wait(operation: dict[str, Any], vm: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
LATE_WATCHES: dict[str, threading.Event] = {}
|
||||
|
||||
|
||||
def cancel_late_restart(vm: str) -> None:
|
||||
"""Пользователь сам выключает машину или снова делает копию — наблюдатель не должен её запускать."""
|
||||
event = LATE_WATCHES.pop(vm, None)
|
||||
if event:
|
||||
event.set()
|
||||
|
||||
|
||||
def restart_after_late_shutdown(operation: dict[str, Any], vm: str) -> None:
|
||||
"""Команда выключения уже ушла в машину: если она всё же выключится с опозданием, вернуть её в работу."""
|
||||
cancel_late_restart(vm)
|
||||
cancelled = LATE_WATCHES.setdefault(vm, threading.Event())
|
||||
deadline = time.monotonic() + LATE_SHUTDOWN_WATCH
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(POLL_INTERVAL)
|
||||
if cancelled.wait(POLL_INTERVAL):
|
||||
core.append_operation_log(operation["id"], "Наблюдение снято: машиной занялись вручную или новой задачей.")
|
||||
return
|
||||
if not core.vm_is_running(vm):
|
||||
if any(other["id"] != operation["id"] for other in core.active_operations_for(vm, OPERATION_TYPES)):
|
||||
core.append_operation_log(operation["id"], "Машина выключилась, но с ней уже работает другая задача — не запускаем.")
|
||||
return
|
||||
core.append_operation_log(operation["id"], "Машина всё-таки выключилась — запускаем её снова.")
|
||||
start_vm(operation, vm)
|
||||
return
|
||||
core.append_operation_log(operation["id"], "Машина так и не выключилась — оставляем её работать.")
|
||||
finally:
|
||||
if LATE_WATCHES.get(vm) is cancelled:
|
||||
LATE_WATCHES.pop(vm, None)
|
||||
|
||||
|
||||
def save_paused(operation: dict[str, Any], vm: str) -> str:
|
||||
@@ -645,10 +673,6 @@ def start_restore(vm: str, backup_id: str, new_name: str, replace: bool) -> dict
|
||||
backup = plan["backup"]
|
||||
title = f"Восстановление {plan['new_name']} из копии"
|
||||
operation = core.new_operation("restore", title, "Ожидает начала", vm_name=plan["new_name"], source_vm=vm, backup_id=backup_id, replace=plan["replace"], disks=backup["disks"], size_bytes=plan["need"])
|
||||
try:
|
||||
(Path(backup["path"]) / RESTORE_MARKER).write_text(operation["id"], encoding="utf-8") # чтобы копию не удалили, пока её читают
|
||||
except OSError:
|
||||
pass
|
||||
core.run_operation(operation, restore_worker)
|
||||
return operation
|
||||
|
||||
@@ -730,9 +754,6 @@ def restore_worker(operation: dict[str, Any]) -> None:
|
||||
log(f"Ошибка: {exc}")
|
||||
core.finish_operation(operation, False, str(exc)[:240])
|
||||
return
|
||||
finally:
|
||||
if backup:
|
||||
(Path(backup["path"]) / RESTORE_MARKER).unlink(missing_ok=True)
|
||||
created_label = backup["created_label"] if backup else ""
|
||||
core.finish_operation(operation, True, f"Машина {new_name} восстановлена из копии от {created_label}. Она выключена — запустите её, когда будете готовы.")
|
||||
|
||||
|
||||
Ссылка в новой задаче
Block a user