fix(docker): apply release updates before supervisor restart

This commit is contained in:
jxxghp
2026-09-07 08:28:14 +08:00
parent b9120a5b6a
commit c56734a826
13 changed files with 1034 additions and 410 deletions
+504 -4
View File
@@ -4,13 +4,16 @@ from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import stat
import subprocess
import threading
import zipfile
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from tempfile import TemporaryDirectory
from typing import Any, cast
from app.adapters.network.http import RequestUtils
@@ -18,6 +21,7 @@ from app.adapters.system.resource import ResourceHelper, get_resource_versions
from app.foundation.environment import is_docker
from app.foundation.singleton import SingletonClass
from app.foundation.version import compare_version
from app.runtime.dependencies.profile import runtime_sync_arguments
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.runtime.thread import ThreadHelper
@@ -79,7 +83,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
@property
def _install_file(self) -> Path:
"""返回启动器消费的安装意图文件路径。"""
"""返回 Docker root worker 或本地 CLI 消费的安装意图文件路径。"""
return self._root / "install.json"
@property
@@ -97,6 +101,33 @@ class SystemUpdateManager(metaclass=SingletonClass):
"""返回站点资源包暂存目录。"""
return self._root / "resources"
@property
def _docker_app_dir(self) -> Path:
"""返回 Docker 当前后端源码目录。"""
return Path(get_runtime_setting("ROOT_PATH"))
@property
def _docker_public_dir(self) -> Path:
"""返回 Docker 当前前端静态文件目录。"""
return Path(get_runtime_setting("FRONTEND_PATH"))
@property
def _docker_pending_file(self) -> Path:
"""返回 Docker 载荷切换事务标记路径。"""
return Path(get_runtime_setting("TEMP_PATH")) / "__update_pending__"
@property
def _docker_previous_app_dir(self) -> Path:
"""返回 Docker 更新前后端源码备份目录。"""
app_dir = self._docker_app_dir
return app_dir.with_name(f"{app_dir.name}.__update_previous__")
@property
def _docker_previous_public_dir(self) -> Path:
"""返回 Docker 更新前前端静态文件备份目录。"""
public_dir = self._docker_public_dir
return public_dir.with_name(f"{public_dir.name}.__update_previous__")
@staticmethod
def _now() -> str:
"""返回 UTC ISO 时间戳。"""
@@ -504,10 +535,11 @@ class SystemUpdateManager(metaclass=SingletonClass):
return self.get_status()
def request_install(self, target: SystemUpdateType = _APPLICATION) -> tuple[bool, str]:
"""校验指定待安装制品,并写入启动阶段消费的安装意图。"""
"""校验指定待安装制品,并写入 Docker worker 消费的安装意图。"""
if target not in _TARGETS:
return False, f"未知升级类型:{target}"
with self._lock:
temporary: Path | None = None
state = self.get_status()
item = self._get_item(state.model_dump(), target)
if item["state"] != "ready":
@@ -529,15 +561,477 @@ class SystemUpdateManager(metaclass=SingletonClass):
if target not in targets:
targets.append(target)
prepared["targets"] = targets
self._install_file.write_text(
self._install_file.parent.mkdir(parents=True, exist_ok=True)
temporary = self._install_file.with_suffix(f".tmp.{os.getpid()}")
temporary.write_text(
json.dumps(prepared, ensure_ascii=False, indent=2), encoding="utf-8"
)
temporary.replace(self._install_file)
self._write_item(target, state="installing", can_install=False, error=None)
return True, message
except (OSError, RuntimeError, json.JSONDecodeError) as error:
if temporary is not None:
temporary.unlink(missing_ok=True)
self._write_item(target, state="failed", error=str(error), can_install=False)
return False, str(error)
def apply_prepared_update(self) -> tuple[bool, str]:
"""由 Docker root 更新 worker 将已确认制品替换到当前运行目录。"""
if not is_docker():
return False, "当前运行环境不是 Docker"
targets: set[SystemUpdateType] = set()
with self._lock:
try:
prepared = self._read_install_manifest()
targets = self._prepared_targets(prepared)
if not targets:
raise RuntimeError("更新清单缺少可安装目标")
if _APPLICATION in targets:
self._validate_application_manifest(prepared)
version = str(prepared.get("version") or "")
frontend_version = str(prepared.get("frontend_version") or "")
if self._validate_backend_archive(version) != frontend_version:
raise RuntimeError("后端更新包声明的前端版本不匹配")
self._validate_frontend_archive(frontend_version)
if _RESOURCES in targets:
self._validate_resource_manifest(prepared)
if _APPLICATION in targets:
self._apply_docker_application(
prepared,
include_resources=_RESOURCES in targets,
)
elif _RESOURCES in targets:
self._apply_docker_resources(prepared)
try:
for target in (_APPLICATION, _RESOURCES):
if target in targets:
self._consume_prepared_target(target)
except (OSError, RuntimeError, ValueError) as error:
# 载荷已经替换成功,清单清理失败不能阻止 worker 通知入口重启。
logger.warning(f"更新载荷已替换,但清理下载清单失败:{error}")
try:
self._install_file.unlink(missing_ok=True)
except OSError as error:
logger.warning(f"清理 Docker 更新安装清单失败:{error}")
return True, "已下载的更新已替换到 Docker 程序目录"
except (OSError, RuntimeError, ValueError, zipfile.BadZipFile) as error:
message = f"Docker 更新包替换失败:{error}"
self._mark_install_failed(targets, message)
logger.error(message)
return False, message
def _read_install_manifest(self) -> dict[str, Any]:
"""读取必须存在的 Docker 更新安装清单。"""
payload = json.loads(self._install_file.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise RuntimeError("更新安装清单格式无效")
return payload
@staticmethod
def _prepared_targets(prepared: dict[str, Any]) -> set[SystemUpdateType]:
"""解析更新清单中的主程序和站点资源安装目标。"""
raw_targets = prepared.get("targets")
if raw_targets is not None and not isinstance(raw_targets, list):
raise RuntimeError("更新清单目标格式无效")
if isinstance(raw_targets, list) and any(
not isinstance(target, str) or target not in _TARGETS
for target in raw_targets
):
raise RuntimeError("更新清单包含未知安装目标")
targets = {
cast(SystemUpdateType, target)
for target in raw_targets or []
}
if not targets and prepared.get("backend_archive"):
targets.add(_APPLICATION)
return targets
def _mark_install_failed(
self, targets: set[SystemUpdateType], message: str
) -> None:
"""记录 Docker 更新失败并撤销本次安装意图,保留下载包供重试。"""
try:
self._install_file.unlink(missing_ok=True)
except OSError as error:
logger.warning(f"清理 Docker 更新安装清单失败:{error}")
if not targets:
try:
state = self._read_state()
targets = {
cast(SystemUpdateType, item["type"])
for item in state.get("updates", [])
if item.get("state") == "installing" and item.get("type") in _TARGETS
}
except Exception as error: # noqa: BLE001 失败路径只记录,不能遮蔽原始错误
logger.warning(f"读取待安装更新状态失败:{error}")
for target in targets:
try:
self._write_item(
target,
state="failed",
error=message,
can_update=True,
can_install=False,
)
except Exception as error: # noqa: BLE001 失败路径不得遮蔽原始安装错误
logger.error(f"记录 {target} 更新失败状态失败:{error}")
def _consume_prepared_target(self, target: SystemUpdateType) -> None:
"""从持久化下载清单移除已替换目标,保留另一类下载制品。"""
prepared = self._read_prepared_manifest_optional()
if target == _APPLICATION:
for key in (
"version",
"frontend_version",
"backend_archive",
"frontend_archive",
"backend_sha256",
"frontend_sha256",
):
prepared.pop(key, None)
elif target == _RESOURCES:
for key in ("resource_package_version", "resource_files"):
prepared.pop(key, None)
else:
raise ValueError(f"未知升级类型:{target}")
targets = [
value
for value in prepared.get("targets", [])
if value in _TARGETS and value != target
]
if targets:
prepared["targets"] = targets
else:
prepared.pop("targets", None)
prepared_file = self._root / "prepared.json"
if prepared.get("backend_archive") or prepared.get("resource_files"):
temporary = prepared_file.with_suffix(f".tmp.{os.getpid()}")
temporary.write_text(
json.dumps(prepared, ensure_ascii=False, indent=2), encoding="utf-8"
)
temporary.replace(prepared_file)
else:
prepared_file.unlink(missing_ok=True)
def _set_docker_pending(self, state: str) -> None:
"""原子写入 Docker 载荷切换状态,供入口脚本在异常重启时恢复。"""
self._docker_pending_file.parent.mkdir(parents=True, exist_ok=True)
temporary = self._docker_pending_file.with_suffix(f".tmp.{os.getpid()}")
temporary.write_text(f"{state}\n", encoding="utf-8")
temporary.replace(self._docker_pending_file)
def _clear_docker_pending(self) -> None:
"""清除已经提交完成的 Docker 载荷切换状态。"""
self._docker_pending_file.unlink(missing_ok=True)
@staticmethod
def _setting_text(key: str, default: str = "") -> str:
"""读取更新 worker 所需的运行配置,并兼容独立 root 子进程环境。"""
try:
value = get_runtime_setting(key)
except AttributeError:
value = None
return str(value or os.getenv(key, default) or default).strip()
def _sync_docker_dependencies(self, project_dir: Path, *, force: bool = False) -> bool:
"""按新后端清单同步 Docker 共享虚拟环境依赖。"""
current_dir = self._docker_app_dir
if not force and all(
(current_dir / name).read_bytes() == (project_dir / name).read_bytes()
for name in ("pyproject.toml", "uv.lock")
):
return False
venv_path = self._setting_text("VENV_PATH", "/opt/venv")
uv_bin = self._setting_text("UV_BIN", "/usr/local/bin/uv")
command = [
uv_bin,
"sync",
"--project",
str(project_dir),
"--locked",
"--inexact",
"--no-dev",
"--no-install-project",
"--python",
f"{venv_path}/bin/python3",
*runtime_sync_arguments(),
]
package_index = self._setting_text("PIP_PROXY")
if package_index:
command.extend(("--default-index", package_index))
environment = os.environ.copy()
proxy = self._setting_text("PROXY_HOST")
if proxy:
for key in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
environment[key] = proxy
environment.update(
{
"UV_PROJECT_ENVIRONMENT": venv_path,
"UV_LINK_MODE": "copy",
}
)
try:
result = subprocess.run(
command,
cwd=str(project_dir),
env=environment,
capture_output=True,
text=True,
check=False,
)
except OSError as error:
raise RuntimeError(f"依赖同步执行失败:{error}") from error
if result.returncode != 0:
raise RuntimeError(f"依赖同步失败,退出码:{result.returncode}")
return True
def _extract_backend_archive(self, archive_path: Path, destination: Path) -> Path:
"""安全解压后端 Release,并返回唯一的源码根目录。"""
with zipfile.ZipFile(archive_path) as archive:
self._validate_zip_members(archive)
roots = {
PurePosixPath(name).parts[0]
for name in archive.namelist()
if PurePosixPath(name).parts
}
if len(roots) != 1:
raise RuntimeError("后端更新包源码根目录无效")
archive.extractall(destination)
source_root = destination / next(iter(roots))
if not source_root.is_dir():
raise RuntimeError("后端更新包源码目录不存在")
return source_root
def _extract_frontend_archive(self, archive_path: Path, destination: Path) -> Path:
"""安全解压前端 dist.zip,并返回静态文件目录。"""
with zipfile.ZipFile(archive_path) as archive:
self._validate_zip_members(archive)
archive.extractall(destination)
frontend_dir = destination / "dist"
if not frontend_dir.is_dir():
raise RuntimeError("前端更新包缺少 dist 目录")
return frontend_dir
@staticmethod
def _remove_path(path: Path) -> None:
"""删除 Docker 更新事务中的文件或目录。"""
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
elif path.exists() or path.is_symlink():
path.unlink()
@staticmethod
def _preserve_tree_ownership(source: Path, destination: Path) -> None:
"""复制运行时目录后恢复原目录的所有者,避免插件变成 root 不可写。"""
source_paths = (source, *source.rglob("*"))
for source_path in source_paths:
destination_path = destination / source_path.relative_to(source)
source_stat = source_path.lstat()
os.chown(
destination_path,
source_stat.st_uid,
source_stat.st_gid,
follow_symlinks=False,
)
@staticmethod
def _clear_staged_native_resources(resource_dir: Path) -> None:
"""清除暂存目录中的旧平台原生站点资源。"""
if not resource_dir.is_dir():
return
for path in resource_dir.iterdir():
if path.is_file() and path.name.startswith("sites.") and path.suffix in {
".so",
".pyd",
".dylib",
}:
path.unlink()
def _resource_source_dir(self, app_dir: Path) -> Path:
"""定位当前后端携带的站点资源目录,并兼容历史目录。"""
resource_dir = app_dir / "app" / "application" / "site"
for legacy_dir in (
app_dir / "app" / "infrastructure",
app_dir / "app" / "adapters" / "network",
app_dir / "app" / "helper",
):
if not resource_dir.is_dir() and legacy_dir.is_dir():
resource_dir = legacy_dir
return resource_dir
def _copy_prepared_resources(
self, prepared: dict[str, Any], resource_dir: Path
) -> None:
"""把已校验的完整站点资源包复制到指定源码目录。"""
self._clear_staged_native_resources(resource_dir)
for item in prepared.get("resource_files", []):
name = Path(str(item.get("name") or ""))
if name.name != str(name):
raise RuntimeError("站点资源文件名不安全")
shutil.copy2(str(item["path"]), resource_dir / name)
def _prepare_docker_application(
self,
prepared: dict[str, Any],
temporary_root: Path,
*,
include_resources: bool,
) -> tuple[Path, Path]:
"""解压并组装待切换的 Docker 后端、前端和插件资源载荷。"""
backend_extract = temporary_root / "backend"
frontend_extract = temporary_root / "frontend"
backend_extract.mkdir()
frontend_extract.mkdir()
source_app = self._extract_backend_archive(
Path(str(prepared["backend_archive"])), backend_extract
)
source_public = self._extract_frontend_archive(
Path(str(prepared["frontend_archive"])), frontend_extract
)
stage_app = temporary_root / "App"
stage_public = temporary_root / "public"
source_app.replace(stage_app)
source_public.replace(stage_public)
current_app = self._docker_app_dir
current_plugins = current_app / "app" / "plugins"
stage_plugins = stage_app / "app" / "plugins"
if stage_plugins.exists() or stage_plugins.is_symlink():
self._remove_path(stage_plugins)
if current_plugins.is_dir():
shutil.copytree(current_plugins, stage_plugins, symlinks=True)
self._preserve_tree_ownership(current_plugins, stage_plugins)
else:
stage_plugins.mkdir(parents=True, exist_ok=True)
if not (stage_plugins / "__init__.py").is_file():
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)
if include_resources:
self._copy_prepared_resources(prepared, stage_resources)
return stage_app, stage_public
def _restore_docker_payload(self) -> None:
"""在 Docker 载荷切换失败时恢复更新前的源码和前端目录。"""
for current, previous in (
(self._docker_app_dir, self._docker_previous_app_dir),
(self._docker_public_dir, self._docker_previous_public_dir),
):
if not previous.exists():
continue
if current.exists() or current.is_symlink():
self._remove_path(current)
previous.replace(current)
def _apply_docker_application(
self, prepared: dict[str, Any], *, include_resources: bool
) -> None:
"""原子替换 Docker 后端源码和前端静态目录,并同步依赖。"""
app_dir = self._docker_app_dir
public_dir = self._docker_public_dir
previous_app = self._docker_previous_app_dir
previous_public = self._docker_previous_public_dir
if not app_dir.is_dir() or not public_dir.is_dir():
raise RuntimeError("Docker 当前程序目录不完整")
if previous_app.exists() or previous_public.exists():
raise RuntimeError("存在未完成的 Docker 更新事务")
with TemporaryDirectory(prefix=".moviepilot-update-", dir=str(app_dir.parent)) as temp:
temporary_root = Path(temp)
stage_app, stage_public = self._prepare_docker_application(
prepared,
temporary_root,
include_resources=include_resources,
)
dependencies_changed = any(
(app_dir / name).read_bytes() != (stage_app / name).read_bytes()
for name in ("pyproject.toml", "uv.lock")
)
dependency_sync_started = False
self._set_docker_pending("prepared")
try:
if dependencies_changed:
self._set_docker_pending("dependencies")
dependency_sync_started = True
self._sync_docker_dependencies(stage_app)
self._set_docker_pending("prepared")
app_dir.replace(previous_app)
try:
public_dir.replace(previous_public)
stage_app.replace(app_dir)
stage_public.replace(public_dir)
except OSError:
self._restore_docker_payload()
raise
self._set_docker_pending("committed")
except Exception:
rollback_failed = False
try:
self._restore_docker_payload()
except OSError as error:
logger.error(f"Docker 更新回滚失败:{error}")
rollback_failed = True
if dependency_sync_started:
try:
self._set_docker_pending("dependencies")
self._sync_docker_dependencies(app_dir, force=True)
except (OSError, RuntimeError) as error:
logger.error(f"Docker 更新依赖回滚失败:{error}")
rollback_failed = True
if rollback_failed:
raise
try:
self._clear_docker_pending()
except OSError as error:
logger.error(f"清理 Docker 更新事务标记失败:{error}")
raise
raise
try:
self._remove_path(previous_app)
self._remove_path(previous_public)
self._clear_docker_pending()
except OSError as error:
logger.warning(f"Docker 更新已完成但旧载荷清理失败:{error}")
def _apply_docker_resources(self, prepared: dict[str, Any]) -> None:
"""原子替换 Docker 当前源码携带的站点资源目录。"""
resource_dir = self._resource_source_dir(self._docker_app_dir)
resource_dir.parent.mkdir(parents=True, exist_ok=True)
with TemporaryDirectory(
prefix=".moviepilot-resource-update-", dir=str(resource_dir.parent)
) as temp:
stage_dir = Path(temp) / "site"
if resource_dir.is_dir():
shutil.copytree(resource_dir, stage_dir, symlinks=True)
else:
stage_dir.mkdir()
self._copy_prepared_resources(prepared, stage_dir)
backup_dir = resource_dir.with_name(f"{resource_dir.name}.__prepared_previous__")
self._remove_path(backup_dir)
if resource_dir.exists() or resource_dir.is_symlink():
resource_dir.replace(backup_dir)
try:
stage_dir.replace(resource_dir)
except OSError:
if backup_dir.exists():
backup_dir.replace(resource_dir)
raise
self._remove_path(backup_dir)
def cancel_install(self, reason: str) -> None:
"""重启请求失败时撤销全部已选安装意图,避免下次普通启动意外安装。"""
with self._lock:
@@ -771,6 +1265,12 @@ class SystemUpdateManager(metaclass=SingletonClass):
frontend_archive = Path(str(prepared.get("frontend_archive") or ""))
if not prepared.get("version") or not prepared.get("frontend_version"):
raise RuntimeError("主程序更新清单缺少版本信息")
for archive, expected in (
(backend_archive, self._backend_archive),
(frontend_archive, self._frontend_archive),
):
if archive.resolve() != expected.resolve():
raise RuntimeError("主程序更新包路径不安全")
if not backend_archive.is_file() or self._sha256(backend_archive) != prepared.get("backend_sha256"):
raise RuntimeError("后端更新包校验失败")
if not frontend_archive.is_file() or self._sha256(frontend_archive) != prepared.get("frontend_sha256"):
@@ -785,7 +1285,7 @@ class SystemUpdateManager(metaclass=SingletonClass):
actual_names = {
str(item.get("name") or "") for item in files if isinstance(item, dict)
}
if actual_names != expected_names:
if len(files) != len(expected_names) or actual_names != expected_names:
raise RuntimeError("站点资源更新清单不是当前平台的完整资源包")
root = self._root.resolve()
for item in files:
+1 -1
View File
@@ -389,7 +389,7 @@ class SystemService:
return SystemOperationResult(status.state != "failed", status.error, status)
def install_update(self, target: SystemUpdateType = "application") -> SystemOperationResult:
"""确认指定制品并在重启失败时回滚安装请求"""
"""确认指定制品并安排 Docker worker 在受管重启前完成替换"""
if not self._control.can_restart():
return SystemOperationResult(False, "当前运行环境不支持升级操作!")
prepared, message = self._updates.prepare_install(target)
+14
View File
@@ -1213,6 +1213,20 @@ def restart(start_timeout: int, stop_timeout: int, force: bool) -> None:
click.echo(f"Frontend URL: {_frontend_base_url(frontend_result['runtime'])}")
@cli.command("apply-prepared-update", hidden=True, context_settings=CONTEXT_SETTINGS)
def apply_prepared_update() -> None:
"""由 Docker root 更新 worker 应用已确认的下载制品。"""
from app.adapters.system.update import system_update_manager
from app.foundation.environment import is_docker
if not is_docker():
raise click.ClickException("仅 Docker 更新 worker 可以执行该操作")
success, message = system_update_manager.apply_prepared_update()
if not success:
raise click.ClickException(message)
click.echo(message)
@cli.command(context_settings=CONTEXT_SETTINGS)
def status() -> None:
"""查看本地 MoviePilot 前后端服务状态"""
+37 -17
View File
@@ -9,10 +9,10 @@ from typing import Optional, Tuple
import psutil
from app.runtime.settings import get_runtime_setting
from app.foundation.environment import is_docker, is_frozen, is_windows
from app.runtime.log import logger
from app.runtime.reload import ConfigReloadMixin
from app.foundation.environment import is_windows,is_frozen,is_docker
from app.runtime.settings import get_runtime_setting
class SystemHelper(ConfigReloadMixin):
@@ -38,9 +38,13 @@ class SystemHelper(ConfigReloadMixin):
__one_shot_dev_update_flag_file = (
get_runtime_setting('TEMP_PATH') / "moviepilot.pending_dev_update"
)
__prepared_update_manifest = (
get_runtime_setting('TEMP_PATH') / "moviepilot-update/install.json"
)
__supervisor_config = Path("/etc/supervisor/supervisord.conf")
__supervisorctl = Path("/usr/bin/supervisorctl")
__supervisor_socket = Path("/run/moviepilot/supervisor.sock")
__supervisor_update_worker = "moviepilot-update-worker"
def on_config_changed(self):
"""配置变化后重新应用日志设置。"""
@@ -175,14 +179,25 @@ class SystemHelper(ConfigReloadMixin):
@staticmethod
def _schedule_supervisor_restart() -> None:
"""延迟调用本地 supervisor,确保重启接口有机会完成响应。"""
def restart_backend() -> None:
SystemHelper._schedule_supervisor_command("restart", "all")
@staticmethod
def _schedule_supervisor_shutdown() -> None:
"""延迟关闭 supervisor,让容器入口重新执行更新和启动准备流程。"""
SystemHelper._schedule_supervisor_command("shutdown")
@staticmethod
def _schedule_supervisor_command(action: str, target: Optional[str] = None) -> None:
"""延迟调用本地 supervisor 控制命令,确保重启接口有机会完成响应。"""
def run_command() -> None:
command = [
str(SystemHelper.__supervisorctl),
"-c",
str(SystemHelper.__supervisor_config),
"restart",
"all",
action,
]
if target is not None:
command.append(target)
try:
subprocess.Popen(
command,
@@ -193,9 +208,9 @@ class SystemHelper(ConfigReloadMixin):
start_new_session=True,
)
except OSError as err:
logger.error(f"调用 supervisor 重启后端失败: {err}")
logger.error(f"调用 supervisor {action} 失败: {err}")
restart_timer = threading.Timer(0.5, restart_backend)
restart_timer = threading.Timer(0.5, run_command)
restart_timer.daemon = True
restart_timer.start()
@@ -222,7 +237,7 @@ class SystemHelper(ConfigReloadMixin):
@staticmethod
def restart() -> Tuple[bool, str]:
"""请求容器内 supervisor 重启受管的前后端进程。"""
"""执行当前部署支持的受管重启流程。"""
if not is_frozen() and is_windows():
success, message = SystemHelper._windows_restart()
return success, message
@@ -244,20 +259,25 @@ class SystemHelper(ConfigReloadMixin):
and SystemHelper.__supervisor_socket.exists()
):
return False, "容器内 supervisor 未安装"
logger.info("请求容器内 supervisor 重启后端服务")
SystemHelper._schedule_supervisor_restart()
if SystemHelper.__prepared_update_manifest.is_file():
logger.info("检测到已确认的更新包,请求 root 更新 worker 替换程序目录")
SystemHelper._schedule_supervisor_command(
"start", SystemHelper.__supervisor_update_worker
)
elif SystemHelper.__one_shot_dev_update_flag_file.is_file():
logger.info("检测到一次性 Dev 更新,请求 supervisor 关闭并重新执行容器启动流程")
SystemHelper._schedule_supervisor_shutdown()
else:
logger.info("请求容器内 supervisor 重启前后端服务")
SystemHelper._schedule_supervisor_restart()
return True, ""
@staticmethod
def upgrade_dev() -> Tuple[bool, str]:
"""保留原 Dev 模式:重启后跟踪当前 v3 开发分支。"""
configured_mode = str(
get_runtime_setting('MOVIEPILOT_AUTO_UPDATE') or ""
).strip().lower()
if configured_mode != "dev":
queued, message = SystemHelper.queue_one_shot_dev_update()
if not queued:
return False, message
queued, message = SystemHelper.queue_one_shot_dev_update()
if not queued:
return False, message
ret, message = SystemHelper.restart()
if not ret:
SystemHelper.clear_one_shot_dev_update()
+102 -4
View File
@@ -32,11 +32,15 @@ function is_truthy_value() {
# 设置虚拟环境路径(兼容群晖等系统必须这样配置)
VENV_PATH="${VENV_PATH:-/opt/venv}"
export VENV_PATH
export PATH="${VENV_PATH}/bin:$PATH"
UV_BIN="${UV_BIN:-/usr/local/bin/uv}"
# 校正设置目录
CONFIG_DIR="${CONFIG_DIR:-/config}"
export CONFIG_DIR
MP_CONTROL_DIR="${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}"
export MP_CONTROL_DIR
function apply_package_cache_env() {
PACKAGE_CACHE_ROOT="${PACKAGE_CACHE_ROOT:-${CONFIG_DIR}/.cache}"
@@ -310,6 +314,36 @@ function maybe_reexec_control_bundle() {
fi
}
function run_pending_dev_update_after_supervisor_shutdown() {
# 消费由受管重启请求留下的一次性 Dev 更新标记。
[ -f "${ONE_SHOT_DEV_UPDATE_FLAG}" ] || return 1
if ! rm -f "${ONE_SHOT_DEV_UPDATE_FLAG}"; then
ERROR "→ 无法消费一次性 Dev 更新标记,停止启动。"
return 1
fi
local update_exit_code=0
MOVIEPILOT_AUTO_UPDATE="dev"
INFO "检测到受管重启的 Dev 更新请求"
run_moviepilot_update || update_exit_code=$?
MOVIEPILOT_AUTO_UPDATE="${MOVIEPILOT_AUTO_UPDATE_ORIGINAL}"
[ "${update_exit_code}" -eq 0 ] \
&& [ "${MOVIEPILOT_UPDATE_RESULT:-noop}" = "updated" ]
}
function apply_pending_release_update_at_startup() {
# worker 尚未启动就发生容器重启时,由 root 入口兜底消费安装清单。
local install_manifest="${CONFIG_DIR}/temp/moviepilot-update/install.json"
[ -f "${install_manifest}" ] || return 1
INFO "检测到未完成的 Release 安装请求,启动前由 root 安装器恢复"
if ! "${VENV_PATH}/bin/python3" -m app.cli apply-prepared-update; then
WARN "→ 启动前 Release 更新恢复失败,继续使用当前程序启动。"
return 1
fi
return 0
}
function correct_home_permissions() {
local child
@@ -422,8 +456,9 @@ function correct_file_permissions() {
load_config_from_app_env
apply_package_cache_env
# Dev 手动更新仍沿用一次性标记;Release 安装只消费已下载并校验的清单
# Dev 手动更新仍沿用一次性标记;Release 安装由 root 更新 worker 在重启前完成
ONE_SHOT_DEV_UPDATE_FLAG="${CONFIG_DIR}/temp/moviepilot.pending_dev_update"
SUPERVISOR_RESTART_REQUEST_FILE="${CONFIG_DIR}/temp/moviepilot.pending_supervisor_restart"
ONE_SHOT_DEV_UPDATE="false"
MOVIEPILOT_AUTO_UPDATE_ORIGINAL="${MOVIEPILOT_AUTO_UPDATE}"
if [ -f "${ONE_SHOT_DEV_UPDATE_FLAG}" ]; then
@@ -464,6 +499,14 @@ fi
maybe_reexec_control_bundle
cd /app || exit
if [ "${MOVIEPILOT_BOOTSTRAP_UPDATE_DONE:-0}" != "1" ] \
&& [ -f "${CONFIG_DIR}/temp/moviepilot-update/install.json" ]; then
if apply_pending_release_update_at_startup; then
INFO "→ 未完成的 Release 更新已安装,重新执行入口加载新代码。"
exec /entrypoint.sh --post-update-reexec
fi
fi
source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/browser.sh"
# 更改 moviepilot userid 和 groupid
@@ -498,7 +541,62 @@ ensure_browser_kernel
# 证书管理
source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/cert.sh"
# supervisord 常驻前台并统一托管 Nginx 与后端;容器停止信号由它转发给两个进程组
# supervisord 常驻前台并统一托管 Nginx 与后端;带更新标记的 shutdown 会回到本入口消费更新包
install -d -m 0755 /run/moviepilot
INFO "→ 启动容器进程 supervisor..."
exec /usr/bin/supervisord -n -c /etc/supervisor/supervisord.conf
# Supervisor 的控制面只在容器内使用;未显式传入时生成本次容器启动专用的随机凭据,避免固定密码进入镜像。
if [ -z "${MOVIEPILOT_SUPERVISOR_PASSWORD:-}" ]; then
MOVIEPILOT_SUPERVISOR_PASSWORD="$(openssl rand -hex 32)" || {
ERROR "→ 无法生成 supervisor 控制面认证凭据,停止启动。"
exit 1
}
fi
if [ -z "${MOVIEPILOT_SUPERVISOR_PASSWORD}" ]; then
ERROR "→ supervisor 控制面认证凭据为空,停止启动。"
exit 1
fi
export MOVIEPILOT_SUPERVISOR_PASSWORD
SUPERVISOR_SIGNAL_RECEIVED="false"
SUPERVISOR_PID=""
function forward_supervisor_signal() {
SUPERVISOR_SIGNAL_RECEIVED="true"
if [ -n "${SUPERVISOR_PID}" ]; then
kill -TERM "${SUPERVISOR_PID}" 2>/dev/null || true
fi
}
trap 'forward_supervisor_signal' SIGINT SIGTERM
while true; do
INFO "→ 启动容器进程 supervisor..."
/usr/bin/supervisord -n -c /etc/supervisor/supervisord.conf &
SUPERVISOR_PID=$!
wait "${SUPERVISOR_PID}"
supervisor_exit_code=$?
SUPERVISOR_PID=""
if [ "${SUPERVISOR_SIGNAL_RECEIVED}" = "true" ] || [ "${supervisor_exit_code}" -ne 0 ]; then
exit "${supervisor_exit_code}"
fi
if [ -f "${SUPERVISOR_RESTART_REQUEST_FILE}" ]; then
if ! rm -f "${SUPERVISOR_RESTART_REQUEST_FILE}"; then
ERROR "→ 无法消费更新后的重启请求,停止启动。"
exit 1
fi
INFO "→ 更新代码已落盘,重新执行容器入口以加载新版本。"
exec /entrypoint.sh --post-update-reexec
fi
if [ -f "${ONE_SHOT_DEV_UPDATE_FLAG}" ]; then
if run_pending_dev_update_after_supervisor_shutdown; then
INFO "→ 更新包已安装,重新执行容器入口以加载新版本。"
exec /entrypoint.sh --post-update-reexec
fi
if [ -f "${ONE_SHOT_DEV_UPDATE_FLAG}" ]; then
ERROR "→ 更新请求未能完成且标记仍存在,停止启动。"
exit 1
fi
WARN "→ Dev 更新失败,继续启动当前版本。"
continue
fi
exit 0
done
+22
View File
@@ -2,8 +2,11 @@
file=/run/moviepilot/supervisor.sock
chmod=0770
chown=root:moviepilot
username=moviepilot
password=%(ENV_MOVIEPILOT_SUPERVISOR_PASSWORD)s
[supervisord]
user=root
nodaemon=true
logfile=/dev/null
pidfile=/run/moviepilot/supervisord.pid
@@ -14,6 +17,8 @@ supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///run/moviepilot/supervisor.sock
username=moviepilot
password=%(ENV_MOVIEPILOT_SUPERVISOR_PASSWORD)s
[program:moviepilot-nginx]
command=/usr/sbin/nginx -g "daemon off;" -c /etc/nginx/nginx.conf
@@ -45,3 +50,20 @@ stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:moviepilot-update-worker]
command=/bin/bash /usr/local/lib/moviepilot/control/update-worker.sh
directory=/app
priority=30
user=root
autostart=false
autorestart=false
startsecs=0
stopsignal=TERM
stopwaitsecs=300
stopasgroup=true
killasgroup=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# shellcheck shell=bash
set -u
VENV_PATH="${VENV_PATH:-/opt/venv}"
CONFIG_DIR="${CONFIG_DIR:-/config}"
export VENV_PATH CONFIG_DIR
SUPERVISOR_CONFIG="/etc/supervisor/supervisord.conf"
RESTART_REQUEST_FILE="${CONFIG_DIR}/temp/moviepilot.pending_supervisor_restart"
function INFO() {
echo "[INFO] ${1}"
}
function ERROR() {
echo "[ERROR] ${1}" >&2
}
cd /app || exit 1
INFO "→ 开始将已下载的更新包替换到 Docker 程序目录..."
if ! "${VENV_PATH}/bin/python3" -m app.cli apply-prepared-update; then
ERROR "→ Docker 更新包替换失败,保留当前运行进程。"
exit 1
fi
if ! mkdir -p "$(dirname "${RESTART_REQUEST_FILE}")"; then
ERROR "→ 无法记录更新后的重启请求。"
exit 1
fi
restart_request_tmp="${RESTART_REQUEST_FILE}.tmp.$$"
if ! printf '%s\n' update > "${restart_request_tmp}" \
|| ! mv -f "${restart_request_tmp}" "${RESTART_REQUEST_FILE}"; then
rm -f "${restart_request_tmp}"
ERROR "→ 无法记录更新后的重启请求。"
exit 1
fi
INFO "→ 更新包已替换,通知 supervisor 关闭并由容器入口重新启动新代码..."
if ! /usr/bin/supervisorctl -c "${SUPERVISOR_CONFIG}" shutdown; then
ERROR "→ supervisor 关闭请求失败,更新代码已落盘,可手动重启后生效。"
exit 1
fi
+27 -237
View File
@@ -31,27 +31,6 @@ PUBLIC_DIR=/public
UPDATE_PENDING_FILE="${CONFIG_DIR}/temp/__update_pending__"
UPDATE_PREVIOUS_APP="${APP_DIR}.__update_previous__"
UPDATE_PREVIOUS_PUBLIC="${PUBLIC_DIR}.__update_previous__"
PREPARED_UPDATE_ROOT="${CONFIG_DIR}/temp/moviepilot-update"
PREPARED_UPDATE_MANIFEST="${PREPARED_UPDATE_ROOT}/install.json"
PREPARED_DOWNLOAD_MANIFEST="${PREPARED_UPDATE_ROOT}/prepared.json"
PREPARED_UPDATE_STATE="${PREPARED_UPDATE_ROOT}/state.json"
function mark_prepared_update_failed() {
local message="$1"
local temporary_state="${PREPARED_UPDATE_STATE}.tmp.$$"
mkdir -p "${PREPARED_UPDATE_ROOT}"
if [ -f "${PREPARED_UPDATE_STATE}" ]; then
jq --arg error "${message}" \
'.state = "failed" | .error = $error | .can_update = true | .can_install = false | .updates = ((.updates // []) | map(if .state == "installing" then .state = "failed" | .error = $error | .can_update = true | .can_install = false else . end))' \
"${PREPARED_UPDATE_STATE}" > "${temporary_state}"
else
jq -n --arg error "${message}" \
'{state: "failed", error: $error, can_update: true, can_install: false, updates: []}' \
> "${temporary_state}"
fi
mv -f "${temporary_state}" "${PREPARED_UPDATE_STATE}"
rm -f "${PREPARED_UPDATE_MANIFEST}"
}
function apply_package_cache_env() {
PACKAGE_CACHE_ROOT="${PACKAGE_CACHE_ROOT:-${CONFIG_DIR}/.cache}"
@@ -309,91 +288,6 @@ function existing_resource_dir() {
printf '%s\n' "${resource_source_dir}"
}
function prepared_update_has_target() {
local target="$1"
jq -e --arg target "${target}" \
'if (.targets | type) == "array" then (.targets | index($target)) != null else $target == "application" and (.backend_archive // "") != "" end' \
"${PREPARED_UPDATE_MANIFEST}" >/dev/null
}
function validate_prepared_resources() {
local resource_path
local resource_name
local resource_sha256
local resource_count
resource_count=$(jq -r '.resource_files // [] | length' "${PREPARED_UPDATE_MANIFEST}") || return 1
[ "${resource_count}" -gt 0 ] || return 1
jq -e '([.resource_files[]?.name] | index("user.sites.v3.bin")) != null and any(.resource_files[]?.name; startswith("sites."))' "${PREPARED_UPDATE_MANIFEST}" >/dev/null || return 1
while IFS=$'\t' read -r resource_path resource_name resource_sha256; do
[ -n "${resource_path}" ] && [ -n "${resource_name}" ] || return 1
[ "$(basename "${resource_name}")" = "${resource_name}" ] || return 1
[[ "${resource_name}" != *..* ]] || return 1
[ -f "${resource_path}" ] || return 1
[ "$(sha256sum "${resource_path}" | awk '{print $1}')" = "${resource_sha256}" ] || return 1
done < <(jq -r '.resource_files[]? | [.path, .name, .sha256] | @tsv' "${PREPARED_UPDATE_MANIFEST}")
}
function consume_prepared_target() {
local target="$1"
local temporary="${PREPARED_DOWNLOAD_MANIFEST}.tmp.$$"
[ -f "${PREPARED_DOWNLOAD_MANIFEST}" ] || return 0
jq --arg target "${target}" '
if $target == "application" then
del(.version, .frontend_version, .backend_archive, .frontend_archive, .backend_sha256, .frontend_sha256)
elif $target == "resources" then
del(.resource_package_version, .resource_files)
else . end
| if (.targets | type) == "array" then
.targets = [.targets[] | select(. != $target)]
| if (.targets | length) == 0 then del(.targets) else . end
else . end
' "${PREPARED_DOWNLOAD_MANIFEST}" > "${temporary}" || {
rm -f "${temporary}"
return 1
}
if jq -e '((.backend_archive // "") == "") and (((.resource_files // []) | length) == 0)' "${temporary}" >/dev/null; then
rm -f "${temporary}" "${PREPARED_DOWNLOAD_MANIFEST}"
else
mv -f "${temporary}" "${PREPARED_DOWNLOAD_MANIFEST}"
fi
}
function clear_staged_native_resources() {
local resource_dir="$1"
rm -f "${resource_dir}"/sites.*.so "${resource_dir}"/sites.*.pyd "${resource_dir}"/sites.*.dylib
}
function apply_prepared_resources() {
local target_dir="${APP_DIR}/app/application/site"
local stage_dir="${TMP_PATH}/PreparedResources"
local backup_dir="${target_dir}.__prepared_previous__"
local resource_path
local resource_name
validate_prepared_resources || return 1
rm -rf "${stage_dir}" "${backup_dir}"
mkdir -p "${stage_dir}" "${target_dir}" || return 1
if [ -d "${target_dir}" ] && ! cp -a "${target_dir}/." "${stage_dir}/"; then
return 1
fi
clear_staged_native_resources "${stage_dir}"
while IFS=$'\t' read -r resource_path resource_name; do
[ -n "${resource_path}" ] && [ -n "${resource_name}" ] || return 1
cp -f "${resource_path}" "${stage_dir}/${resource_name}" || return 1
done < <(jq -r '.resource_files[]? | [.path, .name] | @tsv' "${PREPARED_UPDATE_MANIFEST}")
if [ -d "${target_dir}" ]; then
mv "${target_dir}" "${backup_dir}" || return 1
fi
if ! mkdir -p "${target_dir}" || ! cp -a "${stage_dir}/." "${target_dir}/"; then
rm -rf "${target_dir}"
[ -d "${backup_dir}" ] && mv "${backup_dir}" "${target_dir}"
return 1
fi
rm -rf "${backup_dir}" "${stage_dir}"
return 0
}
function download_staged_resource() {
local url="$1"
local destination="$2"
@@ -456,17 +350,6 @@ function stage_runtime_payload() {
cp -a "${resource_file}" "${stage_resource_dir}/" || return 1
done
if [ "${MOVIEPILOT_PREPARED_UPDATE:-false}" = "true" ]; then
if prepared_update_has_target resources; then
clear_staged_native_resources "${stage_resource_dir}"
while IFS=$'\t' read -r resource_path resource_name; do
[ -n "${resource_path}" ] && [ -n "${resource_name}" ] || return 1
cp -f "${resource_path}" "${stage_resource_dir}/${resource_name}" || return 1
done < <(jq -r '.resource_files[]? | [.path, .name] | @tsv' "${PREPARED_UPDATE_MANIFEST}")
fi
return 0
fi
python_version="$("${VENV_PATH}/bin/python3" -c 'import sys, sysconfig; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}{"t" if sysconfig.get_config_var("Py_GIL_DISABLED") == 1 else ""}")')" || return 1
arch="$(uname -m)"
if [ "${arch}" = "aarch64" ]; then
@@ -503,15 +386,7 @@ function swap_staged_payload() {
# 下载程序资源,$1: 后端版本路径
function install_backend_and_download_resources() {
# 更新后端程序
if [ "${MOVIEPILOT_PREPARED_UPDATE:-false}" = "true" ]; then
if ! busybox unzip -q "${PREPARED_BACKEND_ARCHIVE}" -d "${TMP_PATH}"; then
ERROR "已准备的后端更新包解压失败"
return 1
fi
if [ -e "${TMP_PATH}"/MoviePilot-* ]; then
mv "${TMP_PATH}"/MoviePilot-* "${TMP_PATH}/App" || return 1
fi
elif ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot/archive/refs/${1}" "App"; then
if ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot/archive/refs/${1}" "App"; then
WARN "后端程序下载失败,继续使用旧的程序来启动..."
return 1
fi
@@ -532,10 +407,7 @@ function install_backend_and_download_resources() {
fi
# 如果是"heads/v3.zip",则查找v3开头的最新版本号
if [ "${MOVIEPILOT_PREPARED_UPDATE:-false}" = "true" ]; then
frontend_version="${PREPARED_FRONTEND_VERSION}"
INFO "已准备的前端版本号:${frontend_version}"
elif [[ "${1}" == "heads/v3.zip" ]]; then
if [[ "${1}" == "heads/v3.zip" ]]; then
INFO "→ 正在获取前端最新版本号..."
# 获取所有发布的版本列表,并筛选出以v3开头的版本号
releases=$(curl ${CURL_OPTIONS} "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases" ${CURL_HEADERS} | jq -r '.[].tag_name' | grep "^v3\.")
@@ -558,12 +430,7 @@ function install_backend_and_download_resources() {
INFO "前端版本号:${frontend_version}"
fi
# 更新前端程序
if [ "${MOVIEPILOT_PREPARED_UPDATE:-false}" = "true" ]; then
if ! busybox unzip -q "${PREPARED_FRONTEND_ARCHIVE}" -d "${TMP_PATH}"; then
ERROR "已准备的前端更新包解压失败"
return 1
fi
elif ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" "dist"; then
if ! download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" "dist"; then
WARN "前端程序下载失败,继续使用旧的程序来启动..."
return 1
fi
@@ -728,110 +595,33 @@ function configure_package_route() {
}
function run_moviepilot_update() {
MOVIEPILOT_UPDATE_RESULT="noop"
if [ -f "${PREPARED_UPDATE_MANIFEST}" ]; then
PREPARED_HAS_APPLICATION="false"
PREPARED_HAS_RESOURCES="false"
if prepared_update_has_target application; then PREPARED_HAS_APPLICATION="true"; fi
if prepared_update_has_target resources; then PREPARED_HAS_RESOURCES="true"; fi
PREPARED_BACKEND_ARCHIVE=$(jq -r '.backend_archive // empty' "${PREPARED_UPDATE_MANIFEST}")
PREPARED_FRONTEND_ARCHIVE=$(jq -r '.frontend_archive // empty' "${PREPARED_UPDATE_MANIFEST}")
PREPARED_BACKEND_SHA256=$(jq -r '.backend_sha256 // empty' "${PREPARED_UPDATE_MANIFEST}")
PREPARED_FRONTEND_SHA256=$(jq -r '.frontend_sha256 // empty' "${PREPARED_UPDATE_MANIFEST}")
PREPARED_VERSION=$(jq -r '.version // empty' "${PREPARED_UPDATE_MANIFEST}")
PREPARED_FRONTEND_VERSION=$(jq -r '.frontend_version // empty' "${PREPARED_UPDATE_MANIFEST}")
if [ "${PREPARED_HAS_APPLICATION}" = "true" ] && { [ ! -f "${PREPARED_BACKEND_ARCHIVE}" ] || [ ! -f "${PREPARED_FRONTEND_ARCHIVE}" ] \
|| [ "$(sha256sum "${PREPARED_BACKEND_ARCHIVE}" | awk '{print $1}')" != "${PREPARED_BACKEND_SHA256}" ] \
|| [ "$(sha256sum "${PREPARED_FRONTEND_ARCHIVE}" | awk '{print $1}')" != "${PREPARED_FRONTEND_SHA256}" ]; }; then
ERROR "已准备的更新包校验失败,拒绝安装"
mark_prepared_update_failed "已准备的更新包校验失败"
MOVIEPILOT_UPDATE_RESULT="failed"
return 1
fi
if [ "${PREPARED_HAS_RESOURCES}" = "true" ] && ! validate_prepared_resources; then
ERROR "已准备的站点资源包校验失败,拒绝安装"
mark_prepared_update_failed "已准备的站点资源包校验失败"
MOVIEPILOT_UPDATE_RESULT="failed"
return 1
fi
if [ "${PREPARED_HAS_APPLICATION}" != "true" ] && [ "${PREPARED_HAS_RESOURCES}" != "true" ]; then
ERROR "已准备的更新清单没有可安装内容,拒绝安装"
mark_prepared_update_failed "已准备的更新清单没有可安装内容"
MOVIEPILOT_UPDATE_RESULT="failed"
return 1
fi
MOVIEPILOT_PREPARED_UPDATE="true"
TMP_PATH=$(mktemp -d)
if [ ! -d "${TMP_PATH}" ]; then
# 如果自动生成 tmp 文件夹失败则手动指定,避免出现数据丢失等情况
TMP_PATH=/tmp/mp_update_path
if [ -d /tmp/mp_update_path ]; then
rm -rf /tmp/mp_update_path
MOVIEPILOT_UPDATE_RESULT="noop"
if [ "${MOVIEPILOT_AUTO_UPDATE}" = "dev" ]; then
TMP_PATH=$(mktemp -d)
if [ ! -d "${TMP_PATH}" ]; then
TMP_PATH=/tmp/mp_update_path
rm -rf "${TMP_PATH}"
mkdir -p "${TMP_PATH}"
fi
mkdir -p /tmp/mp_update_path
fi
CURL_OPTIONS="-sL"
if [ -n "${PROXY_HOST}" ]; then
CURL_OPTIONS="-sL -x ${PROXY_HOST}"
fi
if [ -n "${GITHUB_TOKEN}" ]; then
CURL_HEADERS="--oauth2-bearer ${GITHUB_TOKEN}"
else
CURL_HEADERS=""
fi
INFO "安装已下载并校验的 MoviePilot 更新包"
prepared_install_success="true"
if [ "${PREPARED_HAS_APPLICATION}" = "true" ]; then
if ! install_backend_and_download_resources "tags/${PREPARED_VERSION}.zip"; then
prepared_install_success="false"
elif [ "${PREPARED_HAS_RESOURCES}" = "true" ] && ! consume_prepared_target resources; then
prepared_install_success="false"
elif ! consume_prepared_target application; then
prepared_install_success="false"
retries=0
while true; do
if test_connectivity_github "${retries}"; then
break
fi
retries=$((retries + 1))
done
INFO "Github${GITHUB_LOG}"
if [ -n "${GITHUB_TOKEN}" ]; then
CURL_HEADERS="--oauth2-bearer ${GITHUB_TOKEN}"
else
CURL_HEADERS=""
fi
INFO "Dev 更新模式"
if ! install_backend_and_download_resources "heads/v3.zip"; then
MOVIEPILOT_UPDATE_RESULT="failed"
fi
fi
if [ "${PREPARED_HAS_APPLICATION}" != "true" ] && [ "${PREPARED_HAS_RESOURCES}" = "true" ] \
&& ! apply_prepared_resources; then
prepared_install_success="false"
elif [ "${PREPARED_HAS_APPLICATION}" != "true" ] && [ "${PREPARED_HAS_RESOURCES}" = "true" ] \
&& ! consume_prepared_target resources; then
prepared_install_success="false"
fi
if [ "${prepared_install_success}" = "true" ]; then
rm -f "${PREPARED_UPDATE_MANIFEST}"
else
mark_prepared_update_failed "已下载的 Release 更新安装失败"
MOVIEPILOT_UPDATE_RESULT="failed"
fi
if [ -d "${TMP_PATH}" ]; then
rm -rf "${TMP_PATH}"
fi
elif [ "${MOVIEPILOT_AUTO_UPDATE}" = "dev" ]; then
TMP_PATH=$(mktemp -d)
if [ ! -d "${TMP_PATH}" ]; then
TMP_PATH=/tmp/mp_update_path
rm -rf "${TMP_PATH}"
mkdir -p "${TMP_PATH}"
fi
retries=0
while true; do
if test_connectivity_github ${retries}; then
break
fi
retries=$((retries + 1))
done
INFO "Github${GITHUB_LOG}"
if [ -n "${GITHUB_TOKEN}" ]; then
CURL_HEADERS="--oauth2-bearer ${GITHUB_TOKEN}"
else
CURL_HEADERS=""
INFO "没有待安装 Dev 更新,按当前版本启动"
fi
INFO "Dev 更新模式"
if ! install_backend_and_download_resources "heads/v3.zip"; then
MOVIEPILOT_UPDATE_RESULT="failed"
fi
rm -rf "${TMP_PATH}"
else
INFO "没有待安装更新,按当前版本启动"
fi
}
+43 -21
View File
@@ -4,7 +4,7 @@
`docker/` 下的控制脚本、更新事务、依赖自愈、浏览器和证书准备、Nginx、Python lifespan、
异常保活与退出清理。
本文基于 `v3` 分支 2026-09-02 的实现整理。实际行为以当前源码为准。
本文基于 `v3` 分支 2026-09-07 的实现整理。实际行为以当前源码为准。
## 1. 文件职责
@@ -15,7 +15,8 @@
| `docker/entrypoint.sh` | 真正的容器启动编排器;加载配置,驱动更新、权限、浏览器和证书准备,最后启动 supervisor。 |
| `docker/backend.sh` | supervisor 托管的后端进程命令,负责工作目录、权限和 Python 进程。 |
| `docker/supervisord.conf` | 容器内 supervisor 配置,同时托管 Nginx 和后端。 |
| `docker/update.sh` | 被 `entrypoint.sh` source;处理未完成更新恢复、已准备 Release 安装、Dev 更新、依赖同步和载荷事务。 |
| `docker/update.sh` | 被 `entrypoint.sh` source;处理未完成更新恢复、Dev 更新、依赖同步和载荷事务,不再安装 Release 程序包。 |
| `docker/update-worker.sh` | 由 Supervisor 以 root 按需运行;将已确认的 Release/资源制品替换到 Docker 程序目录,然后请求入口重新加载新代码。 |
| `docker/browser.sh` | 被 `entrypoint.sh` source;选择持久化 CloakBrowser 缓存、校正权限并按需安装浏览器内核。 |
| `docker/cert.sh` | 被 `entrypoint.sh` source;校验证书、按需安装 acme.sh、签发证书并配置续期任务。 |
| `docker/nginx.template.conf` | 由环境变量渲染为 `/etc/nginx/nginx.conf`,提供前端静态文件、API 和 SSE 反向代理。 |
@@ -33,7 +34,7 @@ Docker
-> 渲染 Nginx 配置
-> source update.sh
-> 恢复未完成更新
-> 安装已准备的 Release/资源包,或执行 Dev 更新
-> 执行 Dev 更新
-> 必要时用更新后的控制脚本重新 exec 一次
-> source browser.sh
-> 映射 PUID/PGID
@@ -45,6 +46,7 @@ Docker
-> supervisord
-> Nginx
-> gosu moviepilot python3 app/main.py
-> 按需启动 root update-worker.sh 安装已确认 Release/资源制品
-> Uvicorn/FastAPI lifespan
-> 数据库迁移和全部生命周期组件
-> /health/ready 返回 200
@@ -63,7 +65,7 @@ Docker
### 3.1 为什么不直接执行 `/app/docker/entrypoint.sh`
Docker 的 Dev 更新可以在启动过程中整体替换 `/app`。如果当前 Shell 正在从 `/app/docker` 继续
Docker 的 Dev 更新和 root 更新 worker 都可能整体替换 `/app`。如果当前 Shell 正在从 `/app/docker` 继续
source 其他脚本,可能出现同一次启动混用新旧脚本的情况。因此 launcher 会先选择完整的一代控制脚本,
再复制到只属于本轮启动的运行时快照目录。
@@ -184,8 +186,8 @@ Alembic migration。保留当前载荷并恢复其依赖,可以避免形成“
### 5.3 已准备的 Release/资源更新
稳定版更新不在容器启动时联网检查 GitHub Release,也不在 Shell 中比较版本号。后台更新服务会提前
下载校验制品用户确认重启后生成:
稳定版更新不在容器启动时联网检查 GitHub Release,也不在 `update.sh` 中比较版本号或替换程序。
后台更新服务负责下载校验制品用户确认安装后生成:
```text
/config/temp/moviepilot-update/prepared.json
@@ -193,14 +195,21 @@ Alembic migration。保留当前载荷并恢复其依赖,可以避免形成“
/config/temp/moviepilot-update/state.json
```
启动时 `install.json` 优先于 `MOVIEPILOT_AUTO_UPDATE`处理顺序:
随后 `SystemHelper` 请求 Supervisor 启动 root 更新 worker处理顺序:
1. 识别 `application``resources` 目标。
1. root worker 识别 `application``resources` 目标。
2. 校验后端、前端和资源文件存在且 SHA-256 一致。
3. 应用更新时解压后端和前端到临时目录,保留当前插件运行目录和 V3 站点资源。
4. 同时包含资源目标时,把已准备资源写入新后端的 `app/application/site/`
5. 只有资源目标时,使用临时目录和备份目录原子替换当前资源文件。
6. 安装成功后逐项消费 `prepared.json`,删除 `install.json`;失败时写入 `state.json` 并保留可重试状态
5. 只有资源目标时,使用临时目录和备份目录原子替换当前源码携带的资源文件。
6. 后端依赖清单变化时,由 root worker 使用共享虚拟环境同步锁定依赖
7. 源码和资源替换完成后消费下载清单,写入重启标记并关闭 Supervisor;外层入口重新执行 launcher,加载新代码。
8. 校验、依赖或替换失败时不关闭当前服务,写入 `state.json` 的可重试失败状态。
如果 worker 尚未启动就发生外部容器重启,入口会在启动 Supervisor 前调用同一个 root 安装器兜底消费
`install.json`,成功后重新执行 launcher;这条恢复路径也不经过 `update.sh`
因此,制品下载完成后仍保留用户确认安装这一安全边界;确认后先替换 Docker 中的程序目录,重启只负责加载已经落盘的新代码。
### 5.4 Dev 自动更新
@@ -212,9 +221,9 @@ GitHub 访问按 `GITHUB_PROXY`、`PROXY_HOST`、直连顺序选择;包索引
### 5.5 载荷切换事务
应用更新提交顺序为:
Release root worker 的应用更新提交顺序为:
1. 下载、解压并验证后端和前端。
1. 后台下载后,在 worker 中解压并验证后端和前端。
2. 暂存插件和站点资源。
3. 写入 `prepared`
4. 依赖清单变化时写入 `dependencies`,再同步临时后端声明的依赖。
@@ -223,6 +232,9 @@ GitHub 访问按 `GITHUB_PROXY`、`PROXY_HOST`、直连顺序选择;包索引
7. 写入 `committed`
8. 删除旧代备份和事务标记。
Dev 更新仍由 `update.sh` 使用同一组事务标记处理;非 Dev 的制品替换只在
`app.adapters.system.update.SystemUpdateManager``docker/update-worker.sh` 中执行。
依赖同步固定使用当前虚拟环境解释器,并执行等价于:
```text
@@ -235,7 +247,7 @@ uv sync --project <project> --locked --inexact --no-dev --no-install-project \
### 5.6 控制脚本更新后重入
应用更新成功后,entrypoint 通过根目录 launcher 的 `--source-generation` 重新计算 `/app/docker` 代际。
Release 或 Dev 应用更新成功后,entrypoint 通过根目录 launcher 的 `--source-generation` 重新计算 `/app/docker` 代际。
如果新代际与当前 `MP_CONTROL_GENERATION` 不同,会:
```text
@@ -250,7 +262,10 @@ launcher 设置“更新已完成”和“已经重入”标志,新 entrypoint
### 6.1 运行用户映射
entrypoint 使用 `PUID``PGID` 修改镜像内 `moviepilot` 用户和组。后端、浏览器安装及 doctor 默认通过
`gosu moviepilot:moviepilot` 执行;`START_NOGOSU=true` 仅用于不降权的特殊运行场景。
`gosu moviepilot:moviepilot` 执行;Release 更新 worker 明确以 root 运行来替换 root 所有的 `/app`
`/public`,不会把运行权限提升给后端;`START_NOGOSU=true` 仅用于不降权的特殊运行场景。即使 `PUID/PGID`
设置为非 0,内置重启仍然可用,因为 Supervisor socket 会使用映射后的 `moviepilot` 组权限;但容器入口
本身必须以 root 启动,不能额外使用 Docker 的 `--user` 覆盖入口用户。
### 6.2 后端依赖自愈
@@ -310,8 +325,10 @@ entrypoint 使用 `PUID`、`PGID` 修改镜像内 `moviepilot` 用户和组。
### 7.2 Nginx 和 supervisor
证书检查完成后,entrypoint 以前台模式启动 supervisor。supervisor 同时托管 `moviepilot-nginx`
`moviepilot-backend`,两者异常退出时自动拉起。控制 socket 位于 `/run/moviepilot/supervisor.sock`,权限为
`root:moviepilot``0770`,后端运行用户可访问;镜像不再挂载或代理 Docker Socket。
`moviepilot-backend`,两者异常退出时自动拉起;显式安装更新时还会按需启动 root worker。控制 socket 位于 `/run/moviepilot/supervisor.sock`,权限为
`root:moviepilot``0770`,后端运行用户可访问;控制面同时启用认证,密码默认在每次容器启动时随机生成,
不写入配置卷。supervisor 本身显式以 root 运行以管理 Nginx,后端仍由 `backend.sh` 降权为
`moviepilot`;镜像不再挂载或代理 Docker Socket。
## 8. Python 后端启动
@@ -400,8 +417,12 @@ Python lifespan 会先撤销 readiness,再按组件声明的 `stop_order` 停
### 9.2 应用内重启
应用请求重启时,通过本地 `supervisorctl restart all` 同时重启 Nginx 和后端。supervisor 先向旧进程组发送
SIGTERM,等待后端完成 lifespan 关停,再拉起新进程。该过程不访问 Docker API,也不依赖 Docker restart policy。
普通应用重启通过本地 `supervisorctl restart all` 同时重启 Nginx 和后端。确认安装 Release 时,
`SystemHelper` 只启动 root `moviepilot-update-worker`worker 先替换 `/app``/public` 或站点资源,
再写入 `moviepilot.pending_supervisor_restart` 并执行 `supervisorctl shutdown`。外层 entrypoint 看到标记后
重新执行 launcher,加载新代码。Dev 更新仍通过一次性 Dev 标记关闭 Supervisor,再由 entrypoint 调用
`update.sh`。这样更新包不会因只重启受管进程而停留在暂存目录;整个过程不访问 Docker API,也不依赖
Docker restart policy。
### 9.3 异常诊断
@@ -423,8 +444,9 @@ SIGTERM,等待后端完成 lifespan 关停,再拉起新进程。该过程不
| `/app.__update_previous__` | 更新前后端备份。 |
| `/public.__update_previous__` | 更新前前端备份。 |
| `/config/temp/moviepilot-update/` | 后台下载的 Release/资源包及安装状态。 |
| `/config/temp/moviepilot.pending_supervisor_restart` | Release worker 已替换程序、等待入口重新加载的标记。 |
| `/config/temp/moviepilot.pending_dev_update` | 单次 Dev 更新请求。 |
| `/run/moviepilot/supervisor.sock` | 容器内 supervisor 控制 socket,权限为 `root:moviepilot``0770`。 |
| `/run/moviepilot/supervisor.sock` | 容器内 supervisor 控制 socket,权限为 `root:moviepilot``0770`,并启用本次容器启动的认证凭据。 |
| `/config/certs/latest/` | Nginx 使用的稳定证书路径。 |
## 11. 关键环境变量
@@ -436,7 +458,7 @@ SIGTERM,等待后端完成 lifespan 关停,再拉起新进程。该过程不
| `UMASK` | `000` | 后端进程文件权限掩码。 |
| `PORT` | `3001` | 后端监听和 readiness 端口。 |
| `NGINX_PORT` | `3000` | HTTP 前端入口。 |
| `MOVIEPILOT_AUTO_UPDATE` | `false` | 只有 `dev` 会触发启动时分支更新;稳定版使用准备清单。 |
| `MOVIEPILOT_AUTO_UPDATE` | `false` | 只有 `dev` 会触发 `update.sh` 的启动时分支更新;稳定版由后台下载和 root worker 安装。 |
| `MOVIEPILOT_SAFE_MODE` | `false` | 跳过普通模式专属的插件及后台控制面。 |
| `MOVIEPILOT_FORCE_CHOWN` | `false` | 是否执行大范围递归权限修复。 |
| `PACKAGE_CACHE_ROOT` | `/config/.cache` | 包管理缓存根目录。 |
@@ -455,7 +477,7 @@ SIGTERM,等待后端完成 lifespan 关停,再拉起新进程。该过程不
后续修改 Docker 启动流程时应保持以下边界:
1. 控制脚本必须按完整代际执行,不能在同一次启动中直接混用更新前后的 `/app/docker/*.sh`
2. Release 更新由后台下载用户确认驱动;启动脚本只消费已校验清单,不恢复启动时 GitHub Release 查询 Shell 版本比较。
2. Release 更新由后台下载用户确认和 root worker 驱动;`update.sh` 不得恢复 Release 程序/资源替换、启动时 GitHub Release 查询 Shell 版本比较。
3. 更新载荷与共享虚拟环境必须作为一个可恢复事务处理,不能留下新源码配旧依赖或旧源码配新数据库的混合状态。
4. 标准 V3 与 V3t 依赖恢复必须复用 `app.runtime.dependencies.profile`,不能使用默认组覆盖当前 ABI profile。
5. 站点资源只安装到 `app/application/site/`;历史目录仅用于更新旧载荷时读取兼容资源。
+21 -123
View File
@@ -1,5 +1,3 @@
import hashlib
import json
import os
import shlex
import subprocess
@@ -836,130 +834,14 @@ def test_updater_exposes_explicit_result(
assert result.stdout == f"{expected}\n"
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_prepared_resource_update_is_applied_without_backend_or_release_lookup(tmp_path: Path) -> None:
config_dir = tmp_path / "config"
update_root = config_dir / "temp" / "moviepilot-update"
resource_dir = update_root / "resources"
resource_dir.mkdir(parents=True)
resource_files = []
for name, content in (("user.sites.v3.bin", b"index"), ("sites.cpython-test.so", b"auth")):
path = resource_dir / name
path.write_bytes(content)
resource_files.append(
{
"name": name,
"path": str(path),
"sha256": hashlib.sha256(content).hexdigest(),
}
)
(update_root / "install.json").write_text(
json.dumps({"targets": ["resources"], "resource_files": resource_files}),
encoding="utf-8",
)
release_probe = tmp_path / "release-probe"
backend_probe = tmp_path / "backend-probe"
script = textwrap.dedent(
f"""\
CONFIG_DIR="$1"
MOVIEPILOT_AUTO_UPDATE=release
PIP_PROXY= PROXY_HOST= GITHUB_PROXY= GITHUB_TOKEN=
RELEASE_PROBE="$2"
BACKEND_PROBE="$3"
source {UPDATER!s}
INFO() {{ :; }}
WARN() {{ :; }}
ERROR() {{ :; }}
test_connectivity_github() {{ touch "${{RELEASE_PROBE}}"; return 1; }}
install_backend_and_download_resources() {{ touch "${{BACKEND_PROBE}}"; return 1; }}
apply_prepared_resources() {{ test "${{MOVIEPILOT_PREPARED_UPDATE}}" = true; return 0; }}
run_moviepilot_update
printf '%s\\n' "${{MOVIEPILOT_UPDATE_RESULT}}"
"""
)
result = subprocess.run(
[
"bash",
"-c",
script,
"prepared-resource-update-test",
str(config_dir),
str(release_probe),
str(backend_probe),
],
text=True,
capture_output=True,
check=True,
)
assert result.stdout == "noop\n"
assert not release_probe.exists()
assert not backend_probe.exists()
assert not (update_root / "install.json").exists()
def test_release_mode_no_longer_checks_or_installs_during_restart(
tmp_path: Path,
) -> None:
"""Release 模式只能消费准备清单,不得保留启动时查版本的旧实现"""
"""非 Dev 模式不再由 update.sh 查版本或替换已下载程序"""
updater = UPDATER.read_text(encoding="utf-8")
assert "install.json" not in updater
assert "MOVIEPILOT_PREPARED_UPDATE" not in updater
assert "apply_prepared" not in updater
for retired_function in (
"fetch_latest_v3_release",
"compare_versions",
@@ -1027,13 +909,29 @@ def test_entrypoint_delegates_restart_to_external_supervisor() -> None:
assert "docker_http_proxy" not in entrypoint
assert "/var/run/docker.sock" not in entrypoint
assert "docker_http_proxy" not in dockerfile
assert "exec /usr/bin/supervisord -n" in entrypoint
assert "/usr/bin/supervisord -n -c /etc/supervisor/supervisord.conf" in entrypoint
assert "run_pending_dev_update_after_supervisor_shutdown" in entrypoint
assert "apply_pending_release_update_at_startup" in entrypoint
assert "-m app.cli apply-prepared-update" in entrypoint
assert "supervisor_exit_code=$?" in entrypoint
assert "supervisor" in dockerfile
assert "[program:moviepilot-nginx]" in supervisor
assert "[program:moviepilot-backend]" in supervisor
assert "[program:moviepilot-update-worker]" in supervisor
assert "user=root" in supervisor
assert "-name '*.sh' ! -name 'launcher.sh'" in dockerfile
assert supervisor.count("autorestart=true") == 2
def test_release_update_worker_applies_before_supervisor_shutdown() -> None:
"""Release worker 必须先调用后端安装器,再关闭 supervisor 触发入口重载。"""
worker = (ROOT / "docker" / "update-worker.sh").read_text(encoding="utf-8")
assert "-m app.cli apply-prepared-update" in worker
assert "supervisorctl -c \"${SUPERVISOR_CONFIG}\" shutdown" in worker
assert worker.index("apply-prepared-update") < worker.index("shutdown")
@pytest.mark.parametrize(
("pyproject_changed", "lock_changed", "expected_route_calls", "expected_sync_calls"),
(
+6 -1
View File
@@ -3,7 +3,6 @@ import subprocess
import textwrap
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
@@ -588,7 +587,13 @@ def test_supervisor_manages_backend_and_nginx() -> None:
assert "supervisord -n" in entrypoint
assert "[program:moviepilot-nginx]" in supervisor
assert "[program:moviepilot-backend]" in supervisor
assert "[program:moviepilot-update-worker]" in supervisor
assert "command=/bin/bash /usr/local/lib/moviepilot/control/update-worker.sh" in supervisor
assert "user=root" in supervisor
assert "file=/run/moviepilot/supervisor.sock" in supervisor
assert "chmod=0770" in supervisor
assert "chown=root:moviepilot" in supervisor
assert "username=moviepilot" in supervisor
assert supervisor.count("password=%(ENV_MOVIEPILOT_SUPERVISOR_PASSWORD)s") == 2
assert "openssl rand -hex 32" in entrypoint
assert supervisor.count("autorestart=true") == 2
+146
View File
@@ -24,6 +24,30 @@ def _manager(monkeypatch, tmp_path: Path):
return manager
def _docker_manager(monkeypatch, tmp_path: Path):
"""创建指向隔离 Docker 目录的更新管理器。"""
runtime_settings = {
"TEMP_PATH": tmp_path / "config" / "temp",
"ROOT_PATH": tmp_path / "app",
"FRONTEND_PATH": tmp_path / "public",
"VENV_PATH": tmp_path / "venv",
"UV_BIN": tmp_path / "uv",
"PIP_PROXY": "",
"PROXY_HOST": "",
}
monkeypatch.setattr(
update_module,
"get_runtime_setting",
lambda key: runtime_settings[key],
)
monkeypatch.setattr(update_module, "is_docker", lambda: True)
manager = object.__new__(update_module.SystemUpdateManager)
manager._lock = threading.RLock()
manager._download_active = False
manager._active_target = None
return manager
def _response(payload, status_code=200):
return SimpleNamespace(status_code=status_code, json=lambda: payload)
@@ -342,3 +366,125 @@ def test_cancel_install_returns_prepared_update_to_ready(monkeypatch, tmp_path):
assert status.can_install is True
assert status.error == "restart failed"
assert not manager._install_file.exists()
def test_apply_prepared_application_replaces_docker_payload_and_preserves_plugins(
monkeypatch, tmp_path
):
"""Docker root worker 应替换前后端目录,同时保留运行时插件和站点资源。"""
manager = _docker_manager(monkeypatch, tmp_path)
app_dir = manager._docker_app_dir
public_dir = manager._docker_public_dir
plugin_dir = app_dir / "app" / "plugins"
resource_dir = app_dir / "app" / "application" / "site"
plugin_dir.mkdir(parents=True)
resource_dir.mkdir(parents=True)
public_dir.mkdir()
(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")
(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")
(public_dir / "index.html").write_text("old-front\n", encoding="utf-8")
manager._root.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(manager._backend_archive, "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")
archive.writestr("MoviePilot-v3.1.0/new.py", "new\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")
prepared = {
"targets": ["application"],
"version": "v3.1.0",
"frontend_version": "v3.1.0",
"backend_archive": str(manager._backend_archive),
"frontend_archive": str(manager._frontend_archive),
"backend_sha256": manager._sha256(manager._backend_archive),
"frontend_sha256": manager._sha256(manager._frontend_archive),
}
(manager._root / "prepared.json").write_text(
json.dumps(prepared), encoding="utf-8"
)
manager._install_file.write_text(json.dumps(prepared), encoding="utf-8")
sync_calls = []
monkeypatch.setattr(
manager,
"_sync_docker_dependencies",
lambda project_dir, **kwargs: sync_calls.append((project_dir, kwargs)),
)
success, message = manager.apply_prepared_update()
assert success is True
assert message == "已下载的更新已替换到 Docker 程序目录"
assert len(sync_calls) == 1
assert sync_calls[0][0].name == "App"
assert sync_calls[0][1] == {}
assert (app_dir / "new.py").read_text(encoding="utf-8") == "new\n"
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 (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()
assert not manager._docker_pending_file.exists()
assert not manager._docker_previous_app_dir.exists()
assert not manager._docker_previous_public_dir.exists()
def test_apply_prepared_resources_replaces_complete_docker_resource_package(
monkeypatch, tmp_path
):
"""Docker root worker 应原子替换完整站点资源包而不触碰主程序目录。"""
manager = _docker_manager(monkeypatch, tmp_path)
monkeypatch.setattr(
update_module.ResourceHelper,
"_get_needed_files",
classmethod(lambda cls: ["user.sites.v3.bin", "sites.cpython-test.so"]),
)
monkeypatch.setattr(update_module, "get_resource_versions", lambda: ("1", "1"))
resource_dir = manager._docker_app_dir / "app" / "application" / "site"
resource_dir.mkdir(parents=True)
(manager._docker_app_dir / "keep.py").parent.mkdir(parents=True, exist_ok=True)
(manager._docker_app_dir / "keep.py").write_text("keep\n", encoding="utf-8")
(resource_dir / "user.sites.v3.bin").write_bytes(b"old-index")
(resource_dir / "sites.cpython-old.so").write_bytes(b"old-native")
prepared_files = []
for name, content in (
("user.sites.v3.bin", b"new-index"),
("sites.cpython-test.so", b"new-native"),
):
path = manager._resource_dir / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
prepared_files.append(
{"name": name, "path": str(path), "sha256": manager._sha256(path)}
)
prepared = {
"targets": ["resources"],
"resource_package_version": "10",
"resource_files": prepared_files,
}
manager._root.mkdir(parents=True, exist_ok=True)
(manager._root / "prepared.json").write_text(
json.dumps(prepared), encoding="utf-8"
)
manager._install_file.write_text(json.dumps(prepared), encoding="utf-8")
success, _message = manager.apply_prepared_update()
assert success is True
assert (manager._docker_app_dir / "keep.py").read_text(encoding="utf-8") == "keep\n"
assert (resource_dir / "user.sites.v3.bin").read_bytes() == b"new-index"
assert (resource_dir / "sites.cpython-test.so").read_bytes() == b"new-native"
assert not (resource_dir / "sites.cpython-old.so").exists()
assert not manager._install_file.exists()
assert not (manager._root / "prepared.json").exists()
+67 -2
View File
@@ -13,9 +13,9 @@ from unittest.mock import MagicMock, call, patch
import psutil
import pytest
from app.runtime.state import SystemHelper
from app.runtime.config import ConfigModel, Settings
from app.adapters.system.host import SystemUtils
from app.runtime.config import ConfigModel, Settings
from app.runtime.state import SystemHelper
def test_get_config_path_uses_repository_config_for_source_runtime():
@@ -91,19 +91,50 @@ def test_docker_restart_delegates_to_supervisor():
patch.object(SystemHelper, "_SystemHelper__supervisor_config") as supervisor_config, \
patch.object(SystemHelper, "_SystemHelper__supervisorctl") as supervisorctl, \
patch.object(SystemHelper, "_SystemHelper__supervisor_socket") as supervisor_socket, \
patch.object(SystemHelper, "_SystemHelper__prepared_update_manifest") as prepared_manifest, \
patch.object(SystemHelper, "_SystemHelper__one_shot_dev_update_flag_file") as dev_update_flag, \
patch.object(SystemHelper, "_schedule_supervisor_restart") as restart_mock, \
patch.object(SystemHelper, "_schedule_supervisor_shutdown") as shutdown_mock, \
patch("app.runtime.state.os.kill") as kill_mock:
supervisor_config.exists.return_value = True
supervisorctl.exists.return_value = True
supervisor_socket.exists.return_value = True
prepared_manifest.is_file.return_value = False
dev_update_flag.is_file.return_value = False
ret, msg = SystemHelper.restart()
assert ret
assert msg == ""
restart_mock.assert_called_once_with()
shutdown_mock.assert_not_called()
kill_mock.assert_not_called()
def test_docker_update_restart_reenters_entrypoint_for_pending_install():
"""待安装更新先启动 root worker 替换 Docker 程序目录。"""
with patch("app.runtime.state.is_docker", return_value=True), \
patch.object(SystemHelper, "_SystemHelper__supervisor_config") as supervisor_config, \
patch.object(SystemHelper, "_SystemHelper__supervisorctl") as supervisorctl, \
patch.object(SystemHelper, "_SystemHelper__supervisor_socket") as supervisor_socket, \
patch.object(SystemHelper, "_SystemHelper__prepared_update_manifest") as prepared_manifest, \
patch.object(SystemHelper, "_SystemHelper__one_shot_dev_update_flag_file") as dev_update_flag, \
patch.object(SystemHelper, "_schedule_supervisor_restart") as restart_mock, \
patch.object(SystemHelper, "_schedule_supervisor_shutdown") as shutdown_mock, \
patch.object(SystemHelper, "_schedule_supervisor_command") as command_mock:
supervisor_config.exists.return_value = True
supervisorctl.exists.return_value = True
supervisor_socket.exists.return_value = True
prepared_manifest.is_file.return_value = True
dev_update_flag.is_file.return_value = False
ret, msg = SystemHelper.restart()
assert ret
assert msg == ""
command_mock.assert_called_once_with("start", "moviepilot-update-worker")
restart_mock.assert_not_called()
shutdown_mock.assert_not_called()
def test_supervisor_restart_command_restarts_frontend_and_backend(monkeypatch):
"""延迟任务必须通过本地 supervisor 同时重启前后端进程。"""
callback = None
@@ -126,6 +157,40 @@ def test_supervisor_restart_command_restarts_frontend_and_backend(monkeypatch):
assert popen_mock.call_args.args[0][-2:] == ["restart", "all"]
def test_supervisor_shutdown_command(monkeypatch):
"""一次性 Dev 更新使用 supervisor shutdown,交回 root 入口执行更新流程。"""
callback = None
class ImmediateTimer:
def __init__(self, _delay, timer_callback):
nonlocal callback
callback = timer_callback
self.daemon = False
def start(self):
callback()
popen_mock = MagicMock()
monkeypatch.setattr("app.runtime.state.threading.Timer", ImmediateTimer)
monkeypatch.setattr("app.runtime.state.subprocess.Popen", popen_mock)
SystemHelper._schedule_supervisor_shutdown()
assert popen_mock.call_args.args[0][-1:] == ["shutdown"]
def test_upgrade_dev_always_marks_bootstrap_update():
"""Dev 更新即使已配置 dev 模式也要留下入口消费标记。"""
with patch.object(SystemHelper, "queue_one_shot_dev_update", return_value=(True, "")) as queue_mock, \
patch.object(SystemHelper, "restart", return_value=(True, "")) as restart_mock:
ret, msg = SystemHelper.upgrade_dev()
assert ret
assert msg == "已安排 Dev 更新并重启"
queue_mock.assert_called_once_with()
restart_mock.assert_called_once_with()
def test_execute_with_subprocess_passes_env_to_subprocess():
with patch("app.adapters.system.host.subprocess.run") as run_mock:
run_mock.return_value.stdout = "ok"