Резервные копии: копию нельзя удалить, пока из неё идёт любое восстановление; наблюдатель позднего выключения отменяется
- защита от удаления проверяет все незавершённые восстановления этой копии (под любым именем машины) - наблюдатель, который запускает машину после позднего выключения, снимается при «Выключить»/«Удалить» в панели и при новой копии, и не мешает другим задачам с этой машиной 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 "<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 any(cmd[:2] == ["virsh", "undefine"] for cmd in virsh.calls)
|
||||||
assert not list((fast_worker["backups"] / "web01" / "20260924-0300").glob("restore-*.xml"))
|
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):
|
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)
|
path = make_backup(fast_worker)
|
||||||
restore = backups.start_restore("web01", "20260924-0300", "web02", False) # под другим именем: задача записана на web02
|
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:
|
with pytest.raises(backups.BackupError) as exc:
|
||||||
backups.delete_backup("web01", "20260924-0300")
|
backups.delete_backup("web01", "20260924-0300")
|
||||||
assert "идёт работа" in str(exc.value)
|
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")
|
backups.delete_backup("web01", "20260924-0300")
|
||||||
assert not path.exists()
|
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):
|
if not valid_vm_name(name):
|
||||||
return JSONResponse({"ok": False, "error": "Invalid VM name"}, status_code=400)
|
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]}
|
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 action == "delete":
|
||||||
if vm_state(name) == "running":
|
if vm_state(name) == "running":
|
||||||
run_cmd(["virsh", "destroy", name], timeout=20)
|
run_cmd(["virsh", "destroy", name], timeout=20)
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ COPY_TIMEOUT = 12 * 3600.0 # секунд на копирование одно
|
|||||||
POLL_INTERVAL = 2.0
|
POLL_INTERVAL = 2.0
|
||||||
SPACE_MARGIN = 256 * 1024 * 1024 # запас, чтобы не забить диск под завязку
|
SPACE_MARGIN = 256 * 1024 * 1024 # запас, чтобы не забить диск под завязку
|
||||||
NOTE_MAX = 120
|
NOTE_MAX = 120
|
||||||
RESTORE_MARKER = ".restoring" # файл в папке копии с id задачи восстановления, которая её читает
|
|
||||||
MONTHS = ("января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря")
|
MONTHS = ("января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря")
|
||||||
BACKUP_ID_LOCK = threading.Lock()
|
BACKUP_ID_LOCK = threading.Lock()
|
||||||
|
|
||||||
@@ -305,17 +304,24 @@ def remove_backup_dir(path: Path) -> None:
|
|||||||
pass
|
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:
|
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)):
|
if any(operation.get("backup_id") == backup_id for operation in core.active_operations_for(vm, OPERATION_TYPES)):
|
||||||
return True
|
return True
|
||||||
# Восстановление под другим именем записано на новую машину, поэтому его id лежит меткой в папке копии.
|
return bool(unfinished_restores_of(vm, backup_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"))
|
|
||||||
|
|
||||||
|
|
||||||
def delete_backup(vm: str, backup_id: str) -> None:
|
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 копия делается
|
поднимают BackupError с понятным текстом. С wait=True копия делается
|
||||||
в текущем потоке — удобно для расписания."""
|
в текущем потоке — удобно для расписания."""
|
||||||
|
cancel_late_restart(vm_name)
|
||||||
if not core.valid_vm_name(vm_name):
|
if not core.valid_vm_name(vm_name):
|
||||||
raise BackupError("Некорректное имя машины")
|
raise BackupError("Некорректное имя машины")
|
||||||
note = clean_note(note)
|
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:
|
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
|
deadline = time.monotonic() + LATE_SHUTDOWN_WATCH
|
||||||
|
try:
|
||||||
while time.monotonic() < deadline:
|
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 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"], "Машина всё-таки выключилась — запускаем её снова.")
|
core.append_operation_log(operation["id"], "Машина всё-таки выключилась — запускаем её снова.")
|
||||||
start_vm(operation, vm)
|
start_vm(operation, vm)
|
||||||
return
|
return
|
||||||
core.append_operation_log(operation["id"], "Машина так и не выключилась — оставляем её работать.")
|
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:
|
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"]
|
backup = plan["backup"]
|
||||||
title = f"Восстановление {plan['new_name']} из копии"
|
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"])
|
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)
|
core.run_operation(operation, restore_worker)
|
||||||
return operation
|
return operation
|
||||||
|
|
||||||
@@ -730,9 +754,6 @@ def restore_worker(operation: dict[str, Any]) -> None:
|
|||||||
log(f"Ошибка: {exc}")
|
log(f"Ошибка: {exc}")
|
||||||
core.finish_operation(operation, False, str(exc)[:240])
|
core.finish_operation(operation, False, str(exc)[:240])
|
||||||
return
|
return
|
||||||
finally:
|
|
||||||
if backup:
|
|
||||||
(Path(backup["path"]) / RESTORE_MARKER).unlink(missing_ok=True)
|
|
||||||
created_label = backup["created_label"] if backup else ""
|
created_label = backup["created_label"] if backup else ""
|
||||||
core.finish_operation(operation, True, f"Машина {new_name} восстановлена из копии от {created_label}. Она выключена — запустите её, когда будете готовы.")
|
core.finish_operation(operation, True, f"Машина {new_name} восстановлена из копии от {created_label}. Она выключена — запустите её, когда будете готовы.")
|
||||||
|
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user