From ec922865be18864d96625362b84f21e37ba8bd99 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Thu, 22 Oct 2026 14:59:43 +0800 Subject: [PATCH] feat(update): require image system baseline before installing updates Co-Authored-By: Claude Opus 5.5 --- app/adapters/system/update.py | 87 ++++++++- docker/update.sh | 37 ++++ tests/test_system_baseline.py | 340 ++++++++++++++++++++++++++++++++++ version.py | 5 + 4 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 tests/test_system_baseline.py diff --git a/app/adapters/system/update.py b/app/adapters/system/update.py index 3cd52fc85..5f6514e09 100644 --- a/app/adapters/system/update.py +++ b/app/adapters/system/update.py @@ -14,7 +14,7 @@ import stat import subprocess import threading import zipfile -from datetime import datetime, timezone +from datetime import date, datetime, timezone from pathlib import Path, PurePosixPath from tempfile import TemporaryDirectory from typing import Any, cast @@ -71,6 +71,12 @@ class SystemUpdateManager(metaclass=SingletonClass): "https://github.com/jxxghp/MoviePilot/archive/refs/tags/{tag}.zip" ) _VERSION_PATTERN = re.compile(r"^v3\.\d+\.\d+(?:[-.](?:alpha|beta|rc)\d*)?$", re.I) + # 镜像构建时写入的系统基线(ISO 日期),记录镜像系统软件包刷新到的安全基线。 + _SYSTEM_BASELINE_FILE = Path("/etc/moviepilot/system-baseline") + # 基线机制引入前的下限:没有基线文件的旧镜像按此值比较,因此只有目标版本把 + # MIN_SYSTEM_BASELINE 提高到该日期之后才会拦截旧镜像。 + _LEGACY_SYSTEM_BASELINE = "2026-01-01" + _SYSTEM_BASELINE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") # 稳定版允许 -N 后缀,用于不升小版本的临时修复(如 v3.0.10-1) _STABLE_VERSION_PATTERN = re.compile(r"^v3\.\d+\.\d+(?:-\d+)?$", re.I) @@ -1423,6 +1429,8 @@ class SystemUpdateManager(metaclass=SingletonClass): 0, ) frontend_version = self._validate_backend_archive(version) + # 先于前端下载判断镜像系统基线,避免不满足要求时继续占用带宽或进入可安装状态。 + self._check_system_baseline() frontend_release = self._fetch_frontend_release(frontend_version) frontend_asset = next( ( @@ -1746,6 +1754,83 @@ class SystemUpdateManager(metaclass=SingletonClass): raise RuntimeError("后端更新包缺少依赖锁定文件") return frontend_match.group(1) + def _check_system_baseline(self) -> None: + """ + 拒绝把要求更高系统基线的版本安装到旧 Docker 镜像。 + + 仅 Docker 部署执行:系统组件随镜像发布,应用内更新无法替换;本地源码与 exe 部署跳过。 + 目标版本未声明 ``MIN_SYSTEM_BASELINE`` 时视为无要求,声明格式无效时按失败处理。 + + :raises RuntimeError: 目标版本声明无效,或当前镜像系统基线低于目标要求 + """ + if not is_docker(): + return + required = self._backend_min_system_baseline() + if required is None: + return + current = self._image_system_baseline() + compared = current or self._LEGACY_SYSTEM_BASELINE + # 基线均已校验为 YYYY-MM-DD,字符串顺序与日期顺序一致。 + if required > compared: + raise RuntimeError( + "该版本包含系统组件安全更新,需要先更新 Docker 镜像" + f"(要求系统基线 {required},当前 {current or '未标记'})" + ) + + def _backend_min_system_baseline(self) -> str | None: + """ + 读取已下载后端归档 version.py 中声明的最低系统基线。 + + :return: 规范化的 ISO 日期;旧版本未声明时返回 ``None`` + :raises RuntimeError: 声明存在但不是有效 ISO 日期 + """ + with zipfile.ZipFile(self._backend_archive) as archive: + version_name = next( + name + for name in archive.namelist() + if name.count("/") == 1 and name.endswith("/version.py") + ) + version_source = archive.read(version_name).decode("utf-8") + match = re.search(r"^MIN_SYSTEM_BASELINE\s*=\s*['\"]([^'\"]*)['\"]", version_source, re.M) + if not match: + return None + required = self._parse_system_baseline(match.group(1)) + if required is None: + raise RuntimeError(f"目标版本声明的系统基线无效:{match.group(1)}") + return required + + def _image_system_baseline(self) -> str | None: + """ + 读取当前镜像构建时写入的系统基线。 + + :return: 规范化的 ISO 日期;旧镜像缺少文件或内容无效时返回 ``None``,按最旧基线处理 + """ + try: + raw = self._SYSTEM_BASELINE_FILE.read_text(encoding="utf-8") + except OSError: + return None + baseline = self._parse_system_baseline(raw) + if baseline is None: + logger.warning(f"镜像系统基线文件内容无效,按旧镜像处理:{raw.strip()[:32]}") + return baseline + + @classmethod + def _parse_system_baseline(cls, value: str) -> str | None: + """ + 校验系统基线为真实存在的 YYYY-MM-DD 日期。 + + :param value: 原始基线文本 + :return: 去除空白后的基线;格式或日期无效时返回 ``None`` + """ + text = value.strip() + if not cls._SYSTEM_BASELINE_PATTERN.fullmatch(text): + return None + try: + date.fromisoformat(text) + except ValueError: + return None + return text + def _validate_frontend_archive(self, version: str) -> None: """ 校验前端包只包含可接受的发布目录和匹配版本。 diff --git a/docker/update.sh b/docker/update.sh index 83d310fc4..df27e12d6 100644 --- a/docker/update.sh +++ b/docker/update.sh @@ -35,6 +35,9 @@ PUBLIC_DIR=/public 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__" +# 镜像构建时写入的系统基线;旧镜像缺少该文件时按机制引入前的下限比较。 +SYSTEM_BASELINE_FILE="${MOVIEPILOT_SYSTEM_BASELINE_FILE:-/etc/moviepilot/system-baseline}" +LEGACY_SYSTEM_BASELINE="2026-01-01" function apply_package_cache_env() { PACKAGE_CACHE_ROOT="${PACKAGE_CACHE_ROOT:-${CONFIG_DIR}/.cache}" @@ -92,6 +95,37 @@ function download_and_unzip() { fi } +# 输出去除空白后的 YYYY-MM-DD 基线;格式无效时返回失败。 +function normalize_system_baseline() { + local value + value="$(printf '%s' "$1" | tr -d '[:space:]')" + [[ "${value}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || return 1 + printf '%s\n' "${value}" +} + +# 目标版本要求的系统基线高于当前镜像时拒绝更新:系统组件只能随镜像更新。 +# 未声明 MIN_SYSTEM_BASELINE 的旧版本视为无要求;声明无效时同样拒绝,保留当前版本。 +function check_system_baseline() { + local version_file="$1" + local required_raw + local required + local current + + grep -Eq '^MIN_SYSTEM_BASELINE[[:space:]]*=' "${version_file}" 2>/dev/null || return 0 + required_raw="$(sed -n "s/^MIN_SYSTEM_BASELINE[[:space:]]*=[[:space:]]*['\"]\([^'\"]*\)['\"].*/\1/p" "${version_file}" | head -n 1)" + if ! required="$(normalize_system_baseline "${required_raw}")"; then + WARN "更新包声明的系统基线无效(${required_raw}),已跳过本次更新并保留当前版本" + return 1 + fi + current="$(normalize_system_baseline "$(cat "${SYSTEM_BASELINE_FILE}" 2>/dev/null)")" || current="" + # 基线均为 YYYY-MM-DD,字符串顺序与日期顺序一致。 + if [[ "${required}" > "${current:-${LEGACY_SYSTEM_BASELINE}}" ]]; then + WARN "该版本包含系统组件安全更新,需要先更新 Docker 镜像(要求系统基线 ${required},当前 ${current:-未标记}),已跳过本次更新并保留当前版本" + return 1 + fi + return 0 +} + function sync_project_dependencies() { INFO "检测到依赖变化,正在更新虚拟环境..." configure_package_route || return 1 @@ -399,6 +433,9 @@ function install_backend_and_download_resources() { return 1 fi INFO "后端程序包准备成功" + if ! check_system_baseline "${TMP_PATH}/App/version.py"; then + return 1 + fi # 检查依赖清单,实际同步延后到所有运行载荷准备完成之后。 INFO "→ 检查依赖变化..." diff --git a/tests/test_system_baseline.py b/tests/test_system_baseline.py new file mode 100644 index 000000000..3b8eb78dc --- /dev/null +++ b/tests/test_system_baseline.py @@ -0,0 +1,340 @@ +""" +验证 Docker 镜像系统基线:发布声明、镜像构建契约与应用内/Dev 更新门禁。 +""" + +import json +import re +import subprocess +import textwrap +import threading +import zipfile +from datetime import date +from pathlib import Path + +import pytest + +from app.adapters.system import update as update_module + +ROOT = Path(__file__).resolve().parents[1] +UPDATER = ROOT / "docker" / "update.sh" +_BASELINE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def _release_declarations() -> dict[str, str]: + """ + 按发布工作流 release-gate 的 sed 规则解析 version.py。 + """ + declarations: dict[str, str] = {} + pattern = re.compile( + r"^(APP_VERSION|FRONTEND_VERSION|MIN_SYSTEM_BASELINE)[ \t]*=[ \t]*'([^']+)'[ \t]*$" + ) + for line in (ROOT / "version.py").read_text(encoding="utf-8").splitlines(): + match = pattern.match(line) + if match: + assert match.group(1) not in declarations + declarations[match.group(1)] = match.group(2) + return declarations + + +def test_release_declares_valid_min_system_baseline() -> None: + """ + 发布声明的最低系统基线必须是有效日期,且不高于本仓镜像写入的基线。 + """ + declarations = _release_declarations() + image_baseline = (ROOT / "docker" / "system-baseline").read_text(encoding="utf-8").strip() + + assert set(declarations) == {"APP_VERSION", "FRONTEND_VERSION", "MIN_SYSTEM_BASELINE"} + for value in (declarations["MIN_SYSTEM_BASELINE"], image_baseline): + assert _BASELINE_PATTERN.fullmatch(value) + date.fromisoformat(value) + # 同一发布构建的镜像必须满足自身声明,否则新镜像也会拒绝自己的更新包。 + assert declarations["MIN_SYSTEM_BASELINE"] <= image_baseline + assert ( + update_module.SystemUpdateManager._LEGACY_SYSTEM_BASELINE + <= declarations["MIN_SYSTEM_BASELINE"] + ) + + +def test_dockerfile_keeps_system_refresh_and_browser_deps_cache_boundaries() -> None: + """ + 系统刷新层位于虚拟环境之后、--link 载荷之前;浏览器依赖层不再依赖虚拟环境内容。 + """ + dockerfile = (ROOT / "docker" / "Dockerfile").read_text(encoding="utf-8") + final_stage = dockerfile[dockerfile.index("FROM prepare_browser_deps AS final") :] + browser_stage = dockerfile[ + dockerfile.index("FROM prepare_package AS prepare_browser_deps") : dockerfile.index( + "# 准备 Python 环境的公共输入" + ) + ] + + assert "playwright install-deps chromium" in browser_stage + assert "${VENV_PATH}" not in browser_stage + assert "COPY --from=prepare_playwright_requirement" in browser_stage + assert "--require-hashes" in browser_stage + assert "playwright install-deps" not in final_stage + + venv_copy = final_stage.index("COPY --from=prepare_venv --chmod=777 ${VENV_PATH} ${VENV_PATH}") + refresh_arg = final_stage.index('ARG MOVIEPILOT_SYSTEM_REFRESH_ID=""') + stamp = final_stage.index("COPY docker/system-baseline /etc/moviepilot/system-baseline") + upgrade = final_stage.index("upgrade; \\") + first_payload = final_stage.index("COPY --link --from=prepare_frontend") + assert venv_copy < refresh_arg < stamp < upgrade < first_payload + # 刷新键之后只能是不继承父层的 --link 载荷,避免每周刷新连带重建其他层。 + tail = final_stage[upgrade:] + assert "\nRUN " not in tail + assert all( + line.startswith("COPY --link") + for line in tail.splitlines() + if line.startswith("COPY ") + ) + + +def _manager(monkeypatch, tmp_path: Path, *, docker: bool = True, stamp: str | None): + """ + 创建使用临时状态目录和指定镜像基线文件的更新管理器。 + """ + monkeypatch.setattr(update_module, "get_app_version", lambda: "v3.0.0") + monkeypatch.setattr( + update_module, + "get_runtime_setting", + lambda key: tmp_path if key == "TEMP_PATH" else None, + ) + monkeypatch.setattr(update_module, "is_docker", lambda: docker) + baseline_file = tmp_path / "etc" / "system-baseline" + if stamp is not None: + baseline_file.parent.mkdir(parents=True, exist_ok=True) + baseline_file.write_text(stamp, encoding="utf-8") + monkeypatch.setattr( + update_module.SystemUpdateManager, "_SYSTEM_BASELINE_FILE", baseline_file + ) + manager = object.__new__(update_module.SystemUpdateManager) + manager._lock = threading.RLock() + manager._download_active = False + manager._active_target = None + return manager + + +def _prepare_download(monkeypatch, tmp_path: Path, manager, min_baseline: str | None) -> list[str]: + """ + 准备离线的后端与前端归档,返回实际发起的下载记录。 + """ + version_source = "APP_VERSION = 'v3.1.0'\nFRONTEND_VERSION = 'v3.1.0'\n" + if min_baseline is not None: + version_source += f"MIN_SYSTEM_BASELINE = '{min_baseline}'\n" + backend_fixture = tmp_path / "source-backend.zip" + frontend_fixture = tmp_path / "source-frontend.zip" + with zipfile.ZipFile(backend_fixture, "w") as archive: + archive.writestr("MoviePilot-v3.1.0/version.py", version_source) + archive.writestr("MoviePilot-v3.1.0/pyproject.toml", "[project]\n") + archive.writestr("MoviePilot-v3.1.0/uv.lock", "version = 1\n") + with zipfile.ZipFile(frontend_fixture, "w") as archive: + archive.writestr("dist/index.html", "ok") + archive.writestr("dist/version.txt", "v3.1.0\n") + + fixtures = iter((backend_fixture, frontend_fixture)) + downloads: list[str] = [] + + def download(url, destination, downloaded_before, _total_hint): + downloads.append(url) + source = next(fixtures) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(source.read_bytes()) + size = destination.stat().st_size + return downloaded_before + size, size + + monkeypatch.setattr(manager, "_download_file", download) + monkeypatch.setattr(manager, "_prepare_local_backend_ref", lambda _version: None) + monkeypatch.setattr( + manager, + "_fetch_frontend_release", + lambda _version: { + "assets": [ + { + "name": "dist.zip", + "size": frontend_fixture.stat().st_size, + "browser_download_url": "https://example.invalid/dist.zip", + "digest": f"sha256:{manager._sha256(frontend_fixture)}", + } + ] + }, + ) + manager._write_state(state="downloading", version="v3.1.0") + manager._active_target = "application" + return downloads + + +@pytest.mark.parametrize( + ("stamp", "min_baseline"), + [ + ("2026-10-22\n", "2026-10-22"), + ("2026-11-01", "2026-10-22"), + (None, "2026-01-01"), + (None, None), + ("2026-01-01", None), + ], + ids=["equal", "newer-image", "missing-stamp-at-legacy-floor", "undeclared", "undeclared-with-stamp"], +) +def test_download_accepts_image_meeting_system_baseline(monkeypatch, tmp_path, stamp, min_baseline): + """ + 镜像基线满足要求、目标未声明或要求不高于旧镜像下限时,更新包照常进入可安装状态。 + """ + manager = _manager(monkeypatch, tmp_path, stamp=stamp) + _prepare_download(monkeypatch, tmp_path, manager, min_baseline) + + manager._download_update("application") + + status = manager.get_status() + assert status.state == "ready" + assert status.error is None + assert (manager._root / "prepared.json").exists() + + +@pytest.mark.parametrize( + ("stamp", "current"), + [ + ("2026-10-22", "2026-10-22"), + (None, "未标记"), + ("not-a-date", "未标记"), + ("2026-02-30", "未标记"), + ], + ids=["older-image", "missing-stamp", "malformed-stamp", "impossible-date-stamp"], +) +def test_download_refuses_image_below_system_baseline(monkeypatch, tmp_path, stamp, current): + """ + 镜像基线低于目标要求(缺失或无效视为最旧)时拒绝更新,不下载前端也不进入可安装状态。 + """ + manager = _manager(monkeypatch, tmp_path, stamp=stamp) + downloads = _prepare_download(monkeypatch, tmp_path, manager, "2026-11-05") + + manager._download_update("application") + + status = manager.get_status() + item = next(item for item in status.updates if item.type == "application") + assert item.state == "failed" + assert item.can_install is False + assert item.error == ( + "该版本包含系统组件安全更新,需要先更新 Docker 镜像" + f"(要求系统基线 2026-11-05,当前 {current})" + ) + assert len(downloads) == 1 + assert not (manager._root / "prepared.json").exists() + + +def test_download_refuses_malformed_target_system_baseline(monkeypatch, tmp_path): + """ + 目标版本声明了无效系统基线时按失败处理,避免错误发布绕过门禁。 + """ + manager = _manager(monkeypatch, tmp_path, stamp="2026-10-22") + _prepare_download(monkeypatch, tmp_path, manager, "2026/11/05") + + manager._download_update("application") + + item = next(item for item in manager.get_status().updates if item.type == "application") + assert item.state == "failed" + assert item.error == "目标版本声明的系统基线无效:2026/11/05" + + +def test_non_docker_download_skips_system_baseline(monkeypatch, tmp_path): + """ + 本地源码部署的系统组件不随镜像发布,不执行镜像基线门禁。 + """ + manager = _manager(monkeypatch, tmp_path, docker=False, stamp=None) + _prepare_download(monkeypatch, tmp_path, manager, "2099-01-01") + + manager._download_update("application") + + assert manager.get_status().state == "ready" + prepared = json.loads((manager._root / "prepared.json").read_text(encoding="utf-8")) + assert prepared["version"] == "v3.1.0" + + +def _run_update_check(tmp_path: Path, version_source: str, stamp: str | None) -> subprocess.CompletedProcess: + """ + 以 Dev 更新入口运行 update.sh,下载被替换为写入指定 version.py 的离线归档。 + """ + update_dir = tmp_path / "update" + app_dir = update_dir / "App" + app_dir.mkdir(parents=True) + (app_dir / "version.py").write_text(version_source, encoding="utf-8") + (app_dir / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + (app_dir / "uv.lock").write_text("version = 1\n", encoding="utf-8") + baseline_file = tmp_path / "system-baseline" + if stamp is not None: + baseline_file.write_text(stamp, encoding="utf-8") + route_marker = tmp_path / "route-called" + script = textwrap.dedent( + f"""\ + CONFIG_DIR="$1" + TMP_PATH="$2" + ROUTE_MARKER="$3" + export MOVIEPILOT_SYSTEM_BASELINE_FILE="$4" + PIP_PROXY= PROXY_HOST= + source {UPDATER!s} + INFO() {{ :; }} + ERROR() {{ :; }} + download_and_unzip() {{ return 0; }} + dependency_manifests_changed() {{ touch "${{ROUTE_MARKER}}"; return 1; }} + install_backend_and_download_resources heads/v3.zip + """ + ) + return subprocess.run( + [ + "bash", + "-c", + script, + "system-baseline-test", + str(tmp_path / "config"), + str(update_dir), + str(route_marker), + str(baseline_file), + ], + text=True, + capture_output=True, + check=False, + ) + + +@pytest.mark.parametrize( + ("version_source", "stamp", "message"), + [ + ( + "MIN_SYSTEM_BASELINE = '2026-11-05'\n", + "2026-10-22\n", + "要求系统基线 2026-11-05,当前 2026-10-22", + ), + ("MIN_SYSTEM_BASELINE = '2026-11-05'\n", None, "要求系统基线 2026-11-05,当前 未标记"), + ("MIN_SYSTEM_BASELINE = '2026-11-05'\n", "garbage", "要求系统基线 2026-11-05,当前 未标记"), + ("MIN_SYSTEM_BASELINE = 'soon'\n", "2026-10-22", "更新包声明的系统基线无效(soon)"), + ], + ids=["older-image", "missing-stamp", "malformed-stamp", "malformed-target"], +) +def test_dev_update_refuses_image_below_system_baseline(tmp_path, version_source, stamp, message): + """ + Dev 更新在依赖检查和载荷替换之前拒绝不满足系统基线的镜像,并输出警告。 + """ + result = _run_update_check(tmp_path, version_source, stamp) + + assert result.returncode == 1 + assert message in result.stdout + assert "WARN" in result.stdout + assert not (tmp_path / "route-called").exists() + + +@pytest.mark.parametrize( + ("version_source", "stamp"), + [ + ("MIN_SYSTEM_BASELINE = '2026-10-22'\n", "2026-10-22"), + ("MIN_SYSTEM_BASELINE = '2026-10-22'\n", "2026-12-01"), + ("MIN_SYSTEM_BASELINE = '2026-01-01'\n", None), + ("FRONTEND_VERSION = 'v3.0.1'\n", None), + ], + ids=["equal", "newer-image", "missing-stamp-at-legacy-floor", "undeclared"], +) +def test_dev_update_continues_when_image_meets_system_baseline(tmp_path, version_source, stamp): + """ + 镜像满足系统基线时 Dev 更新继续进入依赖检查阶段。 + """ + result = _run_update_check(tmp_path, version_source, stamp) + + assert "系统基线" not in result.stdout + assert (tmp_path / "route-called").exists() diff --git a/version.py b/version.py index 94ab8af75..f91e6cdd3 100644 --- a/version.py +++ b/version.py @@ -4,3 +4,8 @@ APP_VERSION = 'v3.1.9' FRONTEND_VERSION = 'v3.1.9' +# 本版本要求的最低 Docker 镜像系统基线(ISO 日期),与镜像内 /etc/moviepilot/system-baseline 比较。 +# 低于该值的镜像拒绝应用内更新,提示用户先更新 Docker 镜像。只在修复严重系统级漏洞时提高, +# 且在同一发布中把 docker/system-baseline 改成相同日期,保证新镜像自身满足要求。 +# 2026-01-01 是基线机制引入前的下限,旧镜像缺少基线文件时按此值处理,因此不拦截任何更新。 +MIN_SYSTEM_BASELINE = '2026-01-01'