diff --git a/app/adapters/system/update.py b/app/adapters/system/update.py index 300866e9f..e664ee515 100644 --- a/app/adapters/system/update.py +++ b/app/adapters/system/update.py @@ -853,6 +853,28 @@ class SystemUpdateManager(metaclass=SingletonClass): follow_symlinks=False, ) + def _copy_plugin_runtime_payload(self, source: Path, destination: Path) -> None: + """ + 迁移插件运行时内容,但保留新版宿主的包根入口。 + + ``app/plugins/__init__.py`` 属于后端源码提供的兼容入口,不属于持久化插件; + 旧版本该文件包含宿主实现,随插件目录迁移会遮蔽新版 SDK 的兼容符号。 + """ + destination.mkdir(parents=True, exist_ok=True) + for destination_path in destination.iterdir(): + if destination_path.name != "__init__.py": + self._remove_path(destination_path) + + for source_path in source.iterdir(): + if source_path.name == "__init__.py": + continue + destination_path = destination / source_path.name + if source_path.is_dir() and not source_path.is_symlink(): + shutil.copytree(source_path, destination_path, symlinks=True) + else: + shutil.copy2(source_path, destination_path, follow_symlinks=False) + self._preserve_tree_ownership(source_path, destination_path) + @staticmethod def _clear_staged_native_resources(resource_dir: Path) -> None: """清除暂存目录中的旧平台原生站点资源。""" @@ -921,8 +943,8 @@ class SystemUpdateManager(metaclass=SingletonClass): """ 解压并组装待切换的 Docker 后端、前端和插件资源载荷。 - 新版本归档是 Python 源码的唯一来源,旧版本目录只叠加插件和站点运行时资源, - 避免把新版新增的应用模块覆盖掉。 + 新版本归档是 Python 源码的唯一来源,旧版本目录只迁移插件和站点运行时内容, + 不覆盖新版的插件包根兼容入口或新增应用模块。 """ backend_extract = temporary_root / "backend" frontend_extract = temporary_root / "frontend" @@ -942,13 +964,13 @@ class SystemUpdateManager(metaclass=SingletonClass): current_app = self._docker_app_dir current_plugins = current_app / "app" / "plugins" stage_plugins = stage_app / "app" / "plugins" - if stage_plugins.exists() or stage_plugins.is_symlink(): + if stage_plugins.is_symlink(): self._remove_path(stage_plugins) + if stage_plugins.exists() and not stage_plugins.is_dir(): + raise RuntimeError("插件运行目录不是目录") + stage_plugins.mkdir(parents=True, exist_ok=True) if current_plugins.is_dir(): - shutil.copytree(current_plugins, stage_plugins, symlinks=True) - self._preserve_tree_ownership(current_plugins, stage_plugins) - else: - stage_plugins.mkdir(parents=True, exist_ok=True) + self._copy_plugin_runtime_payload(current_plugins, stage_plugins) if not (stage_plugins / "__init__.py").is_file(): raise RuntimeError("插件运行目录缺少 app.plugins 兼容入口") diff --git a/docker/update.sh b/docker/update.sh index 7c877dc4e..5e7bf227f 100644 --- a/docker/update.sh +++ b/docker/update.sh @@ -323,19 +323,22 @@ function stage_runtime_payload() { [ -f "${stage_app}/uv.lock" ] || return 1 [ -f "${TMP_PATH}/dist/index.html" ] || return 1 + if [ -e "${stage_plugin_dir}" ] && [ ! -d "${stage_plugin_dir}" ]; then + ERROR "插件运行目录不是目录" + return 1 + fi + mkdir -p "${stage_plugin_dir}" || return 1 + # 新版后端归档提供宿主兼容入口;仅迁移旧目录中的插件内容,避免旧版入口覆盖它。 + if ! find "${stage_plugin_dir}" -mindepth 1 -maxdepth 1 \ + ! -name "__init__.py" -exec rm -rf -- {} \;; then + return 1 + fi if [ -d "${APP_DIR}/app/plugins" ]; then - rm -rf "${stage_plugin_dir}" || return 1 - mkdir -p "${stage_plugin_dir}" || return 1 - if ! cp -a "${APP_DIR}/app/plugins/." "${stage_plugin_dir}/"; then + if ! find "${APP_DIR}/app/plugins" -mindepth 1 -maxdepth 1 \ + ! -name "__init__.py" -exec cp -a {} "${stage_plugin_dir}/" \;; then return 1 fi - else - mkdir -p "${stage_plugin_dir}" || return 1 fi - # 保留 app.plugins 兼容入口;V1/V2 插件仍从这里导入 _PluginBase。 - # 删除后 app.plugins 会退化为 namespace package,旧插件会在启动时全部导入失败。 - # 该文件必须是不含实现的包说明:契约基类归 app.sdk.plugin,包根自带旧版实现会 - # 遮蔽兼容符号,此时 Compat 会直接拒绝导入并报出覆盖了哪个 canonical 路径。 if [ ! -f "${stage_plugin_dir}/__init__.py" ]; then ERROR "插件运行目录缺少 app.plugins 兼容入口" return 1 diff --git a/scripts/perf/moviepilot_docker_ab.py b/scripts/perf/moviepilot_docker_ab.py index 2d5270bf8..2b4a9bc90 100644 --- a/scripts/perf/moviepilot_docker_ab.py +++ b/scripts/perf/moviepilot_docker_ab.py @@ -106,8 +106,9 @@ FROM ${MP_SUBSTRATE} AS frozen RUN set -eux; \ mkdir -p /frozen/plugins /frozen/site; \ - cp -a /app/app/plugins/. /frozen/plugins/; \ - test -f /frozen/plugins/__init__.py; \ + find /app/app/plugins -mindepth 1 -maxdepth 1 ! -name '__init__.py' \ + -exec cp -a '{}' /frozen/plugins/ \;; \ + test -f /app/app/plugins/__init__.py; \ rm -rf /frozen/plugins/__pycache__; \ find /app/app/application/site -maxdepth 1 -type f \ \( -name 'sites.*.so' -o -name 'user.sites.v3.bin' \) \ diff --git a/tests/test_docker_payload_contract.py b/tests/test_docker_payload_contract.py index 6ded8a150..fb8c3f77a 100644 --- a/tests/test_docker_payload_contract.py +++ b/tests/test_docker_payload_contract.py @@ -1,10 +1,13 @@ """Docker 构建输入和镜像载荷分层合同。""" +import shlex +import subprocess from pathlib import Path ROOT = Path(__file__).resolve().parents[1] DOCKERFILE = ROOT / "docker" / "Dockerfile" +UPDATER = ROOT / "docker" / "update.sh" RELEASE_WORKFLOW = ROOT / ".github" / "workflows" / "build-v3.yml" BETA_WORKFLOW = ROOT / ".github" / "workflows" / "beta.yml" @@ -82,13 +85,68 @@ def test_dockerfile_assigns_each_payload_to_an_independent_stage() -> None: assert "RUN rm -rf /app/frontend-dist" in dockerfile -def test_plugin_runtime_updates_preserve_legacy_base_entrypoint() -> None: - """更新和性能覆盖镜像必须保留旧插件导入 _PluginBase 所需的兼容入口。""" +def test_plugin_runtime_updates_preserve_host_entrypoint() -> None: + """更新载荷只迁移插件内容,不得覆盖新版宿主包根兼容入口。""" update_script = _read(ROOT / "docker" / "update.sh") perf_script = _read(ROOT / "scripts" / "perf" / "moviepilot_docker_ab.py") - assert 'rm -f "${stage_plugin_dir}/__init__.py"' not in update_script - assert "rm -f /frozen/plugins/__init__.py" not in perf_script + assert 'find "${APP_DIR}/app/plugins" -mindepth 1 -maxdepth 1' in update_script + assert '! -name "__init__.py"' in update_script + assert 'cp -a "${APP_DIR}/app/plugins/."' not in update_script + assert "find /app/app/plugins -mindepth 1 -maxdepth 1" in perf_script + assert "! -name '__init__.py'" in perf_script + assert "cp -a /app/app/plugins/." not in perf_script + + +def test_update_script_keeps_new_host_entrypoint_during_runtime_migration( + tmp_path: Path, +) -> None: + """Docker 更新脚本迁移旧插件时不得覆盖新归档中的宿主入口。""" + app_dir = tmp_path / "current" + temp_dir = tmp_path / "temp" + stage_app = temp_dir / "App" + venv_dir = tmp_path / "venv" + current_plugins = app_dir / "app" / "plugins" + stage_plugins = stage_app / "app" / "plugins" + current_plugins.mkdir(parents=True) + stage_plugins.mkdir(parents=True) + (current_plugins / "__init__.py").write_text("old host\n", encoding="utf-8") + (current_plugins / "demoplugin" / "dist").mkdir(parents=True) + (current_plugins / "demoplugin" / "__init__.py").write_text( + "plugin\n", encoding="utf-8" + ) + (stage_plugins / "__init__.py").write_text("new host\n", encoding="utf-8") + (stage_plugins / "stale").write_text("stale\n", encoding="utf-8") + for name, content in ( + ("version.py", "APP_VERSION = 'v3.1.0'\n"), + ("pyproject.toml", "[project]\n"), + ("uv.lock", "version = 1\n"), + ): + (stage_app / name).parent.mkdir(parents=True, exist_ok=True) + (stage_app / name).write_text(content, encoding="utf-8") + (temp_dir / "dist").mkdir(parents=True) + (temp_dir / "dist" / "index.html").write_text("ok\n", encoding="utf-8") + python_bin = venv_dir / "bin" / "python3" + python_bin.parent.mkdir(parents=True) + python_bin.write_text("#!/bin/sh\nprintf '%s\\n' cpython-314\n", encoding="utf-8") + python_bin.chmod(0o755) + + script = f""" +set -eu +CONFIG_DIR={shlex.quote(str(tmp_path / 'config'))} +VENV_PATH={shlex.quote(str(venv_dir))} +GITHUB_PROXY="" +source {shlex.quote(str(UPDATER))} +APP_DIR={shlex.quote(str(app_dir))} +TMP_PATH={shlex.quote(str(temp_dir))} +download_staged_resource() {{ return 0; }} +stage_runtime_payload +test "$(cat {shlex.quote(str(stage_plugins / '__init__.py'))})" = "new host" +test -f {shlex.quote(str(stage_plugins / 'demoplugin' / '__init__.py'))} +test ! -e {shlex.quote(str(stage_plugins / 'stale'))} +""" + + subprocess.run(["bash", "-c", script], check=True) def test_release_workflows_pin_and_record_external_payload_identities() -> None: diff --git a/tests/test_system_update_manager.py b/tests/test_system_update_manager.py index 4711706bd..71a20ec48 100644 --- a/tests/test_system_update_manager.py +++ b/tests/test_system_update_manager.py @@ -490,6 +490,10 @@ def test_apply_prepared_application_replaces_docker_payload_and_preserves_plugin archive.writestr("MoviePilot-v3.1.0/pyproject.toml", "[project]\n") archive.writestr("MoviePilot-v3.1.0/uv.lock", "version = 1\n") archive.writestr("MoviePilot-v3.1.0/new.py", "new\n") + archive.writestr( + "MoviePilot-v3.1.0/app/plugins/__init__.py", + "new compatibility\n", + ) archive.writestr( "MoviePilot-v3.1.0/app/application/site/__init__.py", "", @@ -531,6 +535,9 @@ def test_apply_prepared_application_replaces_docker_payload_and_preserves_plugin assert sync_calls[0][1] == {} assert (app_dir / "new.py").read_text(encoding="utf-8") == "new\n" assert not (app_dir / "old.py").exists() + assert (app_dir / "app" / "plugins" / "__init__.py").read_text( + encoding="utf-8" + ) == "new compatibility\n" assert (app_dir / "app" / "plugins" / "local_plugin.py").exists() assert (resource_dir / "user.sites.v3.bin").read_text(encoding="utf-8") == "old-resource\n" assert (resource_dir / "auth.py").read_text(encoding="utf-8") == "new-auth\n"