mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
fix(docker): stabilize bootstrap self-updates (#6354)
This commit is contained in:
+8
-4
@@ -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
@@ -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服务..."
|
||||
|
||||
Executable
+194
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LAUNCHER = ROOT / "docker" / "launcher.sh"
|
||||
UPDATER = ROOT / "docker" / "update.sh"
|
||||
BASE_CONTROL_FILES = ("entrypoint.sh", "update.sh", "browser.sh", "cert.sh")
|
||||
|
||||
|
||||
def _write_bundle(path: Path, label: str, *, extra_files: tuple[str, ...] = ()) -> None:
|
||||
path.mkdir(parents=True)
|
||||
for name in BASE_CONTROL_FILES:
|
||||
body = "#!/bin/bash\n:\n"
|
||||
if name == "entrypoint.sh":
|
||||
body = f"#!/bin/bash\nprintf '%s\\n' '{label}'\n"
|
||||
(path / name).write_text(body, encoding="utf-8")
|
||||
for name in extra_files:
|
||||
(path / name).write_text("#!/bin/bash\n:\n", encoding="utf-8")
|
||||
|
||||
|
||||
def test_dockerfile_control_bundle_build_checks_fail_closed() -> None:
|
||||
dockerfile = (ROOT / "docker" / "Dockerfile").read_text(encoding="utf-8")
|
||||
|
||||
assert "-exec cp -f -t /usr/local/lib/moviepilot/control {} +" in dockerfile
|
||||
assert "bash -n /entrypoint.sh" in dockerfile
|
||||
assert 'ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/entrypoint.sh" ]' in dockerfile
|
||||
assert "CMD /usr/bin/curl -fsS" in dockerfile
|
||||
assert (
|
||||
'for control_script in /usr/local/lib/moviepilot/control/*.sh; do bash -n "${control_script}" || exit 1; done'
|
||||
in dockerfile
|
||||
)
|
||||
|
||||
|
||||
def _run_launcher(
|
||||
tmp_path: Path,
|
||||
source: Path,
|
||||
image: Path,
|
||||
*,
|
||||
trust_source: bool = True,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
case_env = {
|
||||
**os.environ,
|
||||
"MOVIEPILOT_SOURCE_CONTROL_DIR": str(source),
|
||||
"MOVIEPILOT_IMAGE_CONTROL_DIR": str(image),
|
||||
"MOVIEPILOT_RUNTIME_CONTROL_ROOT": str(tmp_path / "run"),
|
||||
}
|
||||
for name in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
|
||||
case_env.pop(name, None)
|
||||
if extra_env:
|
||||
case_env.update(extra_env)
|
||||
|
||||
command = ["/bin/bash", str(LAUNCHER)]
|
||||
if trust_source:
|
||||
command = [
|
||||
"/bin/bash",
|
||||
"-c",
|
||||
'source "$1"; source_bundle_is_trusted() { control_bundle_generation "$1" >/dev/null; }; launcher_main',
|
||||
"bootstrap-test",
|
||||
str(LAUNCHER),
|
||||
]
|
||||
|
||||
return subprocess.run(
|
||||
command,
|
||||
env=case_env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def test_launcher_prefers_complete_trusted_source_bundle(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
image = tmp_path / "image"
|
||||
_write_bundle(source, "source")
|
||||
_write_bundle(image, "image")
|
||||
|
||||
result = _run_launcher(tmp_path, source, image)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "source\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing_file", BASE_CONTROL_FILES)
|
||||
def test_launcher_falls_back_when_source_bundle_is_incomplete(
|
||||
tmp_path: Path, missing_file: str
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
image = tmp_path / "image"
|
||||
_write_bundle(source, "source")
|
||||
_write_bundle(image, "image")
|
||||
(source / missing_file).unlink()
|
||||
|
||||
result = _run_launcher(tmp_path, source, image)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "image\n"
|
||||
|
||||
|
||||
def test_launcher_falls_back_when_source_bundle_has_invalid_shell(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
image = tmp_path / "image"
|
||||
_write_bundle(source, "source")
|
||||
_write_bundle(image, "image")
|
||||
(source / "future.sh").write_text("if then\n", encoding="utf-8")
|
||||
|
||||
result = _run_launcher(tmp_path, source, image)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "image\n"
|
||||
|
||||
|
||||
def test_launcher_falls_back_when_source_permissions_are_untrusted(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
image = tmp_path / "image"
|
||||
_write_bundle(source, "source")
|
||||
_write_bundle(image, "image")
|
||||
|
||||
result = _run_launcher(tmp_path, source, image, trust_source=False)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "image\n"
|
||||
|
||||
|
||||
def test_launcher_rejects_invalid_source_and_image_bundles(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
image = tmp_path / "image"
|
||||
_write_bundle(source, "source")
|
||||
_write_bundle(image, "image")
|
||||
(source / "entrypoint.sh").write_text("if then\n", encoding="utf-8")
|
||||
(image / "entrypoint.sh").write_text("if then\n", encoding="utf-8")
|
||||
|
||||
result = _run_launcher(tmp_path, source, image)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert result.stdout == ""
|
||||
|
||||
|
||||
def test_direct_fallback_never_bypasses_invalid_image_bundle(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
image = tmp_path / "image"
|
||||
marker = tmp_path / "image-entrypoint-started"
|
||||
_write_bundle(source, "source")
|
||||
_write_bundle(image, "unused")
|
||||
(source / "future.sh").write_text("if then\n", encoding="utf-8")
|
||||
(image / "browser.sh").write_text("if then\n", encoding="utf-8")
|
||||
(image / "entrypoint.sh").write_text(
|
||||
f"#!/bin/bash\ntouch {marker!s}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_launcher(tmp_path, source, image)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_launcher_path_cannot_be_hijacked_by_writable_venv(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
image = tmp_path / "image"
|
||||
fake_bin = tmp_path / "venv-bin"
|
||||
marker = tmp_path / "hijacked"
|
||||
_write_bundle(source, "source")
|
||||
_write_bundle(image, "image")
|
||||
fake_bin.mkdir()
|
||||
fake_stat = fake_bin / "stat"
|
||||
fake_stat.write_text(
|
||||
f"#!/bin/bash\ntouch {marker!s}\nprintf '0\\n'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
fake_stat.chmod(0o755)
|
||||
|
||||
result = _run_launcher(
|
||||
tmp_path,
|
||||
source,
|
||||
image,
|
||||
trust_source=False,
|
||||
extra_env={"PATH": f"{fake_bin}:{os.environ['PATH']}"},
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "image\n"
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_launcher_preserves_inherited_path_and_system_tools_for_entrypoint(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
image = tmp_path / "image"
|
||||
inherited_path = "/custom/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
_write_bundle(source, "unused")
|
||||
_write_bundle(image, "image")
|
||||
(source / "entrypoint.sh").write_text(
|
||||
"#!/bin/bash\ndate +%s >/dev/null\nprintf '%s\\n' \"${PATH}\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_launcher(
|
||||
tmp_path,
|
||||
source,
|
||||
image,
|
||||
extra_env={"PATH": inherited_path},
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == (
|
||||
f"{inherited_path}:/usr/local/sbin:/usr/local/bin:"
|
||||
"/usr/sbin:/usr/bin:/sbin:/bin\n"
|
||||
)
|
||||
|
||||
|
||||
def test_launcher_invalid_inherited_path_does_not_block_entrypoint(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
image = tmp_path / "image"
|
||||
_write_bundle(source, "unused")
|
||||
_write_bundle(image, "image")
|
||||
(source / "entrypoint.sh").write_text(
|
||||
"#!/bin/bash\ndate +%s >/dev/null\nprintf 'started\\n'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_launcher(
|
||||
tmp_path,
|
||||
source,
|
||||
image,
|
||||
extra_env={"PATH": "/definitely-invalid"},
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "started\n"
|
||||
|
||||
|
||||
def test_required_control_script_symlink_forces_bundle_fallback(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
image = tmp_path / "image"
|
||||
_write_bundle(source, "source", extra_files=("future.sh",))
|
||||
_write_bundle(image, "image")
|
||||
(source / "update.sh").unlink()
|
||||
(source / "update.sh").symlink_to(source / "future.sh")
|
||||
|
||||
result = _run_launcher(tmp_path, source, image)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "image\n"
|
||||
|
||||
|
||||
def test_future_control_script_symlink_forces_bundle_fallback(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
image = tmp_path / "image"
|
||||
_write_bundle(source, "source")
|
||||
_write_bundle(image, "image")
|
||||
(source / "future.sh").symlink_to(source / "browser.sh")
|
||||
|
||||
result = _run_launcher(tmp_path, source, image)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "image\n"
|
||||
|
||||
|
||||
def test_materialized_bundle_must_match_selected_generation(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
_write_bundle(source, "source")
|
||||
script = textwrap.dedent(
|
||||
f"""\
|
||||
source {LAUNCHER!s}
|
||||
RUNTIME_ROOT="$2"
|
||||
materialize_control_bundle "$1" mismatched-generation
|
||||
"""
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script, "bootstrap-test", str(source), str(tmp_path / "run")],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert not (tmp_path / "run" / "mismatched-generation").exists()
|
||||
|
||||
|
||||
def test_launcher_operational_failure_runs_image_entrypoint_directly(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
image = tmp_path / "image"
|
||||
_write_bundle(source, "source")
|
||||
_write_bundle(image, "image")
|
||||
script = textwrap.dedent(
|
||||
f"""\
|
||||
source {LAUNCHER!s}
|
||||
SOURCE_CONTROL_DIR="$1"
|
||||
IMAGE_CONTROL_DIR="$2"
|
||||
RUNTIME_ROOT="$3"
|
||||
source_bundle_is_trusted() {{ control_bundle_generation "$1" >/dev/null; }}
|
||||
materialize_control_bundle() {{ return 1; }}
|
||||
launch_with_fallback
|
||||
"""
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script, "bootstrap-test", str(source), str(image), str(tmp_path / "run")],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "image\n"
|
||||
assert "直接使用镜像内置版本启动" in result.stderr
|
||||
|
||||
|
||||
def test_source_generation_probe_failure_never_starts_image_entrypoint(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
image = tmp_path / "image"
|
||||
marker = tmp_path / "image-entrypoint-started"
|
||||
_write_bundle(source, "source")
|
||||
_write_bundle(image, "unused")
|
||||
(source / "future.sh").write_text("if then\n", encoding="utf-8")
|
||||
(image / "entrypoint.sh").write_text(
|
||||
f"#!/bin/bash\ntouch {marker!s}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
env = {
|
||||
**os.environ,
|
||||
"MOVIEPILOT_SOURCE_CONTROL_DIR": str(source),
|
||||
"MOVIEPILOT_IMAGE_CONTROL_DIR": str(image),
|
||||
"MOVIEPILOT_RUNTIME_CONTROL_ROOT": str(tmp_path / "run"),
|
||||
}
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(LAUNCHER), "--source-generation"],
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_source_generation_probe_propagates_generation_failure(tmp_path: Path) -> None:
|
||||
script = textwrap.dedent(
|
||||
f"""\
|
||||
source {LAUNCHER!s}
|
||||
source_bundle_is_trusted() {{ return 0; }}
|
||||
control_bundle_generation() {{ return 1; }}
|
||||
launcher_main --source-generation
|
||||
"""
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
def test_control_generation_automatically_covers_future_shell_files(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
_write_bundle(source, "source")
|
||||
script = textwrap.dedent(
|
||||
f"""\
|
||||
source {LAUNCHER!s}
|
||||
control_bundle_generation "$1"
|
||||
"""
|
||||
)
|
||||
before = subprocess.run(
|
||||
["bash", "-c", script, "bootstrap-test", str(source)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
).stdout
|
||||
(source / "future.sh").write_text("#!/bin/bash\n:\n", encoding="utf-8")
|
||||
after = subprocess.run(
|
||||
["bash", "-c", script, "bootstrap-test", str(source)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
).stdout
|
||||
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_control_generation_does_not_depend_on_bundle_directory(tmp_path: Path) -> None:
|
||||
first = tmp_path / "first"
|
||||
second = tmp_path / "second"
|
||||
_write_bundle(first, "same")
|
||||
_write_bundle(second, "same")
|
||||
script = textwrap.dedent(
|
||||
f"""\
|
||||
source {LAUNCHER!s}
|
||||
control_bundle_generation "$1"
|
||||
"""
|
||||
)
|
||||
|
||||
generations = [
|
||||
subprocess.run(
|
||||
["bash", "-c", script, "bootstrap-test", str(bundle)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
).stdout
|
||||
for bundle in (first, second)
|
||||
]
|
||||
|
||||
assert generations[0] == generations[1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fake_sha256sum",
|
||||
("sha256sum() { return 1; }", "sha256sum() { printf '\\n'; return 0; }"),
|
||||
)
|
||||
def test_control_generation_rejects_hash_failures(
|
||||
tmp_path: Path, fake_sha256sum: str
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
_write_bundle(source, "source")
|
||||
script = textwrap.dedent(
|
||||
f"""\
|
||||
source {LAUNCHER!s}
|
||||
{fake_sha256sum}
|
||||
control_bundle_generation "$1"
|
||||
"""
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script, "generation-hash-test", str(source)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert result.stdout == ""
|
||||
|
||||
|
||||
def test_launcher_does_not_promote_proxy_host_to_process_proxy_env(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
image = tmp_path / "image"
|
||||
_write_bundle(source, "unused")
|
||||
_write_bundle(image, "image")
|
||||
(source / "entrypoint.sh").write_text(
|
||||
"#!/bin/bash\nprintf '%s|%s|%s|%s\\n' \"${HTTP_PROXY-unset}\" \"${HTTPS_PROXY-unset}\" \"${http_proxy-unset}\" \"${https_proxy-unset}\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_launcher(
|
||||
tmp_path,
|
||||
source,
|
||||
image,
|
||||
extra_env={"PROXY_HOST": "http://package-proxy.example:7890"},
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "unset|unset|unset|unset\n"
|
||||
|
||||
|
||||
def test_launcher_preserves_explicit_standard_proxy_env(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
image = tmp_path / "image"
|
||||
_write_bundle(source, "unused")
|
||||
_write_bundle(image, "image")
|
||||
(source / "entrypoint.sh").write_text(
|
||||
"#!/bin/bash\nprintf '%s|%s\\n' \"${HTTP_PROXY-unset}\" \"${HTTPS_PROXY-unset}\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_launcher(
|
||||
tmp_path,
|
||||
source,
|
||||
image,
|
||||
extra_env={
|
||||
"HTTP_PROXY": "http://explicit-http.example:8080",
|
||||
"HTTPS_PROXY": "http://explicit-https.example:8443",
|
||||
},
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "http://explicit-http.example:8080|http://explicit-https.example:8443\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("launcher_args", "expected"),
|
||||
(((), "unset|unset|0\n"), (("--post-update-reexec",), "1|1|0\n")),
|
||||
)
|
||||
def test_launcher_owns_and_consumes_reexec_guard_state(
|
||||
tmp_path: Path, launcher_args: tuple[str, ...], expected: str
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
image = tmp_path / "image"
|
||||
_write_bundle(source, "unused")
|
||||
_write_bundle(image, "image")
|
||||
(source / "entrypoint.sh").write_text(
|
||||
"#!/bin/bash\nprintf '%s|%s|%s\\n' \"${MOVIEPILOT_BOOTSTRAP_UPDATE_DONE-unset}\" \"${MOVIEPILOT_BOOTSTRAP_REEXECUTED-unset}\" \"$#\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
script = textwrap.dedent(
|
||||
f"""\
|
||||
source {LAUNCHER!s}
|
||||
source_bundle_is_trusted() {{ control_bundle_generation "$1" >/dev/null; }}
|
||||
launch_with_fallback "$@"
|
||||
"""
|
||||
)
|
||||
env = {
|
||||
**os.environ,
|
||||
"MOVIEPILOT_SOURCE_CONTROL_DIR": str(source),
|
||||
"MOVIEPILOT_IMAGE_CONTROL_DIR": str(image),
|
||||
"MOVIEPILOT_RUNTIME_CONTROL_ROOT": str(tmp_path / "run"),
|
||||
"MOVIEPILOT_BOOTSTRAP_UPDATE_DONE": "1",
|
||||
"MOVIEPILOT_BOOTSTRAP_REEXECUTED": "1",
|
||||
}
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script, "bootstrap-guard-test", *launcher_args],
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == expected
|
||||
|
||||
|
||||
def test_updater_package_proxy_stays_command_scoped(tmp_path: Path) -> None:
|
||||
script = textwrap.dedent(
|
||||
f"""\
|
||||
CONFIG_DIR="$1"
|
||||
PROXY_HOST=http://package-proxy.example:7890
|
||||
source {UPDATER!s}
|
||||
set_package_proxy_env
|
||||
printf '%s|%s|%s|%s\\n' "${{HTTP_PROXY-unset}}" "${{HTTPS_PROXY-unset}}" "${{http_proxy-unset}}" "${{https_proxy-unset}}"
|
||||
printf '%s\\n' "${{PIP_ENV[*]}}"
|
||||
"""
|
||||
)
|
||||
env = dict(os.environ)
|
||||
for name in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
|
||||
env.pop(name, None)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script, "updater-proxy-test", str(tmp_path / "config")],
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
assert result.stdout.splitlines() == [
|
||||
"unset|unset|unset|unset",
|
||||
"HTTP_PROXY=http://package-proxy.example:7890 HTTPS_PROXY=http://package-proxy.example:7890 http_proxy=http://package-proxy.example:7890 https_proxy=http://package-proxy.example:7890",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "install_result", "expected"),
|
||||
(("false", "unused", "noop"), ("dev", "success", "updated"), ("dev", "failure", "failed")),
|
||||
)
|
||||
def test_updater_exposes_explicit_result(
|
||||
tmp_path: Path, mode: str, install_result: str, expected: str
|
||||
) -> None:
|
||||
script = textwrap.dedent(
|
||||
f"""\
|
||||
CONFIG_DIR="$1"
|
||||
MOVIEPILOT_AUTO_UPDATE="$2"
|
||||
INSTALL_RESULT="$3"
|
||||
PIP_PROXY= PROXY_HOST= GITHUB_PROXY= GITHUB_TOKEN=
|
||||
source {UPDATER!s}
|
||||
INFO() {{ :; }}
|
||||
WARN() {{ :; }}
|
||||
ERROR() {{ :; }}
|
||||
test_connectivity_pip() {{ PIP_LOG=test; return 0; }}
|
||||
test_connectivity_github() {{ GITHUB_LOG=test; return 0; }}
|
||||
install_backend_and_download_resources() {{
|
||||
if [ "${{INSTALL_RESULT}}" = success ]; then
|
||||
MOVIEPILOT_UPDATE_RESULT=updated
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}}
|
||||
run_moviepilot_update
|
||||
printf '%s\\n' "${{MOVIEPILOT_UPDATE_RESULT}}"
|
||||
"""
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script, "updater-test", str(tmp_path / "config"), mode, install_result],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
assert result.stdout == f"{expected}\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("result", "current", "next_generation", "guard", "expected"),
|
||||
(
|
||||
("noop", "old", "new", "0", 1),
|
||||
("failed", "old", "new", "0", 1),
|
||||
("updated", "same", "same", "0", 1),
|
||||
("updated", "old", "new", "0", 0),
|
||||
("updated", "old", "new", "1", 2),
|
||||
),
|
||||
)
|
||||
def test_control_bundle_reexec_decision(
|
||||
result: str,
|
||||
current: str,
|
||||
next_generation: str,
|
||||
guard: str,
|
||||
expected: int,
|
||||
) -> None:
|
||||
entrypoint = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8")
|
||||
functions = entrypoint.split("# 使用env配置", 1)[0]
|
||||
script = f'{functions}\ncontrol_bundle_reexec_decision "$1" "$2" "$3" "$4"\n'
|
||||
|
||||
completed = subprocess.run(
|
||||
["bash", "-c", script, "reexec-test", result, current, next_generation, guard],
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == expected
|
||||
|
||||
|
||||
def test_failed_generation_probe_keeps_current_control_snapshot(tmp_path: Path) -> None:
|
||||
marker = tmp_path / "reexec-decision-called"
|
||||
entrypoint = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8")
|
||||
functions = entrypoint.split("# 使用env配置", 1)[0]
|
||||
script = textwrap.dedent(
|
||||
f"""\
|
||||
{functions}
|
||||
source_control_generation() {{ return 1; }}
|
||||
control_bundle_reexec_decision() {{ touch {shlex.quote(str(marker))}; return 0; }}
|
||||
WARN() {{ printf '%s\\n' "$1"; }}
|
||||
MOVIEPILOT_UPDATE_RESULT=updated
|
||||
maybe_reexec_control_bundle
|
||||
"""
|
||||
)
|
||||
|
||||
completed = subprocess.run(
|
||||
["bash", "-c", script, "generation-probe-test", str(marker)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
assert not marker.exists()
|
||||
assert "继续使用当前控制脚本快照启动" in completed.stdout
|
||||
@@ -306,8 +306,8 @@ def test_browser_install_is_centralized_in_startup() -> None:
|
||||
assert "-m cloakbrowser install" not in entrypoint
|
||||
assert browser.count("-m cloakbrowser install") == 2
|
||||
assert "-m cloakbrowser install" not in updater
|
||||
assert startup.index("source /usr/local/bin/mp_update.sh") < startup.index(
|
||||
'source "/app/docker/browser.sh"'
|
||||
assert startup.index('source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/update.sh"') < startup.index(
|
||||
'source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/browser.sh"'
|
||||
) < startup.index("resolve_browser_cache_dir") < startup.index("ensure_browser_kernel")
|
||||
|
||||
|
||||
@@ -332,6 +332,25 @@ def test_image_paths_force_chown_uses_recursive_repair(tmp_path: Path) -> None:
|
||||
assert "/public" in log
|
||||
|
||||
|
||||
def test_force_chown_keeps_source_control_directory_root_owned(tmp_path: Path) -> None:
|
||||
app_dir = tmp_path / "app"
|
||||
|
||||
log = _run_permission_case(
|
||||
tmp_path,
|
||||
"""
|
||||
mkdir -p "${APP_DIR}/docker"
|
||||
printf '#!/bin/bash\\n' > "${APP_DIR}/docker/launcher.sh"
|
||||
MOVIEPILOT_FORCE_CHOWN=true force_chown_image_paths_if_requested "${APP_DIR}" "${PUBLIC_DIR}"
|
||||
""",
|
||||
)
|
||||
|
||||
lines = log.splitlines()
|
||||
assert f"root:root {app_dir} {app_dir}/docker" in lines
|
||||
assert not any(line.startswith("-R ") and f"{app_dir}/docker" in line for line in lines)
|
||||
assert f"-R moviepilot:moviepilot {app_dir}/app" in lines
|
||||
assert f"-R moviepilot:moviepilot {tmp_path}/public" in lines
|
||||
|
||||
|
||||
def test_image_paths_force_chown_accepts_numeric_and_yes_values(tmp_path: Path) -> None:
|
||||
for force_value in ("1", "YES"):
|
||||
case_path = tmp_path / force_value.lower()
|
||||
|
||||
@@ -101,14 +101,12 @@ def test_install_and_docker_paths_do_not_reference_v2_resources():
|
||||
assert "app/application/site" in content
|
||||
|
||||
|
||||
def test_docker_entrypoint_refreshes_stale_update_script_before_use():
|
||||
"""新镜像应在自动更新前同步源码内置脚本,避免目录约定再次陈旧。"""
|
||||
def test_docker_entrypoint_does_not_sync_updater_as_a_special_case():
|
||||
"""容器控制脚本必须由 launcher 统一固化,不能单独替换 updater。"""
|
||||
content = (ROOT_DIR / "docker" / "entrypoint.sh").read_text(encoding="utf-8")
|
||||
refresh = "cp -f /app/docker/update.sh /usr/local/bin/mp_update.sh"
|
||||
source = "source /usr/local/bin/mp_update.sh"
|
||||
|
||||
assert refresh in content
|
||||
assert content.index(refresh) < content.index(source)
|
||||
assert "mp_update.sh" not in content
|
||||
assert 'source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/update.sh"' in content
|
||||
|
||||
|
||||
def test_v3_release_workflows_use_main_wiki_and_isolated_images():
|
||||
|
||||
Reference in New Issue
Block a user