mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-05 14:48:40 +08:00
fix(update): handle OverlayFS directory swaps
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -995,10 +996,33 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
self._remove_path(current)
|
||||
previous.replace(current)
|
||||
|
||||
def _backup_docker_current(
|
||||
self, current: Path, previous: Path, temporary_root: Path
|
||||
) -> None:
|
||||
"""
|
||||
将 Docker 当前目录移入回滚备份,并兼容 OverlayFS 的目录重命名限制。
|
||||
|
||||
OverlayFS 的镜像层目录可能无法直接 ``rename`` 到 upper 层。此时先完整复制
|
||||
到更新事务的临时目录,复制成功后再把临时备份提升为正式回滚目录,避免将未
|
||||
完成的备份交给回滚逻辑。
|
||||
"""
|
||||
try:
|
||||
current.replace(previous)
|
||||
return
|
||||
except OSError as error:
|
||||
if error.errno != errno.EXDEV:
|
||||
raise
|
||||
|
||||
temporary_previous = temporary_root / f"{current.name}.__update_previous__"
|
||||
shutil.copytree(current, temporary_previous, symlinks=True)
|
||||
self._preserve_tree_ownership(current, temporary_previous)
|
||||
temporary_previous.replace(previous)
|
||||
self._remove_path(current)
|
||||
|
||||
def _apply_docker_application(
|
||||
self, prepared: dict[str, Any], *, include_resources: bool
|
||||
) -> None:
|
||||
"""原子替换 Docker 后端源码和前端静态目录,并同步依赖。"""
|
||||
"""Docker 事务替换后端源码和前端静态目录,并同步依赖。"""
|
||||
app_dir = self._docker_app_dir
|
||||
public_dir = self._docker_public_dir
|
||||
previous_app = self._docker_previous_app_dir
|
||||
@@ -1027,9 +1051,11 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
||||
dependency_sync_started = True
|
||||
self._sync_docker_dependencies(stage_app)
|
||||
self._set_docker_pending("prepared")
|
||||
app_dir.replace(previous_app)
|
||||
self._backup_docker_current(app_dir, previous_app, temporary_root)
|
||||
try:
|
||||
public_dir.replace(previous_public)
|
||||
self._backup_docker_current(
|
||||
public_dir, previous_public, temporary_root
|
||||
)
|
||||
stage_app.replace(app_dir)
|
||||
stage_public.replace(public_dir)
|
||||
except OSError:
|
||||
|
||||
@@ -474,10 +474,21 @@ def test_cancel_install_returns_prepared_update_to_ready(monkeypatch, tmp_path):
|
||||
assert not manager._install_file.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exdev_target", "failure"),
|
||||
[
|
||||
(None, None),
|
||||
("app", None),
|
||||
("public", None),
|
||||
("app", "backup"),
|
||||
("public", "backup"),
|
||||
(None, "stage"),
|
||||
],
|
||||
)
|
||||
def test_apply_prepared_application_replaces_docker_payload_and_preserves_plugins(
|
||||
monkeypatch, tmp_path
|
||||
monkeypatch, tmp_path, exdev_target, failure
|
||||
):
|
||||
"""Docker root worker 应替换前后端目录,同时保留运行时插件和站点资源。"""
|
||||
"""Docker 更新应兼容 OverlayFS,并在备份或切换失败时保留旧载荷。"""
|
||||
manager = _docker_manager(monkeypatch, tmp_path)
|
||||
app_dir = manager._docker_app_dir
|
||||
public_dir = manager._docker_public_dir
|
||||
@@ -540,8 +551,40 @@ def test_apply_prepared_application_replaces_docker_payload_and_preserves_plugin
|
||||
lambda project_dir, **kwargs: sync_calls.append((project_dir, kwargs)),
|
||||
)
|
||||
|
||||
original_replace = Path.replace
|
||||
original_copytree = update_module.shutil.copytree
|
||||
|
||||
def replace(source, target):
|
||||
"""模拟 OverlayFS 目录重命名限制及新载荷切换失败。"""
|
||||
if exdev_target == "app" and source == app_dir:
|
||||
raise OSError(errno.EXDEV, "Invalid cross-device link")
|
||||
if exdev_target == "public" and source == public_dir:
|
||||
raise OSError(errno.EXDEV, "Invalid cross-device link")
|
||||
if failure == "stage" and source.name == "App" and target == app_dir:
|
||||
raise OSError(errno.EIO, "stage install failed")
|
||||
return original_replace(source, target)
|
||||
|
||||
def copytree(source, target, *args, **kwargs):
|
||||
"""模拟 OverlayFS 备份复制失败,确保旧目录未被删除。"""
|
||||
if failure == "backup" and Path(target).name.endswith(".__update_previous__"):
|
||||
raise OSError(errno.ENOSPC, "backup failed")
|
||||
return original_copytree(source, target, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "replace", replace)
|
||||
monkeypatch.setattr(update_module.shutil, "copytree", copytree)
|
||||
|
||||
success, message = manager.apply_prepared_update()
|
||||
|
||||
if failure:
|
||||
assert success is False
|
||||
assert (app_dir / "old.py").read_text(encoding="utf-8") == "old\n"
|
||||
assert (public_dir / "index.html").read_text(encoding="utf-8") == "old-front\n"
|
||||
assert not (app_dir / "new.py").exists()
|
||||
assert (manager._root / "prepared.json").exists()
|
||||
assert not manager._docker_previous_app_dir.exists()
|
||||
assert not manager._docker_previous_public_dir.exists()
|
||||
return
|
||||
|
||||
assert success is True
|
||||
assert message == "已下载的更新已替换到 Docker 程序目录"
|
||||
assert len(sync_calls) == 1
|
||||
|
||||
Reference in New Issue
Block a user