From 240f43b3276695e59cef6a8ee81fc3c43f519401 Mon Sep 17 00:00:00 2001 From: "moviepilot-codex[bot]" Date: Mon, 21 Sep 2026 15:49:21 +0800 Subject: [PATCH] fix(update): converge stale state and Docker transaction --- app/adapters/system/update.py | 16 ++++---- docker/launcher.sh | 3 +- docker/update.sh | 4 +- docs/docker-startup.md | 12 ++++-- tests/test_docker_bootstrap.py | 5 +++ tests/test_docker_payload_contract.py | 12 ++++++ tests/test_system_update_manager.py | 55 +++++++++++++++++++++++++++ 7 files changed, 93 insertions(+), 14 deletions(-) diff --git a/app/adapters/system/update.py b/app/adapters/system/update.py index 5a1b0568c..49a96710f 100644 --- a/app/adapters/system/update.py +++ b/app/adapters/system/update.py @@ -317,16 +317,13 @@ class SystemUpdateManager(metaclass=SingletonClass): } ) changed = True - elif item.get("state") == "installing" and self._is_install_applied(item, target): - self._reset_item_after_install(item) - changed = True elif ( - target == _RESOURCES - and item.get("state") == "ready" + item.get("state") in {"installing", "ready"} and self._is_install_applied(item, target) ): - # 容器替换或手工安装可能先让运行资源达到目标版本,需丢弃残留待安装包。 - self._discard_prepared_target(target) + if item.get("state") == "ready": + # 容器替换或手工安装可能先让运行版本达到目标,需丢弃残留待安装包。 + self._discard_prepared_target(target) self._reset_item_after_install(item) changed = True resources_changed = ( @@ -344,7 +341,10 @@ class SystemUpdateManager(metaclass=SingletonClass): def _is_install_applied(self, item: dict[str, Any], target: SystemUpdateType) -> bool: """判断启动器应用后的当前版本是否已经达到安装目标。""" if target == _APPLICATION: - return bool(item.get("version")) and item["version"] == get_app_version() + target_version = str(item.get("version") or "") + return bool(target_version) and compare_version( + get_app_version(), ">=", target_version + ) is True current_auth, current_indexer = get_resource_versions() checks = [] if item.get("auth_version"): diff --git a/docker/launcher.sh b/docker/launcher.sh index d23a885e3..7d67ada55 100755 --- a/docker/launcher.sh +++ b/docker/launcher.sh @@ -12,7 +12,8 @@ export PATH SOURCE_CONTROL_DIR="${MOVIEPILOT_SOURCE_CONTROL_DIR:-/app/docker}" IMAGE_CONTROL_DIR="${MOVIEPILOT_IMAGE_CONTROL_DIR:-/usr/local/lib/moviepilot/control}" RUNTIME_ROOT="${MOVIEPILOT_RUNTIME_CONTROL_ROOT:-/run/moviepilot/control}" -UPDATE_PENDING_FILE="${CONFIG_DIR:-/config}/temp/__update_pending__" +# 与 update.sh 共用容器可写层中的 Dev 事务标记;容器重建时不保留旧半成品状态。 +UPDATE_PENDING_FILE="${MOVIEPILOT_UPDATE_PENDING_FILE:-/var/lib/moviepilot/update/__update_pending__}" UPDATE_PREVIOUS_APP="/app.__update_previous__" CONTROL_FILES=() CONTROL_REQUIRED_FILES=(entrypoint.sh update.sh browser.sh cert.sh) diff --git a/docker/update.sh b/docker/update.sh index 5e7bf227f..4540be617 100644 --- a/docker/update.sh +++ b/docker/update.sh @@ -28,7 +28,9 @@ UV_BIN="${UV_BIN:-/usr/local/bin/uv}" CONFIG_DIR="${CONFIG_DIR:-/config}" APP_DIR=/app PUBLIC_DIR=/public -UPDATE_PENDING_FILE="${CONFIG_DIR}/temp/__update_pending__" +# Dev 更新只需要跨同一容器重启恢复;标记必须和 /app、/public 位于同一容器可写层, +# 避免容器重建后 /config 遗留旧标记阻断新镜像启动。 +UPDATE_PENDING_FILE="${MOVIEPILOT_UPDATE_PENDING_FILE:-/var/lib/moviepilot/update/__update_pending__}" UPDATE_PREVIOUS_APP="${APP_DIR}.__update_previous__" UPDATE_PREVIOUS_PUBLIC="${PUBLIC_DIR}.__update_previous__" diff --git a/docs/docker-startup.md b/docs/docker-startup.md index 5611066d9..c5fa647aa 100644 --- a/docs/docker-startup.md +++ b/docs/docker-startup.md @@ -102,7 +102,7 @@ source 其他脚本,可能出现同一次启动混用新旧脚本的情况。 ### 3.3 更新恢复时的代际选择 -`/config/temp/__update_pending__` 的值会影响 launcher: +`/var/lib/moviepilot/update/__update_pending__` 的值会影响 launcher: | 状态 | 控制脚本选择原则 | | --- | --- | @@ -169,9 +169,13 @@ entrypoint 会删除该标记,并只在本次启动中把 `MOVIEPILOT_UPDATE_D ```text /app.__update_previous__ /public.__update_previous__ -/config/temp/__update_pending__ +/var/lib/moviepilot/update/__update_pending__ ``` +Dev 更新标记与程序载荷都位于容器可写层,只用于跨同一容器重启恢复;容器重建时它会和旧的 +`/app`、`/public` 载荷一起丢弃,避免持久化卷残留旧事务标记而阻断新镜像启动。稳定版 +Release/资源更新的 `prepared.json`、`install.json` 和 `state.json` 仍保存在 `/config`,不受此规则影响。 + 状态含义与启动处理: | 状态 | 含义 | 下次启动处理 | @@ -447,7 +451,7 @@ Docker restart policy。 | `/config/app.env` | Docker 启动脚本和应用配置。 | | `/config/.cache/uv` | 默认 uv 持久缓存。 | | `/config/.browser/cloakbrowser` | 默认 CloakBrowser 持久缓存。 | -| `/config/temp/__update_pending__` | 载荷切换事务状态。 | +| `/var/lib/moviepilot/update/__update_pending__` | Dev 载荷切换事务状态;与 `/app`、`/public` 同属容器可写层。 | | `/app.__update_previous__` | 更新前后端备份。 | | `/public.__update_previous__` | 更新前前端备份。 | | `/config/temp/moviepilot-update/` | 后台下载的 Release/资源包及安装状态。 | @@ -460,7 +464,7 @@ Docker restart policy。 | 变量 | 默认值 | 影响 | | --- | --- | --- | -| `CONFIG_DIR` | `/config` | 配置、缓存、更新状态和证书根目录。 | +| `CONFIG_DIR` | `/config` | 配置、缓存、Release/资源更新状态和证书根目录。 | | `PUID` / `PGID` | `0` / `0` | 映射 `moviepilot` 运行用户。 | | `UMASK` | `000` | 后端进程文件权限掩码。 | | `PORT` | `3001` | 后端监听和 readiness 端口。 | diff --git a/tests/test_docker_bootstrap.py b/tests/test_docker_bootstrap.py index bb41c65a5..a58f8f3ec 100644 --- a/tests/test_docker_bootstrap.py +++ b/tests/test_docker_bootstrap.py @@ -1186,6 +1186,7 @@ def test_failed_dependency_sync_does_not_replace_program_files(tmp_path: Path) - UV_BIN="$4" PIP_PROXY= PROXY_HOST= GITHUB_PROXY= CURL_OPTIONS= + MOVIEPILOT_UPDATE_PENDING_FILE="$1/temp/__update_pending__" source {UPDATER!s} APP_DIR="$5" PUBLIC_DIR="$6" @@ -1282,6 +1283,7 @@ def test_pending_update_recovers_previous_payload_on_next_start(tmp_path: Path) CONFIG_DIR="$1" VENV_PATH="$2" UV_BIN="$7" + MOVIEPILOT_UPDATE_PENDING_FILE="$1/temp/__update_pending__" PIP_PROXY= PROXY_HOST= GITHUB_PROXY= CURL_OPTIONS= source {UPDATER!s} APP_DIR="$3" @@ -1346,6 +1348,7 @@ def test_pending_dependency_update_keeps_current_payload_for_database_safety( script = textwrap.dedent( f"""\ CONFIG_DIR="$1" + MOVIEPILOT_UPDATE_PENDING_FILE="$1/temp/__update_pending__" source {UPDATER!s} APP_DIR="$2" PUBLIC_DIR="$3" @@ -1436,6 +1439,7 @@ def test_update_transaction_keeps_marker_when_backup_cleanup_fails(tmp_path: Pat script = textwrap.dedent( f"""\ CONFIG_DIR="$1" + MOVIEPILOT_UPDATE_PENDING_FILE="$2" UPDATE_PENDING_FILE="$2" UPDATE_PREVIOUS_APP="$3" UPDATE_PREVIOUS_PUBLIC="$4" @@ -1582,6 +1586,7 @@ def test_staged_payload_swap_failure_restores_previous_generation(tmp_path: Path CONFIG_DIR="$1" TMP_PATH="$2" PIP_PROXY= PROXY_HOST= GITHUB_PROXY= CURL_OPTIONS= + MOVIEPILOT_UPDATE_PENDING_FILE="$1/temp/__update_pending__" source {UPDATER!s} APP_DIR="$3" PUBLIC_DIR="$4" diff --git a/tests/test_docker_payload_contract.py b/tests/test_docker_payload_contract.py index fb8c3f77a..55471c0d1 100644 --- a/tests/test_docker_payload_contract.py +++ b/tests/test_docker_payload_contract.py @@ -98,6 +98,18 @@ def test_plugin_runtime_updates_preserve_host_entrypoint() -> None: assert "cp -a /app/app/plugins/." not in perf_script +def test_dev_update_marker_stays_with_container_payload() -> None: + """Dev 更新标记必须与容器载荷同层,避免持久化卷残留半完成事务。""" + marker = "/var/lib/moviepilot/update/__update_pending__" + update_script = _read(ROOT / "docker" / "update.sh") + launcher_script = _read(ROOT / "docker" / "launcher.sh") + + assert f"MOVIEPILOT_UPDATE_PENDING_FILE:-{marker}" in update_script + assert f"MOVIEPILOT_UPDATE_PENDING_FILE:-{marker}" in launcher_script + assert "/config/temp/__update_pending__" not in update_script + assert "/config/temp/__update_pending__" not in launcher_script + + def test_update_script_keeps_new_host_entrypoint_during_runtime_migration( tmp_path: Path, ) -> None: diff --git a/tests/test_system_update_manager.py b/tests/test_system_update_manager.py index f4bdfb56d..d595861db 100644 --- a/tests/test_system_update_manager.py +++ b/tests/test_system_update_manager.py @@ -266,6 +266,61 @@ def test_interrupted_download_becomes_retryable_failure(monkeypatch, tmp_path): assert "中断" in status.error +def test_installing_application_update_converges_when_runtime_is_newer( + monkeypatch, tmp_path +): + """当前主程序高于残留目标版本时应清理状态并恢复自动检查。""" + manager = _manager(monkeypatch, tmp_path) + monkeypatch.setattr(update_module, "get_app_version", lambda: "v3.0.6") + manager._write_item( + "application", + state="installing", + version="v3.0.1", + can_update=False, + can_install=False, + ) + checked = [] + monkeypatch.setattr(manager, "_check_application", lambda: checked.append(True)) + + status = manager.check("application") + + application = next(item for item in status.updates if item.type == "application") + assert checked == [True] + assert application.state == "idle" + assert application.version is None + assert status.state == "idle" + + +def test_ready_application_update_is_discarded_when_runtime_reaches_target( + monkeypatch, tmp_path +): + """外部升级先于确认安装时应清理过期的主程序待安装包。""" + manager = _manager(monkeypatch, tmp_path) + monkeypatch.setattr(update_module, "get_app_version", lambda: "v3.1.0") + manager._merge_prepared_manifest( + { + "version": "v3.1.0", + "backend_archive": "/tmp/backend.zip", + "frontend_archive": "/tmp/frontend.zip", + } + ) + manager._write_item( + "application", + state="ready", + version="v3.1.0", + can_update=False, + can_install=True, + ) + + status = manager.get_status() + + application = next(item for item in status.updates if item.type == "application") + assert application.state == "idle" + assert application.version is None + assert application.can_install is False + assert not (manager._root / "prepared.json").exists() + + def test_ready_resource_update_clears_after_loaded_version_reaches_target( monkeypatch, tmp_path,