feat(system): add staged release updates

This commit is contained in:
jxxghp
2026-08-24 17:58:03 +08:00
parent 5415398027
commit 84326e20de
23 changed files with 1393 additions and 350 deletions
+20 -3
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6579,
"edge_sha256": "87982f9e351a23cb949bcb8a978b9c1eced6260d6c19761fccc9557b79ac90d3",
"edge_count": 6595,
"edge_sha256": "b0709f6e54bf046386d5df81c54a3e65889f18473b8cc91bbe7c97010bda02dc",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -171,6 +171,19 @@
"app.adapters.system.rust -> app.runtime",
"app.adapters.system.rust -> app.runtime.log",
"app.adapters.system.rust -> app.runtime.settings",
"app.adapters.system.update -> app.adapters",
"app.adapters.system.update -> app.adapters.network",
"app.adapters.system.update -> app.adapters.network.http",
"app.adapters.system.update -> app.foundation",
"app.adapters.system.update -> app.foundation.environment",
"app.adapters.system.update -> app.foundation.singleton",
"app.adapters.system.update -> app.foundation.version",
"app.adapters.system.update -> app.runtime",
"app.adapters.system.update -> app.runtime.log",
"app.adapters.system.update -> app.runtime.settings",
"app.adapters.system.update -> app.runtime.thread",
"app.adapters.system.update -> app.schemas",
"app.adapters.system.update -> app.schemas.system",
"app.adapters.web.correlation -> app.runtime",
"app.adapters.web.correlation -> app.runtime.correlation",
"app.adapters.web.health -> app.runtime",
@@ -2263,6 +2276,7 @@
"app.api.endpoints.system -> app.adapters.network.http",
"app.api.endpoints.system -> app.adapters.system",
"app.api.endpoints.system -> app.adapters.system.rust",
"app.api.endpoints.system -> app.adapters.system.update",
"app.api.endpoints.system -> app.adapters.web",
"app.api.endpoints.system -> app.adapters.web.security",
"app.api.endpoints.system -> app.adapters.web.security.access",
@@ -5830,6 +5844,8 @@
"app.scheduler -> app.adapters",
"app.scheduler -> app.adapters.external",
"app.scheduler -> app.adapters.external.server",
"app.scheduler -> app.adapters.system",
"app.scheduler -> app.adapters.system.update",
"app.scheduler -> app.application",
"app.scheduler -> app.application.agent",
"app.scheduler -> app.application.agentdata",
@@ -6596,7 +6612,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 812,
"module_count": 813,
"modules": [
"app",
"app.adapters",
@@ -6637,6 +6653,7 @@
"app.adapters.system.resource",
"app.adapters.system.rust",
"app.adapters.system.stdio",
"app.adapters.system.update",
"app.adapters.web",
"app.adapters.web.correlation",
"app.adapters.web.health",
+116 -69
View File
@@ -1,9 +1,10 @@
from __future__ import annotations
import importlib.util
import hashlib
import json
import sys
import tempfile
import unittest
import uuid
from pathlib import Path
from types import ModuleType, SimpleNamespace
@@ -15,12 +16,8 @@ MODULE_PATH = Path(__file__).resolve().parents[1] / "app" / "cli.py"
class _DummySystemHelper:
@staticmethod
def consume_one_shot_update_mode():
return None
@staticmethod
def get_auto_update_mode():
return "false"
def consume_one_shot_dev_update():
return False
def load_cli_module():
@@ -39,6 +36,7 @@ def load_cli_module():
PROXY_HOST="",
PIP_PROXY="",
GITHUB_TOKEN="",
MOVIEPILOT_AUTO_UPDATE="false",
PROXY={},
REPO_GITHUB_HEADERS=lambda _repo: {},
)
@@ -84,74 +82,123 @@ def load_cli_module():
return module
class CliAutoUpdateTests(unittest.TestCase):
def test_resolve_auto_update_targets_only_queries_backend_release(self):
module = load_cli_module()
def test_resolve_auto_update_targets_keeps_dev_branch_tracking():
module = load_cli_module()
with patch.object(module, "_git_current_branch", return_value="v3"):
assert module._resolve_auto_update_targets("dev") == "latest"
assert module._resolve_auto_update_targets("release") is None
with patch.object(module, "_latest_release_tag", return_value="v2.10.12") as latest_mock:
backend_ref = module._resolve_auto_update_targets("release")
latest_mock.assert_called_once_with(
module.BACKEND_RELEASES_API,
repo="jxxghp/MoviePilot",
prefix="v2",
)
self.assertEqual(backend_ref, "v2.10.12")
def test_one_shot_dev_update_overrides_disabled_default():
module = load_cli_module()
module.settings.MOVIEPILOT_AUTO_UPDATE = "false"
def test_best_effort_auto_update_does_not_pass_frontend_version_override(self):
module = load_cli_module()
run_result = SimpleNamespace(returncode=0, stdout="ok")
with patch.object(
module.SystemHelper, "consume_one_shot_dev_update", return_value=True
):
assert module._auto_update_mode() == "dev"
with patch.object(module, "_auto_update_mode", return_value="release"), patch.object(
module, "_resolve_auto_update_targets", return_value="v2.10.12"
), patch.object(module.subprocess, "run", return_value=run_result) as run_mock, patch.object(
module.click, "echo"
):
module._best_effort_auto_update()
command = run_mock.call_args.args[0]
self.assertEqual(command[1:5], [str(module._repo_root() / "scripts" / "local_setup.py"), "update", "all", "--ref"])
self.assertNotIn("--frontend-version", command)
def test_release_mode_does_not_update_during_start():
module = load_cli_module()
with patch.object(module, "_auto_update_mode", return_value="release"), patch.object(
module.subprocess, "run"
) as run_mock:
module._best_effort_auto_update()
run_mock.assert_not_called()
def test_best_effort_auto_update_passes_package_env_and_overrides_proxy(self):
module = load_cli_module()
module.settings.PROXY_HOST = "http://proxy.example:7890"
module.settings.PIP_PROXY = "https://mirror.example/simple"
run_result = SimpleNamespace(returncode=0, stdout="ok")
with patch.dict(module.os.environ, {"HTTPS_PROXY": "http://old.example:8080"}, clear=True), patch.object(
module, "_auto_update_mode", return_value="release"
), patch.object(module, "_resolve_auto_update_targets", return_value="v2.10.12"), patch.object(
module.subprocess, "run", return_value=run_result
) as run_mock, patch.object(
module.click, "echo"
):
module._best_effort_auto_update()
env = run_mock.call_args.kwargs["env"]
self.assertEqual(env["HTTPS_PROXY"], "http://proxy.example:7890")
self.assertEqual(env["PIP_PROXY"], "https://mirror.example/simple")
self.assertEqual(env["PACKAGE_CACHE_ROOT"], str(module.settings.PACKAGE_CACHE_PATH))
self.assertEqual(env["UV_CACHE_DIR"], str(module.settings.PACKAGE_CACHE_PATH / "uv"))
def test_best_effort_auto_update_derives_tool_cache_from_existing_root(self):
module = load_cli_module()
run_result = SimpleNamespace(returncode=0, stdout="ok")
package_cache_root = Path("/custom/package-cache-root")
with patch.dict(
module.os.environ,
def test_prepared_release_uses_downloaded_package_before_dev_mode():
module = load_cli_module()
module.PREPARED_UPDATE_ROOT.mkdir(parents=True)
backend = module.PREPARED_UPDATE_ROOT / "backend.zip"
frontend = module.PREPARED_UPDATE_ROOT / "frontend.zip"
backend.write_bytes(b"backend")
frontend.write_bytes(b"frontend")
module.PREPARED_UPDATE_MANIFEST.write_text(
json.dumps(
{
"PACKAGE_CACHE_ROOT": str(package_cache_root),
},
clear=True,
), patch.object(module, "_auto_update_mode", return_value="release"), patch.object(
module, "_resolve_auto_update_targets", return_value="v2.10.12"
), patch.object(module.subprocess, "run", return_value=run_result) as run_mock, patch.object(
module.click, "echo"
):
module._best_effort_auto_update()
"version": "v3.1.0",
"frontend_version": "v3.1.0",
"backend_archive": str(backend),
"frontend_archive": str(frontend),
"backend_sha256": hashlib.sha256(backend.read_bytes()).hexdigest(),
"frontend_sha256": hashlib.sha256(frontend.read_bytes()).hexdigest(),
}
),
encoding="utf-8",
)
run_result = SimpleNamespace(returncode=0, stdout="ok")
env = run_mock.call_args.kwargs["env"]
self.assertEqual(env["PACKAGE_CACHE_ROOT"], str(package_cache_root))
self.assertEqual(env["UV_CACHE_DIR"], str(package_cache_root / "uv"))
with patch.object(module, "_auto_update_mode", return_value="dev") as mode, patch.object(
module.subprocess, "run", return_value=run_result
) as run_mock, patch.object(module.click, "echo"):
module._best_effort_auto_update()
command = run_mock.call_args.args[0]
assert "--offline-backend" in command
assert command[command.index("--frontend-archive") + 1] == str(frontend)
assert not module.PREPARED_UPDATE_MANIFEST.exists()
mode.assert_not_called()
def test_best_effort_auto_update_does_not_pass_frontend_version_override():
module = load_cli_module()
run_result = SimpleNamespace(returncode=0, stdout="ok")
with patch.object(module, "_auto_update_mode", return_value="dev"), patch.object(
module, "_resolve_auto_update_targets", return_value="latest"
), patch.object(module.subprocess, "run", return_value=run_result) as run_mock, patch.object(
module.click, "echo"
):
module._best_effort_auto_update()
command = run_mock.call_args.args[0]
assert command[1:5] == [
str(module._repo_root() / "scripts" / "local_setup.py"),
"update",
"all",
"--ref",
]
assert "--frontend-version" not in command
def test_best_effort_auto_update_passes_package_env_and_overrides_proxy():
module = load_cli_module()
module.settings.PROXY_HOST = "http://proxy.example:7890"
module.settings.PIP_PROXY = "https://mirror.example/simple"
run_result = SimpleNamespace(returncode=0, stdout="ok")
with patch.dict(module.os.environ, {"HTTPS_PROXY": "http://old.example:8080"}, clear=True), patch.object(
module, "_auto_update_mode", return_value="dev"
), patch.object(module, "_resolve_auto_update_targets", return_value="latest"), patch.object(
module.subprocess, "run", return_value=run_result
) as run_mock, patch.object(module.click, "echo"):
module._best_effort_auto_update()
env = run_mock.call_args.kwargs["env"]
assert env["HTTPS_PROXY"] == "http://proxy.example:7890"
assert env["PIP_PROXY"] == "https://mirror.example/simple"
assert env["PACKAGE_CACHE_ROOT"] == str(module.settings.PACKAGE_CACHE_PATH)
assert env["UV_CACHE_DIR"] == str(module.settings.PACKAGE_CACHE_PATH / "uv")
def test_best_effort_auto_update_derives_tool_cache_from_existing_root():
module = load_cli_module()
run_result = SimpleNamespace(returncode=0, stdout="ok")
package_cache_root = Path("/custom/package-cache-root")
with patch.dict(
module.os.environ,
{"PACKAGE_CACHE_ROOT": str(package_cache_root)},
clear=True,
), patch.object(module, "_auto_update_mode", return_value="dev"), patch.object(
module, "_resolve_auto_update_targets", return_value="latest"
), patch.object(module.subprocess, "run", return_value=run_result) as run_mock, patch.object(
module.click, "echo"
):
module._best_effort_auto_update()
env = run_mock.call_args.kwargs["env"]
assert env["PACKAGE_CACHE_ROOT"] == str(package_cache_root)
assert env["UV_CACHE_DIR"] == str(package_cache_root / "uv")
+62 -9
View File
@@ -1,3 +1,5 @@
import hashlib
import json
import os
import shlex
import subprocess
@@ -761,7 +763,64 @@ def test_updater_exposes_explicit_result(
assert result.stdout == f"{expected}\n"
def test_release_noop_preserves_prerelease_selection_without_probing_package_index(
def test_prepared_release_is_verified_and_installed_without_release_lookup(tmp_path: Path) -> None:
config_dir = tmp_path / "config"
update_root = config_dir / "temp" / "moviepilot-update"
update_root.mkdir(parents=True)
backend = update_root / "backend.zip"
frontend = update_root / "frontend.zip"
backend.write_bytes(b"backend-package")
frontend.write_bytes(b"frontend-package")
backend_sha256 = hashlib.sha256(backend.read_bytes()).hexdigest()
frontend_sha256 = hashlib.sha256(frontend.read_bytes()).hexdigest()
(update_root / "install.json").write_text(
json.dumps(
{
"version": "v3.1.0",
"frontend_version": "v3.1.0",
"backend_archive": str(backend),
"frontend_archive": str(frontend),
"backend_sha256": backend_sha256,
"frontend_sha256": frontend_sha256,
}
),
encoding="utf-8",
)
release_probe = tmp_path / "release-probe"
script = textwrap.dedent(
f"""\
CONFIG_DIR="$1"
MOVIEPILOT_AUTO_UPDATE=release
PIP_PROXY= PROXY_HOST= GITHUB_PROXY= GITHUB_TOKEN=
RELEASE_PROBE="$2"
source {UPDATER!s}
INFO() {{ :; }}
WARN() {{ :; }}
ERROR() {{ :; }}
test_connectivity_github() {{ touch "${{RELEASE_PROBE}}"; return 1; }}
install_backend_and_download_resources() {{
test "${{MOVIEPILOT_PREPARED_UPDATE}}" = true
test "$1" = tags/v3.1.0.zip
MOVIEPILOT_UPDATE_RESULT=updated
}}
run_moviepilot_update
printf '%s\n' "${{MOVIEPILOT_UPDATE_RESULT}}"
"""
)
result = subprocess.run(
["bash", "-c", script, "prepared-update-test", str(config_dir), str(release_probe)],
text=True,
capture_output=True,
check=True,
)
assert result.stdout == "updated\n"
assert not release_probe.exists()
assert not (update_root / "install.json").exists()
def test_release_mode_no_longer_checks_or_installs_during_restart(
tmp_path: Path,
) -> None:
package_probe = tmp_path / "package-probe"
@@ -823,14 +882,8 @@ def test_release_noop_preserves_prerelease_selection_without_probing_package_ind
assert result.stdout == "noop\n"
assert not package_probe.exists()
curl_args = curl_log.read_text(encoding="utf-8")
assert "/releases" in curl_args
assert "/releases/latest" not in curl_args
assert "--compressed" in curl_args
assert "--fail" in curl_args
assert "--connect-timeout 5" in curl_args
assert "--max-time 15" in curl_args
assert comparison_log.read_text(encoding="utf-8") == "v3.0.0|v3.1.0-rc\n"
assert not curl_log.exists()
assert not comparison_log.exists()
@pytest.mark.parametrize(
+32 -6
View File
@@ -916,7 +916,7 @@ def test_uvicorn_preserves_stop_requested_before_serve(monkeypatch):
asyncio.run(server.serve())
@pytest.mark.parametrize("endpoint_name", ["restart_system", "upgrade_system"])
@pytest.mark.parametrize("endpoint_name", ["restart_system", "install_system_update"])
@pytest.mark.parametrize(
"initially_stopped",
[False, True],
@@ -927,7 +927,7 @@ def test_restart_endpoint_failure_preserves_stop_state(
endpoint_name,
initially_stopped,
):
"""重启或升级失败不能发布或撤销停止请求"""
"""重启或更新安装失败不能发布或撤销停止请求"""
from app.api.endpoints import system
stop_event = threading.Event()
@@ -935,19 +935,45 @@ def test_restart_endpoint_failure_preserves_stop_state(
stop_event.set()
monkeypatch.setattr(system.global_vars, "STOP_EVENT", stop_event)
monkeypatch.setattr(system.SystemHelper, "can_restart", MagicMock(return_value=True))
monkeypatch.setattr(system.SystemHelper, "restart", MagicMock(return_value=(False, "restart failed")))
monkeypatch.setattr(
system.SystemHelper,
"restart" if endpoint_name == "restart_system" else "upgrade",
MagicMock(return_value=(False, "restart failed")),
system.system_update_manager,
"request_install",
MagicMock(return_value=(True, "prepared")),
)
cancel_install = MagicMock()
monkeypatch.setattr(system.system_update_manager, "cancel_install", cancel_install)
if endpoint_name == "restart_system":
response = system.restart_system(None)
else:
response = system.upgrade_system(None, None)
response = system.install_system_update(None)
assert not response.success
assert stop_event.is_set() is initially_stopped
if endpoint_name == "install_system_update":
cancel_install.assert_called_once_with("restart failed")
else:
cancel_install.assert_not_called()
def test_upgrade_endpoint_retains_dev_mode_only(monkeypatch):
"""旧升级入口只保留 Dev,Release 必须迁移到后台下载流程。"""
from app.api.endpoints import system
monkeypatch.setattr(system.SystemHelper, "can_restart", MagicMock(return_value=True))
upgrade_dev = MagicMock(return_value=(True, "dev queued"))
monkeypatch.setattr(system.SystemHelper, "upgrade_dev", upgrade_dev)
dev_response = system.upgrade_system("dev", None)
release_response = system.upgrade_system("release", None)
legacy_default_response = system.upgrade_system(None, None)
assert dev_response.success
assert not release_response.success
assert not legacy_default_response.success
assert "update/check" in release_response.message
upgrade_dev.assert_called_once_with()
def test_command_restart_failure_does_not_publish_stop_request(monkeypatch):
+170
View File
@@ -0,0 +1,170 @@
"""系统后台更新状态机测试。"""
import json
import threading
import zipfile
from pathlib import Path
from types import SimpleNamespace
from app.adapters.system import update as update_module
def _manager(monkeypatch, tmp_path: Path):
monkeypatch.setattr(
update_module,
"get_runtime_setting",
lambda key: tmp_path if key == "TEMP_PATH" else None,
)
manager = object.__new__(update_module.SystemUpdateManager)
manager._lock = threading.RLock()
manager._download_active = False
return manager
def _response(payload, status_code=200):
return SimpleNamespace(status_code=status_code, json=lambda: payload)
def test_check_exposes_new_stable_release(monkeypatch, tmp_path):
manager = _manager(monkeypatch, tmp_path)
releases = [
{"tag_name": "v3.2.0-beta", "prerelease": True, "draft": False},
{
"tag_name": "v3.1.0",
"name": "MoviePilot v3.1.0",
"body": "changes",
"published_at": "2026-08-24T00:00:00Z",
"prerelease": False,
"draft": False,
},
]
monkeypatch.setattr(manager, "_request", lambda: SimpleNamespace(get_res=lambda _url: _response(releases)))
monkeypatch.setattr(update_module, "APP_VERSION", "v3.0.0")
status = manager.check()
assert status.state == "available"
assert status.version == "v3.1.0"
assert status.release_notes == "changes"
assert status.can_update is True
def test_scheduled_check_failure_stays_silent(monkeypatch, tmp_path):
manager = _manager(monkeypatch, tmp_path)
monkeypatch.setattr(
manager,
"_request",
lambda: SimpleNamespace(get_res=lambda _url: _response({}, status_code=503)),
)
status = manager.check()
assert status.state == "idle"
assert status.error
def test_interrupted_download_becomes_retryable_failure(monkeypatch, tmp_path):
manager = _manager(monkeypatch, tmp_path)
manager._write_state(state="downloading", version="v3.1.0")
status = manager.get_status()
assert status.state == "failed"
assert status.can_update is True
assert "中断" in status.error
def test_download_prepares_matching_backend_and_frontend_archives(monkeypatch, tmp_path):
manager = _manager(monkeypatch, tmp_path)
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",
"APP_VERSION = 'v3.1.0'\nFRONTEND_VERSION = 'v3.1.0'\n",
)
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))
def download(_url, destination, downloaded_before, _total_hint):
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(update_module, "is_docker", lambda: True)
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._download_update("v3.1.0")
status = manager.get_status()
prepared = json.loads((manager._root / "prepared.json").read_text(encoding="utf-8"))
assert status.state == "ready"
assert status.progress == 100
assert status.frontend_version == "v3.1.0"
assert prepared["backend_sha256"] == manager._sha256(manager._backend_archive)
assert prepared["frontend_sha256"] == manager._sha256(manager._frontend_archive)
def test_request_install_rejects_modified_prepared_package(monkeypatch, tmp_path):
manager = _manager(monkeypatch, tmp_path)
manager._root.mkdir(parents=True)
manager._backend_archive.write_bytes(b"backend")
manager._frontend_archive.write_bytes(b"frontend")
(manager._root / "prepared.json").write_text(
json.dumps(
{
"version": "v3.1.0",
"frontend_version": "v3.1.0",
"backend_archive": str(manager._backend_archive),
"frontend_archive": str(manager._frontend_archive),
"backend_sha256": "invalid",
"frontend_sha256": manager._sha256(manager._frontend_archive),
}
),
encoding="utf-8",
)
manager._write_state(state="ready", version="v3.1.0", can_install=True)
success, message = manager.request_install()
assert success is False
assert "后端更新包校验失败" in message
assert not manager._install_file.exists()
assert manager.get_status().state == "failed"
def test_cancel_install_returns_prepared_update_to_ready(monkeypatch, tmp_path):
manager = _manager(monkeypatch, tmp_path)
manager._root.mkdir(parents=True)
manager._install_file.write_text("{}", encoding="utf-8")
manager._write_state(state="installing", version="v3.1.0")
manager.cancel_install("restart failed")
status = manager.get_status()
assert status.state == "ready"
assert status.can_install is True
assert status.error == "restart failed"
assert not manager._install_file.exists()
+21 -1
View File
@@ -15,7 +15,7 @@ import psutil
import pytest
from app.runtime.state import SystemHelper
from app.runtime.config import ConfigModel, settings
from app.runtime.config import ConfigModel, Settings, settings
from app.adapters.system.host import SystemUtils
@@ -523,6 +523,26 @@ def test_btrfs_fsid_dedup_setting_is_opt_in():
assert ConfigModel(BTRFS_FSID_DEDUP="true").BTRFS_FSID_DEDUP is True
def test_legacy_release_auto_update_mode_is_disabled(monkeypatch):
"""历史 Release 启动更新值迁移为关闭,Dev 值继续保留。"""
updates = []
monkeypatch.setattr(
Settings,
"update_env_config",
lambda field, original, converted: updates.append(
(field, original, converted)
),
)
assert Settings(MOVIEPILOT_AUTO_UPDATE="release").MOVIEPILOT_AUTO_UPDATE == "false"
assert Settings(MOVIEPILOT_AUTO_UPDATE="true").MOVIEPILOT_AUTO_UPDATE == "false"
assert Settings(MOVIEPILOT_AUTO_UPDATE="dev").MOVIEPILOT_AUTO_UPDATE == "dev"
assert updates == [
("MOVIEPILOT_AUTO_UPDATE", "release", "false"),
("MOVIEPILOT_AUTO_UPDATE", "true", "false"),
]
def test_space_usage_default_path_does_not_read_fsid():
with tempfile.TemporaryDirectory() as tmp1, tempfile.TemporaryDirectory() as tmp2:
paths = [Path(tmp1), Path(tmp2)]