diff --git a/app/adapters/system/update.py b/app/adapters/system/update.py index 792cc4c71..eccc13088 100644 --- a/app/adapters/system/update.py +++ b/app/adapters/system/update.py @@ -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: diff --git a/app/application/system.py b/app/application/system.py index 593b6dc43..a22489d03 100644 --- a/app/application/system.py +++ b/app/application/system.py @@ -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) diff --git a/app/cli.py b/app/cli.py index 0583d73ea..801af98f8 100644 --- a/app/cli.py +++ b/app/cli.py @@ -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 前后端服务状态""" diff --git a/app/runtime/state.py b/app/runtime/state.py index ec1b92821..1112f5588 100644 --- a/app/runtime/state.py +++ b/app/runtime/state.py @@ -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() diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 1ff8f423d..cba01ef3e 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -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 diff --git a/docker/supervisord.conf b/docker/supervisord.conf index 9854e2caa..b5c9a818b 100644 --- a/docker/supervisord.conf +++ b/docker/supervisord.conf @@ -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 diff --git a/docker/update-worker.sh b/docker/update-worker.sh new file mode 100644 index 000000000..e215c5219 --- /dev/null +++ b/docker/update-worker.sh @@ -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 diff --git a/docker/update.sh b/docker/update.sh index 8bb107f27..f066d27c0 100644 --- a/docker/update.sh +++ b/docker/update.sh @@ -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 } diff --git a/docs/docker-startup.md b/docs/docker-startup.md index 8c1281720..d34132098 100644 --- a/docs/docker-startup.md +++ b/docs/docker-startup.md @@ -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 --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/`;历史目录仅用于更新旧载荷时读取兼容资源。 diff --git a/tests/test_docker_bootstrap.py b/tests/test_docker_bootstrap.py index fc61e4b42..4137608d4 100644 --- a/tests/test_docker_bootstrap.py +++ b/tests/test_docker_bootstrap.py @@ -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"), ( diff --git a/tests/test_docker_entrypoint_permissions.py b/tests/test_docker_entrypoint_permissions.py index a5438c7d0..7921facf0 100644 --- a/tests/test_docker_entrypoint_permissions.py +++ b/tests/test_docker_entrypoint_permissions.py @@ -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 diff --git a/tests/test_system_update_manager.py b/tests/test_system_update_manager.py index e621a6154..e794882c5 100644 --- a/tests/test_system_update_manager.py +++ b/tests/test_system_update_manager.py @@ -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() diff --git a/tests/test_system_utils.py b/tests/test_system_utils.py index afa223d00..9808acace 100644 --- a/tests/test_system_utils.py +++ b/tests/test_system_utils.py @@ -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"