From fc69039d0002f7f246d6994f7beb4e35c2a0d3b2 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 19 Aug 2026 05:54:41 +0800 Subject: [PATCH] fix(docker): stabilize bootstrap self-updates (#6354) --- docker/Dockerfile | 12 +- docker/entrypoint.sh | 67 +- docker/launcher.sh | 194 ++++++ docker/update.sh | 58 +- tests/test_docker_bootstrap.py | 657 ++++++++++++++++++++ tests/test_docker_entrypoint_permissions.py | 23 +- tests/test_resource_v3.py | 10 +- 7 files changed, 982 insertions(+), 39 deletions(-) create mode 100755 docker/launcher.sh create mode 100644 tests/test_docker_bootstrap.py diff --git a/docker/Dockerfile b/docker/Dockerfile index 69e091f5e..0c40776e6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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" ] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index ed7ea86b5..9dc929a24 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -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服务..." diff --git a/docker/launcher.sh b/docker/launcher.sh new file mode 100755 index 000000000..7078bcff6 --- /dev/null +++ b/docker/launcher.sh @@ -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 diff --git a/docker/update.sh b/docker/update.sh index 5b2d36d8d..0d2b09212 100644 --- a/docker/update.sh +++ b/docker/update.sh @@ -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 +} diff --git a/tests/test_docker_bootstrap.py b/tests/test_docker_bootstrap.py new file mode 100644 index 000000000..ed0da60a0 --- /dev/null +++ b/tests/test_docker_bootstrap.py @@ -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 diff --git a/tests/test_docker_entrypoint_permissions.py b/tests/test_docker_entrypoint_permissions.py index 120480fc2..9dd415244 100644 --- a/tests/test_docker_entrypoint_permissions.py +++ b/tests/test_docker_entrypoint_permissions.py @@ -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() diff --git a/tests/test_resource_v3.py b/tests/test_resource_v3.py index 37fb42971..ff2fea07d 100644 --- a/tests/test_resource_v3.py +++ b/tests/test_resource_v3.py @@ -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():