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 ( "FROM ghcr.io/astral-sh/uv:0.12.5@sha256:" "e85be844203885286c60ffad8a858d48afb6c5a5c237ca0e67f12e74b8f174b1 AS uv" in dockerfile ) assert "COPY --from=uv /uv /usr/local/bin/uv" in dockerfile assert "COPY pyproject.toml uv.lock ./" in dockerfile assert "python3 -m venv --without-pip ${VENV_PATH}" in dockerfile assert "UV_PROJECT_ENVIRONMENT=${VENV_PATH} uv sync" in dockerfile for option in ("--locked", "--no-dev", "--no-install-project"): assert option in dockerfile assert "uv-pip-compat" not in dockerfile assert "requirements.in" not in dockerfile assert "${VENV_PATH}/bin/pip" not in dockerfile assert "FROM prepare_payload AS prepare_control" in dockerfile assert "-exec cp -f -t /bundle/control {} +" in dockerfile assert "bash -n /bundle/entrypoint.sh" in dockerfile assert ( "COPY --from=prepare_control /bundle/control " "/usr/local/lib/moviepilot/control" in dockerfile ) assert 'ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/entrypoint.sh" ]' in dockerfile assert "CMD /usr/bin/curl -fsS" in dockerfile assert '"http://127.0.0.1:${PORT:-3001}/health/ready"' in dockerfile assert "system/global?token=moviepilot" not in dockerfile assert ( 'for control_script in /bundle/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( ("previous_bundle_state", "expected_output"), [("trusted", "old\n"), ("incomplete", "image\n"), ("untrusted", "image\n")], ) def test_launcher_isolates_control_generation_during_pending_recovery( tmp_path: Path, previous_bundle_state: str, expected_output: str ) -> None: source = tmp_path / "source" image = tmp_path / "image" previous = tmp_path / "previous-app" config = tmp_path / "config" _write_bundle(source, "new") _write_bundle(image, "image") _write_bundle(previous / "docker", "old") if previous_bundle_state == "incomplete": (previous / "docker" / "browser.sh").unlink() (config / "temp").mkdir(parents=True) (config / "temp" / "__update_pending__").write_text("prepared\n", encoding="utf-8") script = textwrap.dedent( f"""\ source {LAUNCHER!s} SOURCE_CONTROL_DIR="$1" IMAGE_CONTROL_DIR="$2" RUNTIME_ROOT="$3" UPDATE_PENDING_FILE="$4" UPDATE_PREVIOUS_APP="$5" PREVIOUS_BUNDLE_STATE="$6" PREVIOUS_CONTROL_DIR="$5/docker" source_bundle_is_trusted() {{ [ "${{PREVIOUS_BUNDLE_STATE}}" != "untrusted" ] \ || [ "$1" != "${{PREVIOUS_CONTROL_DIR}}" ] \ || return 1 control_bundle_generation "$1" >/dev/null }} launcher_main """ ) result = subprocess.run( [ "/bin/bash", "-c", script, "pending-launcher-test", str(source), str(image), str(tmp_path / "run"), str(config / "temp" / "__update_pending__"), str(previous), previous_bundle_state, ], text=True, capture_output=True, check=False, ) assert result.returncode == 0 assert result.stdout == expected_output @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' "${{PACKAGE_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_package() {{ PACKAGE_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" def test_release_noop_preserves_prerelease_selection_without_probing_package_index( tmp_path: Path, ) -> None: package_probe = tmp_path / "package-probe" curl_log = tmp_path / "curl.log" comparison_log = tmp_path / "comparison.log" script = textwrap.dedent( f"""\ CONFIG_DIR="$1" MOVIEPILOT_AUTO_UPDATE=release PIP_PROXY= PROXY_HOST= GITHUB_PROXY= GITHUB_TOKEN= PACKAGE_PROBE="$2" CURL_LOG="$3" COMPARISON_LOG="$4" source {UPDATER!s} INFO() {{ :; }} WARN() {{ :; }} ERROR() {{ :; }} test_connectivity_package() {{ touch "${{PACKAGE_PROBE}}"; return 0; }} test_connectivity_github() {{ CURL_OPTIONS=-sL; GITHUB_LOG=test; return 0; }} compare_versions() {{ printf '%s|%s\n' "$1" "$2" > "${{COMPARISON_LOG}}"; return 1; }} grep() {{ if [[ "$*" == *"/app/version.py"* ]]; then printf '%s\n' "APP_VERSION = 'v3.0.0'" return 0 fi command grep "$@" }} sed() {{ if [[ "$*" == *"APP_VERSION"* ]]; then printf '%s\n' 'v3.0.0' return 0 fi command sed "$@" }} curl() {{ printf '%s\n' "$*" >> "${{CURL_LOG}}" printf '%s\n' '[{{"tag_name":"v2.15.6"}},{{"tag_name":"v3.1.0-beta"}},{{"tag_name":"v3.1.0-rc"}},{{"tag_name":"v3.0.0"}}]' }} run_moviepilot_update printf '%s\n' "${{MOVIEPILOT_UPDATE_RESULT}}" """ ) result = subprocess.run( [ "bash", "-c", script, "release-noop-test", str(tmp_path / "config"), str(package_probe), str(curl_log), str(comparison_log), ], text=True, capture_output=True, check=True, ) assert result.stdout == "noop\n" assert not package_probe.exists() curl_args = curl_log.read_text(encoding="utf-8") assert "/releases" in curl_args assert "/releases/latest" not in curl_args assert "--compressed" in curl_args assert "--fail" in curl_args assert "--connect-timeout 5" in curl_args assert "--max-time 15" in curl_args assert comparison_log.read_text(encoding="utf-8") == "v3.0.0|v3.1.0-rc\n" @pytest.mark.parametrize( ("pyproject_changed", "lock_changed", "expected_route_calls", "expected_sync_calls"), ( (False, False, 0, 0), (True, False, 1, 1), (False, True, 1, 1), (True, True, 1, 1), ), ) def test_package_route_is_only_configured_for_changed_dependencies( tmp_path: Path, pyproject_changed: bool, lock_changed: bool, expected_route_calls: int, expected_sync_calls: int, ) -> None: uv_bin = tmp_path / "bin" / "uv" uv_bin.parent.mkdir(parents=True) uv_log = tmp_path / "uv.log" route_log = tmp_path / "route.log" uv_bin.write_text( "#!/bin/bash\n" "printf '%s|%s\\n' \"${UV_PROJECT_ENVIRONMENT:-}\" \"$*\" >> \"${UV_TEST_LOG}\"\n", encoding="utf-8", ) uv_bin.chmod(0o755) update_tree = tmp_path / "update" / "App" update_tree.mkdir(parents=True) (update_tree / "pyproject.toml").write_text("[project]\n", encoding="utf-8") (update_tree / "uv.lock").write_text("version = 1\n", encoding="utf-8") script = textwrap.dedent( f"""\ CONFIG_DIR="$1" VENV_PATH="$2" TMP_PATH="$3" ROUTE_LOG="$4" PYPROJECT_CHANGED="$5" LOCK_CHANGED="$6" UV_BIN="$7" PIP_PROXY= PROXY_HOST= source {UPDATER!s} INFO() {{ :; }} WARN() {{ :; }} ERROR() {{ :; }} cmp() {{ case "$2" in */pyproject.toml) [ "${{PYPROJECT_CHANGED}}" = false ] ;; */uv.lock) [ "${{LOCK_CHANGED}}" = false ] ;; esac }} configure_package_route() {{ printf 'route\n' >> "${{ROUTE_LOG}}" PACKAGE_LOG=test PACKAGE_ENV=() UV_OPTIONS=() }} if dependency_manifests_changed; then sync_project_dependencies fi """ ) env = {**os.environ, "UV_TEST_LOG": str(uv_log)} result = subprocess.run( [ "bash", "-c", script, "package-route-test", str(tmp_path / "config"), str(tmp_path / "venv"), str(tmp_path / "update"), str(route_log), str(pyproject_changed).lower(), str(lock_changed).lower(), str(uv_bin), ], text=True, capture_output=True, check=True, env=env, ) assert result.stderr == "" route_calls = route_log.read_text(encoding="utf-8").splitlines() if route_log.exists() else [] sync_calls = uv_log.read_text(encoding="utf-8").splitlines() if uv_log.exists() else [] assert len(route_calls) == expected_route_calls assert len(sync_calls) == expected_sync_calls if expected_sync_calls: assert sync_calls == [ f"{tmp_path / 'venv'}|sync --project {update_tree} " f"--locked --inexact --no-dev --no-install-project " f"--python {tmp_path / 'venv' / 'bin' / 'python3'}" ] @pytest.mark.parametrize("missing_manifest", ("pyproject.toml", "uv.lock")) def test_dependency_update_requires_complete_uv_manifests( tmp_path: Path, missing_manifest: str, ) -> None: update_tree = tmp_path / "update" / "App" update_tree.mkdir(parents=True) (update_tree / "pyproject.toml").write_text("[project]\n", encoding="utf-8") (update_tree / "uv.lock").write_text("version = 1\n", encoding="utf-8") (update_tree / missing_manifest).unlink() route_marker = tmp_path / "route-called" copy_marker = tmp_path / "copy-called" script = textwrap.dedent( f"""\ CONFIG_DIR="$1" TMP_PATH="$2" ROUTE_MARKER="$3" COPY_MARKER="$4" PIP_PROXY= PROXY_HOST= source {UPDATER!s} INFO() {{ :; }} WARN() {{ :; }} ERROR() {{ :; }} download_and_unzip() {{ return 0; }} configure_package_route() {{ touch "${{ROUTE_MARKER}}"; }} cp() {{ touch "${{COPY_MARKER}}"; }} ! install_backend_and_download_resources tags/v3.0.1.zip """ ) subprocess.run( [ "bash", "-c", script, "incomplete-manifest-test", str(tmp_path / "config"), str(tmp_path / "update"), str(route_marker), str(copy_marker), ], text=True, capture_output=True, check=True, ) assert not route_marker.exists() assert not copy_marker.exists() def test_failed_dependency_sync_does_not_replace_program_files(tmp_path: Path) -> None: uv_bin = tmp_path / "bin" / "uv" uv_bin.parent.mkdir(parents=True) uv_bin.write_text( "#!/bin/bash\n" "count_file=\"${UV_COUNT_FILE}\"\n" "count=$(cat \"${count_file}\" 2>/dev/null || printf '0')\n" "count=$((count + 1))\n" "printf '%s' \"${count}\" > \"${count_file}\"\n" "printf '%s\\n' \"$*\" >> \"${UV_LOG}\"\n" "[ \"${count}\" -gt 1 ]\n", encoding="utf-8", ) uv_bin.chmod(0o755) live_app = tmp_path / "app" live_public = tmp_path / "public" (live_app / "app" / "plugins").mkdir(parents=True) (live_app / "app" / "application" / "site").mkdir(parents=True) live_public.mkdir() (live_app / "app" / "old.py").write_text("old", encoding="utf-8") (live_app / "app" / "plugins" / "__init__.py").write_text( "# legacy plugin compatibility entrypoint\n", encoding="utf-8" ) (live_app / "app" / "plugins" / "plugin.py").write_text("plugin", encoding="utf-8") (live_app / "app" / "application" / "site" / "user.sites.v3.bin").write_text( "sites", encoding="utf-8" ) (live_app / "pyproject.toml").write_text("old-project", encoding="utf-8") (live_app / "uv.lock").write_text("old-lock", encoding="utf-8") (live_public / "index.html").write_text("old-front", encoding="utf-8") update_tree = tmp_path / "update" / "App" (update_tree / "app" / "plugins").mkdir(parents=True) (update_tree / "app" / "plugins" / "__init__.py").write_text( "# legacy plugin compatibility entrypoint\n", encoding="utf-8" ) (update_tree / "pyproject.toml").write_text("[project]\n", encoding="utf-8") (update_tree / "uv.lock").write_text("version = 1\n", encoding="utf-8") (update_tree / "version.py").write_text("FRONTEND_VERSION = 'v3.0.1'\n", encoding="utf-8") (tmp_path / "update" / "dist").mkdir() (tmp_path / "update" / "dist" / "index.html").write_text("new-front", encoding="utf-8") uv_log = tmp_path / "uv.log" script = textwrap.dedent( f"""\ CONFIG_DIR="$1" VENV_PATH="$2" TMP_PATH="$3" UV_BIN="$4" PIP_PROXY= PROXY_HOST= GITHUB_PROXY= CURL_OPTIONS= source {UPDATER!s} APP_DIR="$5" PUBLIC_DIR="$6" UPDATE_PREVIOUS_APP="${{APP_DIR}}.__update_previous__" UPDATE_PREVIOUS_PUBLIC="${{PUBLIC_DIR}}.__update_previous__" INFO() {{ :; }} WARN() {{ :; }} ERROR() {{ :; }} download_and_unzip() {{ return 0; }} curl() {{ local output="" while [ "$#" -gt 0 ]; do if [ "$1" = "-o" ]; then output="$2" shift 2 else shift fi done printf 'resource\n' > "${{output}}" }} cmp() {{ return 1; }} sed() {{ if [[ "$*" == *version.py* ]]; then printf 'v3.0.1\\n'; else command sed "$@"; fi }} configure_package_route() {{ PACKAGE_LOG=test; PACKAGE_ENV=(); UV_OPTIONS=(); }} install_backend_and_download_resources tags/v3.0.1.zip """ ) result = subprocess.run( [ "bash", "-c", script, "dependency-failure-test", str(tmp_path / "config"), str(tmp_path / "venv"), str(tmp_path / "update"), str(uv_bin), str(live_app), str(live_public), ], text=True, capture_output=True, check=False, env={ **os.environ, "UV_LOG": str(uv_log), "UV_COUNT_FILE": str(tmp_path / "uv-count"), }, ) assert result.returncode != 0 assert (live_app / "app" / "old.py").exists() assert (live_public / "index.html").read_text(encoding="utf-8") == "old-front" assert not (tmp_path / "config" / "temp" / "__update_pending__").exists() assert uv_log.exists(), f"stdout={result.stdout!r} stderr={result.stderr!r}" assert uv_log.read_text(encoding="utf-8").splitlines() == [ f"sync --project {update_tree} --locked --inexact --no-dev " f"--no-install-project --python {tmp_path / 'venv' / 'bin' / 'python3'}", f"sync --project {live_app} --locked --inexact --no-dev " f"--no-install-project --python {tmp_path / 'venv' / 'bin' / 'python3'}", ] def test_pending_update_recovers_previous_payload_on_next_start(tmp_path: Path) -> None: live_app = tmp_path / "app" live_public = tmp_path / "public" previous_app = tmp_path / "previous-app" previous_public = tmp_path / "previous-public" (live_app / "app").mkdir(parents=True) live_public.mkdir() (previous_app / "app").mkdir(parents=True) previous_public.mkdir() (live_app / "app" / "new.py").write_text("new", encoding="utf-8") (live_public / "index.html").write_text("new-front", encoding="utf-8") (previous_app / "app" / "old.py").write_text("old", encoding="utf-8") (previous_app / "pyproject.toml").write_text("old-project", encoding="utf-8") (previous_app / "uv.lock").write_text("old-lock", encoding="utf-8") (previous_public / "index.html").write_text("old-front", encoding="utf-8") config_dir = tmp_path / "config" (config_dir / "temp").mkdir(parents=True) (config_dir / "temp" / "__update_pending__").write_text("dependencies\n", encoding="utf-8") uv_bin = tmp_path / "uv" uv_log = tmp_path / "uv.log" uv_bin.write_text( f"#!/bin/bash\nprintf '%s\\n' \"$*\" >> {shlex.quote(str(uv_log))}\n", encoding="utf-8", ) uv_bin.chmod(0o755) script = textwrap.dedent( f"""\ CONFIG_DIR="$1" VENV_PATH="$2" UV_BIN="$7" PIP_PROXY= PROXY_HOST= GITHUB_PROXY= CURL_OPTIONS= source {UPDATER!s} APP_DIR="$3" PUBLIC_DIR="$4" UPDATE_PREVIOUS_APP="$5" UPDATE_PREVIOUS_PUBLIC="$6" INFO() {{ :; }} WARN() {{ :; }} ERROR() {{ :; }} configure_package_route() {{ PACKAGE_ENV=(); UV_OPTIONS=(); }} recover_pending_update printf '%s|%s|%s|%s\\n' \\ "$([[ -f "${{APP_DIR}}/app/old.py" ]] && printf old || printf missing)" \\ "$([[ -f "${{PUBLIC_DIR}}/index.html" ]] && head -n1 "${{PUBLIC_DIR}}/index.html" || printf missing)" \\ "$([[ -f "${{CONFIG_DIR}}/temp/__update_pending__" ]] && printf present || printf cleared)" \\ "${{UPDATE_RECOVERY_COMPLETED}}" """ ) result = subprocess.run( [ "bash", "-c", script, "pending-recovery-test", str(config_dir), str(tmp_path / "venv"), str(live_app), str(live_public), str(previous_app), str(previous_public), str(uv_bin), ], text=True, capture_output=True, check=True, ) assert result.stdout == "old|old-front|cleared|true\n" assert uv_log.read_text(encoding="utf-8").startswith( f"sync --project {live_app} --locked --inexact --no-dev" ) def test_update_transaction_keeps_marker_when_backup_cleanup_fails(tmp_path: Path) -> None: config_dir = tmp_path / "config" pending_file = config_dir / "temp" / "__update_pending__" log_file = tmp_path / "cleanup.log" script = textwrap.dedent( f"""\ CONFIG_DIR="$1" UPDATE_PENDING_FILE="$2" UPDATE_PREVIOUS_APP="$3" UPDATE_PREVIOUS_PUBLIC="$4" source {UPDATER!s} cleanup_previous_payload() {{ if [[ -f "${{UPDATE_PENDING_FILE}}" ]]; then printf 'marker-present\n' > {shlex.quote(str(log_file))} fi return 1 }} set_update_pending committed finalize_update_transaction || true printf '%s\n' "$([[ -f "${{UPDATE_PENDING_FILE}}" ]] && printf present || printf missing)" """ ) result = subprocess.run( [ "/bin/bash", "-c", script, "transaction-finalize-test", str(config_dir), str(tmp_path / "previous-app"), str(tmp_path / "previous-public"), ], text=True, capture_output=True, check=True, ) assert result.stdout == "present\n" assert log_file.read_text(encoding="utf-8") == "marker-present\n" def test_restore_does_not_nest_previous_app_when_current_removal_fails(tmp_path: Path) -> None: live_app = tmp_path / "app" live_public = tmp_path / "public" previous_app = tmp_path / "previous-app" previous_public = tmp_path / "previous-public" (live_app / "app").mkdir(parents=True) live_public.mkdir() (previous_app / "app").mkdir(parents=True) previous_public.mkdir() (live_app / "app" / "new.py").write_text("new", encoding="utf-8") (live_public / "index.html").write_text("new-front", encoding="utf-8") (previous_app / "app" / "old.py").write_text("old", encoding="utf-8") (previous_public / "index.html").write_text("old-front", encoding="utf-8") script = textwrap.dedent( f"""\ source {UPDATER!s} APP_DIR="$1" PUBLIC_DIR="$2" UPDATE_PREVIOUS_APP="$3" UPDATE_PREVIOUS_PUBLIC="$4" CONFIG_DIR="$5" INFO() {{ :; }} WARN() {{ :; }} ERROR() {{ :; }} rm() {{ if [[ "$*" == *"${{APP_DIR}}"* ]]; then return 1; fi command rm "$@" }} restore_previous_payload || true printf '%s|%s|%s|%s\n' \\ "$([[ -f "${{APP_DIR}}/app/new.py" ]] && printf current || printf missing)" \\ "$([[ -f "${{APP_DIR}}/app/old.py" ]] && printf restored || printf absent)" \\ "$([[ -d "${{UPDATE_PREVIOUS_APP}}" ]] && printf retained || printf moved)" \\ "$([[ -f "${{APP_DIR}}/${{UPDATE_PREVIOUS_APP##*/}}/app/old.py" ]] && printf nested || printf clean)" """ ) result = subprocess.run( [ "/bin/bash", "-c", script, "rollback-removal-failure-test", str(live_app), str(live_public), str(previous_app), str(previous_public), str(tmp_path / "config"), ], text=True, capture_output=True, check=True, ) assert result.stdout == "current|absent|retained|clean\n" def test_staged_resource_download_rejects_empty_response_and_keeps_fallback( tmp_path: Path, ) -> None: destination = tmp_path / "stage" / "sites.bin" fallback = tmp_path / "live" / "sites.bin" destination.parent.mkdir() fallback.parent.mkdir() fallback.write_text("old-resource\n", encoding="utf-8") script = textwrap.dedent( f"""\ source {UPDATER!s} INFO() {{ :; }} WARN() {{ :; }} ERROR() {{ :; }} curl() {{ return 0; }} download_staged_resource https://resources.example/sites.bin \\ "$1" "$2" sites.bin cat "$1" """ ) result = subprocess.run( [ "/bin/bash", "-c", script, "resource-download-test", str(destination), str(fallback), ], text=True, capture_output=True, check=True, ) assert result.stdout == "old-resource\n" def test_staged_payload_swap_failure_restores_previous_generation(tmp_path: Path) -> None: live_app = tmp_path / "app" live_public = tmp_path / "public" stage_app = tmp_path / "stage" / "App" stage_public = tmp_path / "stage" / "dist" (live_app / "app").mkdir(parents=True) live_public.mkdir() (stage_app / "app").mkdir(parents=True) stage_public.mkdir(parents=True) (live_app / "app" / "old.py").write_text("old", encoding="utf-8") (live_public / "index.html").write_text("old-front", encoding="utf-8") (stage_app / "app" / "new.py").write_text("new", encoding="utf-8") (stage_public / "index.html").write_text("new-front", encoding="utf-8") config_dir = tmp_path / "config" uv_log = tmp_path / "uv.log" script = textwrap.dedent( f"""\ CONFIG_DIR="$1" TMP_PATH="$2" PIP_PROXY= PROXY_HOST= GITHUB_PROXY= CURL_OPTIONS= source {UPDATER!s} APP_DIR="$3" PUBLIC_DIR="$4" UPDATE_PREVIOUS_APP="${{APP_DIR}}.__update_previous__" UPDATE_PREVIOUS_PUBLIC="${{PUBLIC_DIR}}.__update_previous__" INFO() {{ :; }} WARN() {{ :; }} ERROR() {{ :; }} mv() {{ if [[ "$*" == *"${{TMP_PATH}}/dist"* ]]; then return 1; fi command mv "$@" }} set_update_pending prepared swap_staged_payload || rollback_update_transaction printf '%s|%s|%s\\n' \\ "$([[ -f "${{APP_DIR}}/app/old.py" ]] && printf old || printf missing)" \\ "$([[ -f "${{PUBLIC_DIR}}/index.html" ]] && head -n1 "${{PUBLIC_DIR}}/index.html" || printf missing)" \\ "$([[ -f "${{UPDATE_PENDING_FILE}}" ]] && printf present || printf cleared)" """ ) result = subprocess.run( [ "bash", "-c", script, "payload-swap-test", str(config_dir), str(tmp_path / "stage"), str(live_app), str(live_public), ], text=True, capture_output=True, check=True, ) assert result.stdout == "old|old-front|cleared\n" def test_package_index_probe_is_cacheless_and_bounded(tmp_path: Path) -> None: timeout_log = tmp_path / "timeout.log" script = textwrap.dedent( f"""\ CONFIG_DIR="$1" VENV_PATH="$2" TIMEOUT_LOG="$3" UV_BIN="$4" PIP_PROXY=https://packages.example/simple PROXY_HOST= source {UPDATER!s} timeout() {{ printf '%s\n' "$*" > "${{TIMEOUT_LOG}}"; return 124; }} test_connectivity_package 0 || true """ ) subprocess.run( [ "bash", "-c", script, "package-probe-test", str(tmp_path / "config"), str(tmp_path / "venv"), str(timeout_log), "/fake/uv", ], text=True, capture_output=True, check=True, ) command = timeout_log.read_text(encoding="utf-8") assert command.startswith("--kill-after=2s 10s env ") assert "UV_NO_CACHE=1" in command assert "UV_HTTP_TIMEOUT=5" in command assert "UV_HTTP_RETRIES=0" in command assert "/fake/uv pip install --target " in command assert ( " --no-deps --default-index https://packages.example/simple pip-hello-world" in command ) assert "uninstall" not in command probe_dir = Path(command.split("--target ", 1)[1].split(" ", 1)[0]) assert not probe_dir.exists() @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