mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
fix(update): preserve host plugin entrypoint during Docker upgrades
This commit is contained in:
@@ -853,6 +853,28 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
|||||||
follow_symlinks=False,
|
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
|
@staticmethod
|
||||||
def _clear_staged_native_resources(resource_dir: Path) -> None:
|
def _clear_staged_native_resources(resource_dir: Path) -> None:
|
||||||
"""清除暂存目录中的旧平台原生站点资源。"""
|
"""清除暂存目录中的旧平台原生站点资源。"""
|
||||||
@@ -921,8 +943,8 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
|||||||
"""
|
"""
|
||||||
解压并组装待切换的 Docker 后端、前端和插件资源载荷。
|
解压并组装待切换的 Docker 后端、前端和插件资源载荷。
|
||||||
|
|
||||||
新版本归档是 Python 源码的唯一来源,旧版本目录只叠加插件和站点运行时资源,
|
新版本归档是 Python 源码的唯一来源,旧版本目录只迁移插件和站点运行时内容,
|
||||||
避免把新版新增的应用模块覆盖掉。
|
不覆盖新版的插件包根兼容入口或新增应用模块。
|
||||||
"""
|
"""
|
||||||
backend_extract = temporary_root / "backend"
|
backend_extract = temporary_root / "backend"
|
||||||
frontend_extract = temporary_root / "frontend"
|
frontend_extract = temporary_root / "frontend"
|
||||||
@@ -942,13 +964,13 @@ class SystemUpdateManager(metaclass=SingletonClass):
|
|||||||
current_app = self._docker_app_dir
|
current_app = self._docker_app_dir
|
||||||
current_plugins = current_app / "app" / "plugins"
|
current_plugins = current_app / "app" / "plugins"
|
||||||
stage_plugins = stage_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)
|
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():
|
if current_plugins.is_dir():
|
||||||
shutil.copytree(current_plugins, stage_plugins, symlinks=True)
|
self._copy_plugin_runtime_payload(current_plugins, stage_plugins)
|
||||||
self._preserve_tree_ownership(current_plugins, stage_plugins)
|
|
||||||
else:
|
|
||||||
stage_plugins.mkdir(parents=True, exist_ok=True)
|
|
||||||
if not (stage_plugins / "__init__.py").is_file():
|
if not (stage_plugins / "__init__.py").is_file():
|
||||||
raise RuntimeError("插件运行目录缺少 app.plugins 兼容入口")
|
raise RuntimeError("插件运行目录缺少 app.plugins 兼容入口")
|
||||||
|
|
||||||
|
|||||||
@@ -323,19 +323,22 @@ function stage_runtime_payload() {
|
|||||||
[ -f "${stage_app}/uv.lock" ] || return 1
|
[ -f "${stage_app}/uv.lock" ] || return 1
|
||||||
[ -f "${TMP_PATH}/dist/index.html" ] || 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
|
if [ -d "${APP_DIR}/app/plugins" ]; then
|
||||||
rm -rf "${stage_plugin_dir}" || return 1
|
if ! find "${APP_DIR}/app/plugins" -mindepth 1 -maxdepth 1 \
|
||||||
mkdir -p "${stage_plugin_dir}" || return 1
|
! -name "__init__.py" -exec cp -a {} "${stage_plugin_dir}/" \;; then
|
||||||
if ! cp -a "${APP_DIR}/app/plugins/." "${stage_plugin_dir}/"; then
|
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
else
|
|
||||||
mkdir -p "${stage_plugin_dir}" || return 1
|
|
||||||
fi
|
fi
|
||||||
# 保留 app.plugins 兼容入口;V1/V2 插件仍从这里导入 _PluginBase。
|
|
||||||
# 删除后 app.plugins 会退化为 namespace package,旧插件会在启动时全部导入失败。
|
|
||||||
# 该文件必须是不含实现的包说明:契约基类归 app.sdk.plugin,包根自带旧版实现会
|
|
||||||
# 遮蔽兼容符号,此时 Compat 会直接拒绝导入并报出覆盖了哪个 canonical 路径。
|
|
||||||
if [ ! -f "${stage_plugin_dir}/__init__.py" ]; then
|
if [ ! -f "${stage_plugin_dir}/__init__.py" ]; then
|
||||||
ERROR "插件运行目录缺少 app.plugins 兼容入口"
|
ERROR "插件运行目录缺少 app.plugins 兼容入口"
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -106,8 +106,9 @@ FROM ${MP_SUBSTRATE} AS frozen
|
|||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
mkdir -p /frozen/plugins /frozen/site; \
|
mkdir -p /frozen/plugins /frozen/site; \
|
||||||
cp -a /app/app/plugins/. /frozen/plugins/; \
|
find /app/app/plugins -mindepth 1 -maxdepth 1 ! -name '__init__.py' \
|
||||||
test -f /frozen/plugins/__init__.py; \
|
-exec cp -a '{}' /frozen/plugins/ \;; \
|
||||||
|
test -f /app/app/plugins/__init__.py; \
|
||||||
rm -rf /frozen/plugins/__pycache__; \
|
rm -rf /frozen/plugins/__pycache__; \
|
||||||
find /app/app/application/site -maxdepth 1 -type f \
|
find /app/app/application/site -maxdepth 1 -type f \
|
||||||
\( -name 'sites.*.so' -o -name 'user.sites.v3.bin' \) \
|
\( -name 'sites.*.so' -o -name 'user.sites.v3.bin' \) \
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""Docker 构建输入和镜像载荷分层合同。"""
|
"""Docker 构建输入和镜像载荷分层合同。"""
|
||||||
|
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOCKERFILE = ROOT / "docker" / "Dockerfile"
|
DOCKERFILE = ROOT / "docker" / "Dockerfile"
|
||||||
|
UPDATER = ROOT / "docker" / "update.sh"
|
||||||
RELEASE_WORKFLOW = ROOT / ".github" / "workflows" / "build-v3.yml"
|
RELEASE_WORKFLOW = ROOT / ".github" / "workflows" / "build-v3.yml"
|
||||||
BETA_WORKFLOW = ROOT / ".github" / "workflows" / "beta.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
|
assert "RUN rm -rf /app/frontend-dist" in dockerfile
|
||||||
|
|
||||||
|
|
||||||
def test_plugin_runtime_updates_preserve_legacy_base_entrypoint() -> None:
|
def test_plugin_runtime_updates_preserve_host_entrypoint() -> None:
|
||||||
"""更新和性能覆盖镜像必须保留旧插件导入 _PluginBase 所需的兼容入口。"""
|
"""更新载荷只迁移插件内容,不得覆盖新版宿主包根兼容入口。"""
|
||||||
update_script = _read(ROOT / "docker" / "update.sh")
|
update_script = _read(ROOT / "docker" / "update.sh")
|
||||||
perf_script = _read(ROOT / "scripts" / "perf" / "moviepilot_docker_ab.py")
|
perf_script = _read(ROOT / "scripts" / "perf" / "moviepilot_docker_ab.py")
|
||||||
|
|
||||||
assert 'rm -f "${stage_plugin_dir}/__init__.py"' not in update_script
|
assert 'find "${APP_DIR}/app/plugins" -mindepth 1 -maxdepth 1' in update_script
|
||||||
assert "rm -f /frozen/plugins/__init__.py" not in perf_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:
|
def test_release_workflows_pin_and_record_external_payload_identities() -> None:
|
||||||
|
|||||||
@@ -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/pyproject.toml", "[project]\n")
|
||||||
archive.writestr("MoviePilot-v3.1.0/uv.lock", "version = 1\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/new.py", "new\n")
|
||||||
|
archive.writestr(
|
||||||
|
"MoviePilot-v3.1.0/app/plugins/__init__.py",
|
||||||
|
"new compatibility\n",
|
||||||
|
)
|
||||||
archive.writestr(
|
archive.writestr(
|
||||||
"MoviePilot-v3.1.0/app/application/site/__init__.py",
|
"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 sync_calls[0][1] == {}
|
||||||
assert (app_dir / "new.py").read_text(encoding="utf-8") == "new\n"
|
assert (app_dir / "new.py").read_text(encoding="utf-8") == "new\n"
|
||||||
assert not (app_dir / "old.py").exists()
|
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 (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 / "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"
|
assert (resource_dir / "auth.py").read_text(encoding="utf-8") == "new-auth\n"
|
||||||
|
|||||||
Reference in New Issue
Block a user