diff --git a/app/adapters/system/update.py b/app/adapters/system/update.py index 3f2b565fc..29209a32f 100644 --- a/app/adapters/system/update.py +++ b/app/adapters/system/update.py @@ -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: diff --git a/tests/test_system_update_manager.py b/tests/test_system_update_manager.py index c59122762..734747cf8 100644 --- a/tests/test_system_update_manager.py +++ b/tests/test_system_update_manager.py @@ -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