fix(docker): stabilize bootstrap self-updates (#6354)

This commit is contained in:
InfinityPacer
2026-08-19 05:54:41 +08:00
committed by GitHub
parent da51ff1a5f
commit fc69039d00
7 changed files with 982 additions and 39 deletions
+8 -4
View File
@@ -149,11 +149,15 @@ COPY --from=prepare_code /public /public
RUN cp -f /app/docker/nginx.common.conf /etc/nginx/common.conf \
&& cp -f /app/docker/nginx.template.conf /etc/nginx/nginx.template.conf \
&& cp -f /app/docker/update.sh /usr/local/bin/mp_update.sh \
&& cp -f /app/docker/entrypoint.sh /entrypoint.sh \
&& mkdir -p /usr/local/lib/moviepilot/control \
&& find /app/docker -maxdepth 1 -type f -name '*.sh' ! -name 'launcher.sh' -exec cp -f -t /usr/local/lib/moviepilot/control {} + \
&& cp -f /app/docker/launcher.sh /entrypoint.sh \
&& cp -f /app/docker/docker_http_proxy.conf /etc/nginx/docker_http_proxy.conf \
&& printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail' 'cd /app' 'exec "${VENV_PATH:-/opt/venv}/bin/python3" -m app.cli "$@"' > /usr/local/bin/moviepilot \
&& chmod +x /entrypoint.sh /usr/local/bin/mp_update.sh /usr/local/bin/moviepilot \
&& bash -n /entrypoint.sh \
&& for control_script in /usr/local/lib/moviepilot/control/*.sh; do bash -n "${control_script}" || exit 1; done \
&& chmod 755 /entrypoint.sh /usr/local/bin/moviepilot \
&& chmod 500 /usr/local/lib/moviepilot/control/*.sh \
&& mkdir -p ${HOME} \
&& groupadd -r moviepilot -g 918 \
&& useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 918 \
@@ -166,5 +170,5 @@ RUN cp -f /app/docker/nginx.common.conf /etc/nginx/common.conf \
EXPOSE 3000
VOLUME [ "${CONFIG_DIR}" ]
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 CMD curl -fsS "http://127.0.0.1:${PORT:-3001}/api/v1/system/global?token=moviepilot" >/dev/null || exit 1
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 CMD /usr/bin/curl -fsS "http://127.0.0.1:${PORT:-3001}/api/v1/system/global?token=moviepilot" >/dev/null || exit 1
ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/entrypoint.sh" ]
+58 -9
View File
@@ -380,10 +380,57 @@ function force_chown_image_paths_if_requested() {
local path
for path in "$@"; do
[ -e "${path}" ] || continue
if [ -f "${path}/docker/launcher.sh" ]; then
# 控制脚本会在下一次启动时以 root 执行,源码目录必须保持不可由运行用户改写。
chown root:root "${path}" "${path}/docker"
chmod go-w "${path}" "${path}/docker"
while IFS= read -r -d '' app_child; do
chown -R moviepilot:moviepilot "${app_child}"
done < <(find "${path}" -mindepth 1 -maxdepth 1 ! -name docker -print0)
continue
fi
chown -R moviepilot:moviepilot "${path}"
done
}
function control_bundle_reexec_decision() {
local update_result="${1:-noop}"
local current_generation="${2:-}"
local next_generation="${3:-}"
local already_reexecuted="${4:-0}"
[ "${update_result}" = "updated" ] || return 1
[ "${next_generation}" != "${current_generation}" ] || return 1
[ "${already_reexecuted}" != "1" ] || return 2
return 0
}
function source_control_generation() {
/entrypoint.sh --source-generation 2>/dev/null
}
function maybe_reexec_control_bundle() {
[ "${MOVIEPILOT_UPDATE_RESULT:-noop}" = "updated" ] || return 0
local next_control_generation
if ! next_control_generation="$(source_control_generation)"; then
WARN "→ 更新后的容器控制脚本不可用,本次继续使用当前控制脚本快照启动。"
return 0
fi
if control_bundle_reexec_decision \
"${MOVIEPILOT_UPDATE_RESULT}" \
"${MP_CONTROL_GENERATION:-}" \
"${next_control_generation}" \
"${MOVIEPILOT_BOOTSTRAP_REEXECUTED:-0}"; then
INFO "→ 检测到容器控制脚本更新,使用新版本继续本次启动。"
exec /entrypoint.sh --post-update-reexec
elif [ "$?" -eq 2 ]; then
ERROR "→ 容器控制脚本在单次启动中重复变化,已终止以避免重启循环。"
exit 1
fi
}
function correct_home_permissions() {
local child
@@ -480,21 +527,23 @@ fi
# 使用env配置渲染 nginx 配置
render_nginx_config
# 自动更新
# 自动更新,控制脚本由 launcher 固化到同一代运行目录,源码替换不会改变本轮执行内容。
cd /
if [ -f /app/docker/update.sh ] && ! cmp -s /app/docker/update.sh /usr/local/bin/mp_update.sh; then
# 后端源码可独立于镜像更新,启动前同步更新器,避免它长期保留过期目录约定。
cp -f /app/docker/update.sh /usr/local/bin/mp_update.sh
chmod +x /usr/local/bin/mp_update.sh
INFO "→ 已同步后端内置更新脚本"
if [ "${MOVIEPILOT_BOOTSTRAP_UPDATE_DONE:-0}" != "1" ]; then
source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/update.sh"
run_moviepilot_update
export MOVIEPILOT_BOOTSTRAP_UPDATE_DONE=1
else
MOVIEPILOT_UPDATE_RESULT="noop"
fi
source /usr/local/bin/mp_update.sh
if [ "${ONE_SHOT_UPDATE_APPLIED}" = "true" ]; then
MOVIEPILOT_AUTO_UPDATE="${MOVIEPILOT_AUTO_UPDATE_ORIGINAL}"
fi
maybe_reexec_control_bundle
cd /app || exit
source "/app/docker/browser.sh"
source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/browser.sh"
# 更改 moviepilot userid 和 groupid
groupmod -o -g "${PGID}" moviepilot
@@ -517,7 +566,7 @@ fi
ensure_browser_kernel
# 证书管理
source /app/docker/cert.sh
source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/cert.sh"
# 启动前端nginx服务
INFO "→ 启动前端nginx服务..."
+194
View File
@@ -0,0 +1,194 @@
#!/bin/bash
# shellcheck shell=bash
set -u
# root bootstrap 只使用镜像系统工具;业务入口保留用户 PATH 优先级,同时确保镜像系统工具可用。
LAUNCHER_INHERITED_PATH="${PATH:-}"
LAUNCHER_ENTRYPOINT_PATH="${LAUNCHER_INHERITED_PATH:+${LAUNCHER_INHERITED_PATH}:}/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
PATH="/usr/sbin:/usr/bin:/sbin:/bin"
export PATH
SOURCE_CONTROL_DIR="${MOVIEPILOT_SOURCE_CONTROL_DIR:-/app/docker}"
IMAGE_CONTROL_DIR="${MOVIEPILOT_IMAGE_CONTROL_DIR:-/usr/local/lib/moviepilot/control}"
RUNTIME_ROOT="${MOVIEPILOT_RUNTIME_CONTROL_ROOT:-/run/moviepilot/control}"
CONTROL_FILES=()
CONTROL_REQUIRED_FILES=(entrypoint.sh update.sh browser.sh cert.sh)
function collect_control_files() {
local control_dir="$1"
local path
local file
CONTROL_FILES=()
for path in "${control_dir}"/*.sh; do
[ -e "${path}" ] || [ -L "${path}" ] || continue
file="$(basename "${path}")"
[ "${file}" = "launcher.sh" ] && continue
[ -f "${path}" ] || return 1
[ ! -L "${path}" ] || return 1
done
while IFS= read -r path; do
CONTROL_FILES+=("$(basename "${path}")")
done < <(find "${control_dir}" -maxdepth 1 -type f -name '*.sh' ! -name 'launcher.sh' -print | LC_ALL=C sort)
for path in "${CONTROL_REQUIRED_FILES[@]}"; do
[ -f "${control_dir}/${path}" ] || return 1
[ ! -L "${control_dir}/${path}" ] || return 1
[[ " ${CONTROL_FILES[*]} " = *" ${path} "* ]] || return 1
done
[ "${#CONTROL_FILES[@]}" -ge "${#CONTROL_REQUIRED_FILES[@]}" ]
}
function control_bundle_generation() {
local control_dir="$1"
local file
local checksum_line
local digest
local manifest=""
collect_control_files "${control_dir}" || return 1
for file in "${CONTROL_FILES[@]}"; do
[ -f "${control_dir}/${file}" ] || return 1
[ ! -L "${control_dir}/${file}" ] || return 1
bash -n "${control_dir}/${file}" || return 1
done
for file in "${CONTROL_FILES[@]}"; do
checksum_line="$(sha256sum "${control_dir}/${file}")" || return 1
digest="${checksum_line%% *}"
[[ "${digest}" =~ ^[0-9a-f]{64}$ ]] || return 1
manifest+="${file} ${digest}"$'\n'
done
checksum_line="$(printf '%s' "${manifest}" | sha256sum)" || return 1
digest="${checksum_line%% *}"
[[ "${digest}" =~ ^[0-9a-f]{64}$ ]] || return 1
printf '%s\n' "${digest}"
}
function source_bundle_is_trusted() {
local control_dir="$1"
local path
local mode
local ancestor
[ -d "${control_dir}" ] || return 1
ancestor="${control_dir}"
while [ "${ancestor}" != "/" ]; do
[ ! -L "${ancestor}" ] || return 1
[ "$(stat -c '%u' "${ancestor}" 2>/dev/null || stat -f '%u' "${ancestor}")" = "0" ] || return 1
mode="$(stat -c '%a' "${ancestor}" 2>/dev/null || stat -f '%Lp' "${ancestor}")"
(( (8#${mode} & 8#022) == 0 )) || return 1
ancestor="$(dirname "${ancestor}")"
done
collect_control_files "${control_dir}" || return 1
for path in "${CONTROL_FILES[@]/#/${control_dir}/}"; do
[ -f "${path}" ] || return 1
[ ! -L "${path}" ] || return 1
[ "$(stat -c '%u' "${path}" 2>/dev/null || stat -f '%u' "${path}")" = "0" ] || return 1
mode="$(stat -c '%a' "${path}" 2>/dev/null || stat -f '%Lp' "${path}")"
(( (8#${mode} & 8#022) == 0 )) || return 1
done
control_bundle_generation "${control_dir}" >/dev/null
}
function select_control_dir() {
if source_bundle_is_trusted "${SOURCE_CONTROL_DIR}"; then
printf '%s\n' "${SOURCE_CONTROL_DIR}"
return 0
fi
printf '%s\n' "MoviePilot 源码控制脚本不完整或不可信,回退到镜像内置版本。" >&2
control_bundle_generation "${IMAGE_CONTROL_DIR}" >/dev/null || {
printf '%s\n' "MoviePilot 镜像内置控制脚本无效,无法启动。" >&2
return 1
}
printf '%s\n' "${IMAGE_CONTROL_DIR}"
}
function materialize_control_bundle() {
local source_dir="$1"
local generation="$2"
local runtime_dir="${RUNTIME_ROOT}/${generation}"
local staging_dir="${runtime_dir}.tmp.$$"
local file
local staged_generation
collect_control_files "${source_dir}" || return 1
mkdir -p "${RUNTIME_ROOT}" || return 1
rm -rf "${staging_dir}" || return 1
mkdir -m 0700 "${staging_dir}" || return 1
for file in "${CONTROL_FILES[@]}"; do
if ! cp "${source_dir}/${file}" "${staging_dir}/${file}" \
|| ! chmod 0500 "${staging_dir}/${file}"; then
rm -rf "${staging_dir}"
return 1
fi
done
if ! staged_generation="$(control_bundle_generation "${staging_dir}")" \
|| [ "${staged_generation}" != "${generation}" ]; then
rm -rf "${staging_dir}"
return 1
fi
if ! rm -rf "${runtime_dir}" || ! mv "${staging_dir}" "${runtime_dir}"; then
rm -rf "${staging_dir}"
return 1
fi
printf '%s\n' "${runtime_dir}"
}
function launcher_main() {
if [ "${1:-}" = "--source-generation" ]; then
source_bundle_is_trusted "${SOURCE_CONTROL_DIR}" || return 1
control_bundle_generation "${SOURCE_CONTROL_DIR}"
return $?
fi
local selected_dir
local generation
local runtime_dir
selected_dir="$(select_control_dir)" || return 1
generation="$(control_bundle_generation "${selected_dir}")" || return 1
if ! runtime_dir="$(materialize_control_bundle "${selected_dir}" "${generation}")"; then
[ "${selected_dir}" != "${IMAGE_CONTROL_DIR}" ] || return 1
printf '%s\n' "MoviePilot 源码控制脚本快照失败,回退到镜像内置版本。" >&2
generation="$(control_bundle_generation "${IMAGE_CONTROL_DIR}")" || return 1
runtime_dir="$(materialize_control_bundle "${IMAGE_CONTROL_DIR}" "${generation}")" || return 1
fi
export MP_CONTROL_DIR="${runtime_dir}"
export MP_CONTROL_GENERATION="${generation}"
PATH="${LAUNCHER_ENTRYPOINT_PATH}" exec /bin/bash "${runtime_dir}/entrypoint.sh" "$@"
}
function launch_image_control_fallback() {
local image_entrypoint="${IMAGE_CONTROL_DIR}/entrypoint.sh"
# 运行时快照不可用时仍允许镜像自带版本启动,避免临时目录故障阻断容器恢复。
control_bundle_generation "${IMAGE_CONTROL_DIR}" >/dev/null || return 1
printf '%s\n' "MoviePilot 控制脚本快照不可用,直接使用镜像内置版本启动。" >&2
unset MP_CONTROL_DIR MP_CONTROL_GENERATION
PATH="${LAUNCHER_ENTRYPOINT_PATH}" exec /bin/bash "${image_entrypoint}" "$@"
}
function launch_with_fallback() {
if [ "${1:-}" = "--post-update-reexec" ]; then
export MOVIEPILOT_BOOTSTRAP_UPDATE_DONE=1
export MOVIEPILOT_BOOTSTRAP_REEXECUTED=1
shift
else
unset MOVIEPILOT_BOOTSTRAP_UPDATE_DONE MOVIEPILOT_BOOTSTRAP_REEXECUTED
fi
launcher_main "$@" || launch_image_control_fallback "$@"
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
if [ "${1:-}" = "--source-generation" ]; then
launcher_main "$@"
else
launch_with_fallback "$@"
fi
fi
+40 -18
View File
@@ -37,6 +37,7 @@ function apply_package_cache_env() {
apply_package_cache_env
PIP_ENV=()
MOVIEPILOT_UPDATE_RESULT="noop"
function set_package_proxy_env() {
PIP_ENV=()
@@ -145,14 +146,19 @@ function install_backend_and_download_resources() {
INFO "前端程序下载成功"
# 备份插件目录
INFO "→ 正在备份插件目录..."
rm -rf /plugins
mkdir -p /plugins
cp -a /app/app/plugins/* /plugins/
if ! rm -rf /plugins \
|| ! mkdir -p /plugins \
|| ! cp -a /app/app/plugins/* /plugins/; then
ERROR "插件目录备份失败,终止更新"
return 1
fi
rm -f /plugins/__init__.py
# 备份站点资源
INFO "→ 正在备份站点资源目录..."
rm -rf /resources_bakcup
mkdir /resources_bakcup
if ! rm -rf /resources_bakcup || ! mkdir /resources_bakcup; then
ERROR "站点资源备份目录准备失败,终止更新"
return 1
fi
resource_source_dir=/app/app/application/site
for legacy_resource_dir in /app/app/infrastructure /app/app/adapters/network /app/app/helper; do
if [ ! -d "${resource_source_dir}" ] && [ -d "${legacy_resource_dir}" ]; then
@@ -167,17 +173,21 @@ function install_backend_and_download_resources() {
[ -f "${resource_file}" ] && cp -a "${resource_file}" /resources_bakcup
done
# 清空程序目录
rm -rf /app
mkdir -p /app
# 复制新后端程序
cp -a ${TMP_PATH}/App/* /app/
# 复制新前端程序
rm -rf /public
mkdir -p /public
cp -a ${TMP_PATH}/dist/* /public/
if ! rm -rf /app \
|| ! mkdir -p /app \
|| ! cp -a ${TMP_PATH}/App/* /app/ \
|| ! rm -rf /public \
|| ! mkdir -p /public \
|| ! cp -a ${TMP_PATH}/dist/* /public/; then
ERROR "程序文件替换失败,更新未完成"
return 1
fi
INFO "程序部分更新成功,前端版本:${frontend_version},后端版本:${1}"
# 恢复插件目录
cp -a /plugins/* /app/app/plugins/
if ! cp -a /plugins/* /app/app/plugins/; then
ERROR "插件目录恢复失败,更新未完成"
return 1
fi
# 更新站点资源
INFO "→ 开始更新站点资源..."
python_version=$(python3 -c 'import sys; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}")')
@@ -188,7 +198,10 @@ function install_backend_and_download_resources() {
arch_suffix="x86_64-linux-gnu"
fi
INFO "当前 Python 版本:${python_version},架构:${arch}"
mkdir -p /app/app/application/site
if ! mkdir -p /app/app/application/site; then
ERROR "站点资源目录创建失败,更新未完成"
return 1
fi
# 下载 V3 站点索引
if ! curl ${CURL_OPTIONS} "${GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v3/user.sites.v3.bin" -o /app/app/application/site/user.sites.v3.bin; then
if [ -f /resources_bakcup/user.sites.v3.bin ]; then
@@ -207,6 +220,7 @@ function install_backend_and_download_resources() {
INFO "站点资源更新成功"
# 清理临时目录
rm -rf "${TMP_PATH}"
MOVIEPILOT_UPDATE_RESULT="updated"
return 0
}
@@ -313,8 +327,11 @@ function compare_versions() {
return 1
elif (( current_ver < release_ver )); then
INFO "发现新版本,开始自动升级..."
install_backend_and_download_resources "tags/$2.zip"
return 0
if install_backend_and_download_resources "tags/$2.zip"; then
return 0
fi
MOVIEPILOT_UPDATE_RESULT="failed"
return 1
else
continue
fi
@@ -350,6 +367,8 @@ function get_priority() {
fi
}
function run_moviepilot_update() {
MOVIEPILOT_UPDATE_RESULT="noop"
if [[ "${MOVIEPILOT_AUTO_UPDATE}" = "true" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}" = "release" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}" = "dev" ]]; then
TMP_PATH=$(mktemp -d)
if [ ! -d "${TMP_PATH}" ]; then
@@ -387,7 +406,9 @@ if [[ "${MOVIEPILOT_AUTO_UPDATE}" = "true" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}"
fi
if [ "${MOVIEPILOT_AUTO_UPDATE}" = "dev" ]; then
INFO "Dev 更新模式"
install_backend_and_download_resources "heads/v3.zip"
if ! install_backend_and_download_resources "heads/v3.zip"; then
MOVIEPILOT_UPDATE_RESULT="failed"
fi
else
INFO "Release 更新模式"
old_version=$(grep -m -1 "^\s*APP_VERSION\s*=\s*" /app/version.py | tr -d '\r\n' | awk -F'#' '{print $1}' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
@@ -417,3 +438,4 @@ elif [[ "${MOVIEPILOT_AUTO_UPDATE}" = "false" ]]; then
else
INFO "MOVIEPILOT_AUTO_UPDATE 变量设置错误"
fi
}