mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 01:16:50 +08:00
fix(docker): apply release updates before supervisor restart
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 前后端服务状态"""
|
||||
|
||||
+33
-13
@@ -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,17 +259,22 @@ class SystemHelper(ConfigReloadMixin):
|
||||
and SystemHelper.__supervisor_socket.exists()
|
||||
):
|
||||
return False, "容器内 supervisor 未安装"
|
||||
logger.info("请求容器内 supervisor 重启后端服务")
|
||||
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
|
||||
|
||||
+102
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+9
-219
@@ -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,85 +595,8 @@ 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
|
||||
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"
|
||||
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
|
||||
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
|
||||
@@ -815,7 +605,7 @@ elif [ "${MOVIEPILOT_AUTO_UPDATE}" = "dev" ]; then
|
||||
fi
|
||||
retries=0
|
||||
while true; do
|
||||
if test_connectivity_github ${retries}; then
|
||||
if test_connectivity_github "${retries}"; then
|
||||
break
|
||||
fi
|
||||
retries=$((retries + 1))
|
||||
@@ -831,7 +621,7 @@ elif [ "${MOVIEPILOT_AUTO_UPDATE}" = "dev" ]; then
|
||||
MOVIEPILOT_UPDATE_RESULT="failed"
|
||||
fi
|
||||
rm -rf "${TMP_PATH}"
|
||||
else
|
||||
INFO "没有待安装更新,按当前版本启动"
|
||||
fi
|
||||
else
|
||||
INFO "没有待安装 Dev 更新,按当前版本启动"
|
||||
fi
|
||||
}
|
||||
|
||||
+43
-21
@@ -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
@@ -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"),
|
||||
(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user