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
}
+657
View File
@@ -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
+21 -2
View File
@@ -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()
+4 -6
View File
@@ -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():