mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-05 22:59:46 +08:00
feat(update): require image system baseline before installing updates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
InfinityPacer
parent
cba9779a8a
commit
ec922865be
@@ -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:
|
||||
"""
|
||||
校验前端包只包含可接受的发布目录和匹配版本。
|
||||
|
||||
@@ -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 "→ 检查依赖变化..."
|
||||
|
||||
340
tests/test_system_baseline.py
Normal file
340
tests/test_system_baseline.py
Normal 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()
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user