feat(update): require image system baseline before installing updates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
InfinityPacer
2026-10-22 14:59:43 +08:00
committed by InfinityPacer
parent cba9779a8a
commit ec922865be
4 changed files with 468 additions and 1 deletions

View File

@@ -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:
"""
校验前端包只包含可接受的发布目录和匹配版本。

View File

@@ -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 "→ 检查依赖变化..."

View File

@@ -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()

View File

@@ -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'