fix(update): preserve new Docker modules during upgrades

This commit is contained in:
jxxghp
2026-09-15 11:01:13 +08:00
parent 998a4082c9
commit 989a4b0dce
2 changed files with 92 additions and 10 deletions

View File

@@ -878,6 +878,28 @@ class SystemUpdateManager(metaclass=SingletonClass):
resource_dir = legacy_dir
return resource_dir
@staticmethod
def _is_site_runtime_resource(path: Path) -> bool:
"""判断文件是否属于需要跨 Docker 应用升级继承的站点运行时资源。"""
return path.is_file() and (
(path.name.startswith("user.sites.") and path.suffix == ".bin")
or (
path.name.startswith("sites.")
and path.suffix in {".so", ".pyd", ".dylib"}
)
)
def _copy_site_runtime_resources(
self, source_dir: Path, destination_dir: Path
) -> None:
"""只把旧版本的站点索引和原生资源叠加到新版源码目录。"""
if not source_dir.is_dir():
return
destination_dir.mkdir(parents=True, exist_ok=True)
for path in source_dir.iterdir():
if self._is_site_runtime_resource(path):
shutil.copy2(path, destination_dir / path.name)
def _copy_prepared_resources(
self, prepared: dict[str, Any], resource_dir: Path
) -> None:
@@ -896,7 +918,12 @@ class SystemUpdateManager(metaclass=SingletonClass):
*,
include_resources: bool,
) -> tuple[Path, Path]:
"""解压并组装待切换的 Docker 后端、前端和插件资源载荷。"""
"""
解压并组装待切换的 Docker 后端、前端和插件资源载荷。
新版本归档是 Python 源码的唯一来源,旧版本目录只叠加插件和站点运行时资源,
避免把新版新增的应用模块覆盖掉。
"""
backend_extract = temporary_root / "backend"
frontend_extract = temporary_root / "frontend"
backend_extract.mkdir()
@@ -926,13 +953,8 @@ class SystemUpdateManager(metaclass=SingletonClass):
raise RuntimeError("插件运行目录缺少 app.plugins 兼容入口")
stage_resources = stage_app / "app" / "application" / "site"
if stage_resources.exists() or stage_resources.is_symlink():
self._remove_path(stage_resources)
current_resources = self._resource_source_dir(current_app)
if current_resources.is_dir():
shutil.copytree(current_resources, stage_resources, symlinks=True)
else:
stage_resources.mkdir(parents=True, exist_ok=True)
self._copy_site_runtime_resources(current_resources, stage_resources)
if include_resources:
self._copy_prepared_resources(prepared, stage_resources)
return stage_app, stage_public
@@ -1348,20 +1370,27 @@ class SystemUpdateManager(metaclass=SingletonClass):
raise RuntimeError(f"站点资源文件校验失败:{item.get('name')}")
def _request(self) -> RequestUtils:
"""创建访问 GitHub Release 的请求客户端。"""
"""创建访问 GitHub Release API 的认证请求客户端。"""
return RequestUtils(
proxies=get_runtime_setting("PROXY"),
headers=get_runtime_setting("GITHUB_HEADERS"),
timeout=60,
)
def _download_request(self) -> RequestUtils:
"""创建公开 GitHub 归档下载客户端,避免认证头传递到 codeload。"""
return RequestUtils(
proxies=get_runtime_setting("PROXY"),
timeout=60,
)
def _download_file(
self, url: str, destination: Path, downloaded_before: int, total_hint: int
) -> tuple[int, int]:
"""流式下载文件并把进度写入当前升级类型。"""
"""使用公开下载客户端流式下载文件并把进度写入当前升级类型。"""
temporary = destination.with_suffix(".part")
temporary.unlink(missing_ok=True)
with self._request().get_stream(url) as response:
with self._download_request().get_stream(url) as response:
if response is None or response.status_code != 200:
raise RuntimeError(
f"下载更新包失败:HTTP {getattr(response, 'status_code', '无响应')}"

View File

@@ -1,11 +1,13 @@
"""系统后台更新状态机测试。"""
from contextlib import nullcontext
import errno
import json
import threading
import zipfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, call
import pytest
@@ -151,6 +153,46 @@ def test_check_logs_when_application_is_current(monkeypatch, tmp_path):
assert logs == ["MoviePilot 主程序已是最新版本:v3.0.0"]
def test_public_download_request_omits_github_authorization(monkeypatch, tmp_path):
"""公开归档下载不得复用 GitHub API 的认证请求头。"""
manager = _manager(monkeypatch, tmp_path)
settings = {
"TEMP_PATH": tmp_path,
"PROXY": {"https": "http://proxy.example:7890"},
"GITHUB_HEADERS": {"Authorization": "Bearer github-token"},
}
monkeypatch.setattr(update_module, "get_runtime_setting", settings.__getitem__)
request_utils = Mock()
request_utils.return_value.get_stream.return_value = nullcontext(
SimpleNamespace(
status_code=200,
headers={"content-length": "7"},
iter_content=lambda **_kwargs: [b"archive"],
)
)
monkeypatch.setattr(update_module, "RequestUtils", request_utils)
manager._request()
destination = tmp_path / "backend.zip"
downloaded, content_length = manager._download_file(
"https://github.com/jxxghp/MoviePilot/archive/refs/tags/v3.0.2.zip",
destination,
0,
0,
)
assert request_utils.call_args_list == [
call(
proxies=settings["PROXY"],
headers=settings["GITHUB_HEADERS"],
timeout=60,
),
call(proxies=settings["PROXY"], timeout=60),
]
assert downloaded == content_length == 7
assert destination.read_bytes() == b"archive"
def test_scheduled_check_failure_stays_silent(monkeypatch, tmp_path):
manager = _manager(monkeypatch, tmp_path)
monkeypatch.setattr(
@@ -433,6 +475,7 @@ def test_apply_prepared_application_replaces_docker_payload_and_preserves_plugin
(plugin_dir / "__init__.py").write_text("# compatibility\n", encoding="utf-8")
(plugin_dir / "local_plugin.py").write_text("local\n", encoding="utf-8")
(resource_dir / "user.sites.v3.bin").write_text("old-resource\n", encoding="utf-8")
(resource_dir / "legacy.py").write_text("old-source\n", encoding="utf-8")
(app_dir / "old.py").write_text("old\n", encoding="utf-8")
(app_dir / "pyproject.toml").write_text("old-project\n", encoding="utf-8")
(app_dir / "uv.lock").write_text("old-lock\n", encoding="utf-8")
@@ -447,6 +490,14 @@ 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/application/site/__init__.py",
"",
)
archive.writestr(
"MoviePilot-v3.1.0/app/application/site/auth.py",
"new-auth\n",
)
with zipfile.ZipFile(manager._frontend_archive, "w") as archive:
archive.writestr("dist/index.html", "new-front\n")
archive.writestr("dist/version.txt", "v3.1.0\n")
@@ -482,6 +533,8 @@ def test_apply_prepared_application_replaces_docker_payload_and_preserves_plugin
assert not (app_dir / "old.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 / "auth.py").read_text(encoding="utf-8") == "new-auth\n"
assert not (resource_dir / "legacy.py").exists()
assert (public_dir / "index.html").read_text(encoding="utf-8") == "new-front\n"
assert not manager._install_file.exists()
assert not (manager._root / "prepared.json").exists()