diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index e6f2033ea..4bf1b4bec 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -577,7 +577,15 @@ render_nginx_config cd / if [ "${MOVIEPILOT_BOOTSTRAP_UPDATE_DONE:-0}" != "1" ]; then source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/update.sh" - run_moviepilot_update + if ! recover_pending_update; then + ERROR "→ 上一次容器更新未能恢复,容器将保持运行以便执行 moviepilot doctor。" + diagnostic_keepalive 1 + fi + if [ "${UPDATE_RECOVERY_COMPLETED:-false}" = "true" ]; then + INFO "→ 已恢复到更新前版本,本次启动跳过自动更新。" + else + run_moviepilot_update + fi export MOVIEPILOT_BOOTSTRAP_UPDATE_DONE=1 else MOVIEPILOT_UPDATE_RESULT="noop" @@ -586,6 +594,11 @@ if [ "${ONE_SHOT_UPDATE_APPLIED}" = "true" ]; then MOVIEPILOT_AUTO_UPDATE="${MOVIEPILOT_AUTO_UPDATE_ORIGINAL}" fi +if [ "${UPDATE_RECOVERY_REQUIRED:-false}" = "true" ]; then + ERROR "→ 容器更新回滚未完成,容器将保持运行以便执行 moviepilot doctor。" + diagnostic_keepalive 1 +fi + maybe_reexec_control_bundle cd /app || exit diff --git a/docker/launcher.sh b/docker/launcher.sh index 7078bcff6..caed3aa42 100755 --- a/docker/launcher.sh +++ b/docker/launcher.sh @@ -12,6 +12,8 @@ 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}" +UPDATE_PENDING_FILE="${CONFIG_DIR:-/config}/temp/__update_pending__" +UPDATE_PREVIOUS_APP="/app.__update_previous__" CONTROL_FILES=() CONTROL_REQUIRED_FILES=(entrypoint.sh update.sh browser.sh cert.sh) @@ -94,7 +96,33 @@ function source_bundle_is_trusted() { control_bundle_generation "${control_dir}" >/dev/null } +function pending_update_state() { + [ -f "${UPDATE_PENDING_FILE}" ] || return 1 + tr -d '\r\n' < "${UPDATE_PENDING_FILE}" +} + +function pending_recovery_control_dir() { + local state + local previous_control_dir="${UPDATE_PREVIOUS_APP}/docker" + + state="$(pending_update_state 2>/dev/null || true)" + case "${state}" in + prepared|dependencies) + [ -e "${UPDATE_PREVIOUS_APP}" ] || return 1 + source_bundle_is_trusted "${previous_control_dir}" || return 1 + printf '%s\n' "${previous_control_dir}" + return 0 + ;; + esac + return 1 +} + function select_control_dir() { + local recovery_dir + if recovery_dir="$(pending_recovery_control_dir)"; then + printf '%s\n' "${recovery_dir}" + return 0 + fi if source_bundle_is_trusted "${SOURCE_CONTROL_DIR}"; then printf '%s\n' "${SOURCE_CONTROL_DIR}" return 0 diff --git a/docker/update.sh b/docker/update.sh index 7755de349..74e036574 100644 --- a/docker/update.sh +++ b/docker/update.sh @@ -26,6 +26,11 @@ export PATH="${VENV_PATH}/bin:$PATH" UV_BIN="${UV_BIN:-/usr/local/bin/uv}" CONFIG_DIR="${CONFIG_DIR:-/config}" +APP_DIR=/app +PUBLIC_DIR=/public +UPDATE_PENDING_FILE="${CONFIG_DIR}/temp/__update_pending__" +UPDATE_PREVIOUS_APP="${APP_DIR}.__update_previous__" +UPDATE_PREVIOUS_PUBLIC="${PUBLIC_DIR}.__update_previous__" function apply_package_cache_env() { PACKAGE_CACHE_ROOT="${PACKAGE_CACHE_ROOT:-${CONFIG_DIR}/.cache}" @@ -39,6 +44,10 @@ apply_package_cache_env PACKAGE_ENV=() UV_OPTIONS=() MOVIEPILOT_UPDATE_RESULT="noop" +UPDATE_RECOVERY_REQUIRED="false" +UPDATE_RECOVERY_COMPLETED="false" +DEPENDENCY_SYNC_ATTEMPTED="false" +PACKAGE_ROUTE_READY="false" function set_package_proxy_env() { PACKAGE_ENV=() @@ -81,20 +90,10 @@ function download_and_unzip() { function sync_project_dependencies() { INFO "检测到依赖变化,正在更新虚拟环境..." configure_package_route || return 1 + PACKAGE_ROUTE_READY="true" + DEPENDENCY_SYNC_ATTEMPTED="true" INFO "依赖源:${PACKAGE_LOG}" - local -a uv_cmd=( - "${UV_BIN}" sync - --project "${TMP_PATH}/App" - --locked - --inexact - --no-dev - --no-install-project - --python "${VENV_PATH}/bin/python3" - ) - uv_cmd+=("${UV_OPTIONS[@]}") - if ! env "${PACKAGE_ENV[@]}" \ - "UV_PROJECT_ENVIRONMENT=${VENV_PATH}" \ - "UV_LINK_MODE=copy" "${uv_cmd[@]}"; then + if ! sync_project_dependencies_for "${TMP_PATH}/App"; then ERROR "依赖同步失败,当前程序依赖未完成更新" return 1 fi @@ -102,8 +101,226 @@ function sync_project_dependencies() { } function dependency_manifests_changed() { - ! cmp -s /app/pyproject.toml "${TMP_PATH}/App/pyproject.toml" \ - || ! cmp -s /app/uv.lock "${TMP_PATH}/App/uv.lock" + ! cmp -s "${APP_DIR}/pyproject.toml" "${TMP_PATH}/App/pyproject.toml" \ + || ! cmp -s "${APP_DIR}/uv.lock" "${TMP_PATH}/App/uv.lock" +} + +function set_update_pending() { + local state="${1:-prepared}" + local pending_dir + local pending_tmp + + pending_dir="$(dirname "${UPDATE_PENDING_FILE}")" + pending_tmp="${pending_dir}/.__update_pending__.tmp.$$" + mkdir -p "${pending_dir}" \ + && printf '%s\n' "${state}" > "${pending_tmp}" \ + && mv -f "${pending_tmp}" "${UPDATE_PENDING_FILE}" || { + rm -f "${pending_tmp}" + return 1 + } +} + +function clear_update_pending() { + rm -f "${UPDATE_PENDING_FILE}" +} + +function update_pending_state() { + [ -f "${UPDATE_PENDING_FILE}" ] || return 1 + tr -d '\r\n' < "${UPDATE_PENDING_FILE}" +} + +function sync_project_dependencies_for() { + local project_dir="$1" + local -a uv_cmd=( + "${UV_BIN}" sync + --project "${project_dir}" + --locked + --inexact + --no-dev + --no-install-project + --python "${VENV_PATH}/bin/python3" + ) + uv_cmd+=("${UV_OPTIONS[@]}") + env "${PACKAGE_ENV[@]}" \ + "UV_PROJECT_ENVIRONMENT=${VENV_PATH}" \ + "UV_LINK_MODE=copy" "${uv_cmd[@]}" +} + +function restore_project_dependencies() { + if [ "${PACKAGE_ROUTE_READY}" != "true" ]; then + configure_package_route || return 1 + PACKAGE_ROUTE_READY="true" + fi + INFO "→ 正在恢复更新前的程序依赖..." + if ! sync_project_dependencies_for "${APP_DIR}"; then + ERROR "依赖回滚失败,保留更新事务标记以便下次启动继续恢复" + return 1 + fi + INFO "→ 更新前的程序依赖已恢复" +} + +function cleanup_previous_payload() { + rm -rf "${UPDATE_PREVIOUS_APP}" "${UPDATE_PREVIOUS_PUBLIC}" +} + +function restore_previous_payload() { + local failed="false" + + if [ -e "${UPDATE_PREVIOUS_APP}" ]; then + if [ -e "${APP_DIR}" ] && ! rm -rf "${APP_DIR}"; then + failed="true" + elif ! mv "${UPDATE_PREVIOUS_APP}" "${APP_DIR}"; then + failed="true" + fi + fi + if [ -e "${UPDATE_PREVIOUS_PUBLIC}" ]; then + if [ -e "${PUBLIC_DIR}" ] && ! rm -rf "${PUBLIC_DIR}"; then + failed="true" + elif ! mv "${UPDATE_PREVIOUS_PUBLIC}" "${PUBLIC_DIR}"; then + failed="true" + fi + fi + [ "${failed}" = "false" ] +} + +function rollback_update_transaction() { + local failed="false" + + if ! restore_previous_payload; then + failed="true" + fi + if [ "${DEPENDENCY_SYNC_ATTEMPTED}" = "true" ] && ! restore_project_dependencies; then + failed="true" + fi + + if [ "${failed}" = "true" ]; then + UPDATE_RECOVERY_REQUIRED="true" + return 1 + fi + clear_update_pending + cleanup_previous_payload + return 0 +} + +function recover_pending_update() { + local state + state="$(update_pending_state 2>/dev/null || true)" + [ -n "${state}" ] || return 0 + + if [ "${state}" = "committed" ]; then + INFO "→ 清理已完成的容器更新事务" + cleanup_previous_payload + clear_update_pending + return 0 + fi + + WARN "→ 检测到未完成的容器更新事务,正在恢复旧版本" + if [ "${state}" = "dependencies" ]; then + DEPENDENCY_SYNC_ATTEMPTED="true" + fi + rollback_update_transaction || return 1 + UPDATE_RECOVERY_COMPLETED="true" + INFO "→ 未完成的容器更新事务已恢复" +} + +function existing_resource_dir() { + local resource_source_dir="${APP_DIR}/app/application/site" + for legacy_resource_dir in "${APP_DIR}/app/infrastructure" "${APP_DIR}/app/adapters/network" "${APP_DIR}/app/helper"; do + if [ ! -d "${resource_source_dir}" ] && [ -d "${legacy_resource_dir}" ]; then + resource_source_dir="${legacy_resource_dir}" + fi + done + printf '%s\n' "${resource_source_dir}" +} + +function download_staged_resource() { + local url="$1" + local destination="$2" + local fallback="$3" + local label="$4" + + if curl ${CURL_OPTIONS} --fail "${url}" -o "${destination}" \ + && [ -s "${destination}" ]; then + return 0 + fi + rm -f "${destination}" + if [ -f "${fallback}" ]; then + cp -a "${fallback}" "${destination}" + return $? + fi + ERROR "${label} 下载失败且没有可用旧资源" + return 1 +} + +function stage_runtime_payload() { + local stage_app="${TMP_PATH}/App" + local stage_plugin_dir="${stage_app}/app/plugins" + local stage_resource_dir="${stage_app}/app/application/site" + local resource_source_dir + local resource_file + local python_version + local arch + local arch_suffix + local sites_file + + [ -f "${stage_app}/version.py" ] || return 1 + [ -f "${stage_app}/pyproject.toml" ] || return 1 + [ -f "${stage_app}/uv.lock" ] || return 1 + [ -f "${TMP_PATH}/dist/index.html" ] || return 1 + + if [ -d "${APP_DIR}/app/plugins" ]; then + rm -rf "${stage_plugin_dir}" || return 1 + mkdir -p "${stage_plugin_dir}" || return 1 + if ! cp -a "${APP_DIR}/app/plugins/." "${stage_plugin_dir}/"; then + return 1 + fi + else + mkdir -p "${stage_plugin_dir}" || return 1 + fi + rm -f "${stage_plugin_dir}/__init__.py" + + resource_source_dir="$(existing_resource_dir)" + mkdir -p "${stage_resource_dir}" || return 1 + if [ -f "${resource_source_dir}/user.sites.v3.bin" ] \ + && ! cp -a "${resource_source_dir}/user.sites.v3.bin" "${stage_resource_dir}/"; then + return 1 + fi + for resource_file in "${resource_source_dir}"/sites.cp*; do + [ -f "${resource_file}" ] || continue + cp -a "${resource_file}" "${stage_resource_dir}/" || return 1 + done + + python_version="$(python3 -c 'import sys; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}")')" || return 1 + arch="$(uname -m)" + if [ "${arch}" = "aarch64" ]; then + arch_suffix="aarch64-linux-gnu" + else + arch_suffix="x86_64-linux-gnu" + fi + sites_file="sites.${python_version}-${arch_suffix}.so" + download_staged_resource \ + "${GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v3/user.sites.v3.bin" \ + "${stage_resource_dir}/user.sites.v3.bin" \ + "${resource_source_dir}/user.sites.v3.bin" \ + "user.sites.v3.bin" || return 1 + download_staged_resource \ + "${GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v3/${sites_file}" \ + "${stage_resource_dir}/${sites_file}" \ + "${resource_source_dir}/${sites_file}" \ + "${sites_file}" || return 1 +} + +function swap_staged_payload() { + cleanup_previous_payload || return 1 + mv "${APP_DIR}" "${UPDATE_PREVIOUS_APP}" || return 1 + if ! mv "${PUBLIC_DIR}" "${UPDATE_PREVIOUS_PUBLIC}"; then + mv "${UPDATE_PREVIOUS_APP}" "${APP_DIR}" || true + return 1 + fi + if ! mv "${TMP_PATH}/App" "${APP_DIR}" || ! mv "${TMP_PATH}/dist" "${PUBLIC_DIR}"; then + restore_previous_payload || true + return 1 + fi } # 下载程序资源,$1: 后端版本路径 @@ -115,11 +332,12 @@ function install_backend_and_download_resources() { fi INFO "后端程序下载成功" - # 检查依赖是否有变化 + # 检查依赖清单,实际同步延后到所有运行载荷准备完成之后。 INFO "→ 检查依赖变化..." + local dependencies_changed="false" if [ -f "${TMP_PATH}/App/pyproject.toml" ] && [ -f "${TMP_PATH}/App/uv.lock" ]; then if dependency_manifests_changed; then - sync_project_dependencies || return 1 + dependencies_changed="true" else INFO "依赖无变化,跳过依赖更新" fi @@ -157,82 +375,46 @@ function install_backend_and_download_resources() { return 1 fi INFO "前端程序下载成功" - # 备份插件目录 - INFO "→ 正在备份插件目录..." - if ! rm -rf /plugins \ - || ! mkdir -p /plugins \ - || ! cp -a /app/app/plugins/* /plugins/; then - ERROR "插件目录备份失败,终止更新" + INFO "→ 正在准备插件和站点资源..." + if ! stage_runtime_payload; then + ERROR "更新载荷准备失败,当前程序未替换" return 1 fi - rm -f /plugins/__init__.py - # 备份站点资源 - INFO "→ 正在备份站点资源目录..." - if ! rm -rf /resources_bakcup || ! mkdir /resources_bakcup; then - ERROR "站点资源备份目录准备失败,终止更新" + + # 标记必须先于依赖同步和目录切换写入,进程在任一阶段中断后才能恢复旧代际。 + if ! set_update_pending prepared; 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 - resource_source_dir="${legacy_resource_dir}" + + if [ "${dependencies_changed}" = "true" ]; then + if ! set_update_pending dependencies; then + ERROR "无法记录依赖更新事务,当前程序未替换" + return 1 fi - done - if [ -f "${resource_source_dir}/user.sites.v3.bin" ]; then - cp -a "${resource_source_dir}/user.sites.v3.bin" /resources_bakcup - fi - for resource_file in "${resource_source_dir}"/sites.cp*; do - [ -f "${resource_file}" ] && cp -a "${resource_file}" /resources_bakcup - done - # 清空程序目录 - 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}" - # 恢复插件目录 - 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}")') - arch=$(uname -m) - if [ "$arch" = "aarch64" ]; then - arch_suffix="aarch64-linux-gnu" - else - arch_suffix="x86_64-linux-gnu" - fi - INFO "当前 Python 版本:${python_version},架构:${arch}" - 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 - cp -a /resources_bakcup/user.sites.v3.bin /app/app/application/site/ + if ! sync_project_dependencies; then + ERROR "依赖同步失败,正在恢复更新前的运行环境" + rollback_update_transaction || true + return 1 fi - WARN "user.sites.v3.bin 下载失败,继续使用旧的资源来启动..." fi - # 下载对应平台的 sites 文件 - sites_file="sites.${python_version}-${arch_suffix}.so" - if ! curl ${CURL_OPTIONS} "${GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v3/${sites_file}" -o "/app/app/application/site/${sites_file}"; then - if [ -f "/resources_bakcup/${sites_file}" ]; then - cp -a "/resources_bakcup/${sites_file}" /app/app/application/site/ - fi - WARN "${sites_file} 下载失败,继续使用旧的资源来启动..." + + if ! swap_staged_payload; then + ERROR "程序文件切换失败,正在恢复更新前的运行环境" + rollback_update_transaction || true + return 1 fi - INFO "站点资源更新成功" - # 清理临时目录 + if ! set_update_pending committed; then + ERROR "无法确认更新事务,正在恢复更新前的运行环境" + rollback_update_transaction || true + return 1 + fi + + clear_update_pending + cleanup_previous_payload || WARN "更新完成,但旧程序备份清理失败" rm -rf "${TMP_PATH}" MOVIEPILOT_UPDATE_RESULT="updated" + INFO "程序更新成功,前端版本:${frontend_version},后端版本:${1}" return 0 } diff --git a/tests/test_docker_bootstrap.py b/tests/test_docker_bootstrap.py index 15409e797..3fcca7443 100644 --- a/tests/test_docker_bootstrap.py +++ b/tests/test_docker_bootstrap.py @@ -106,6 +106,50 @@ def test_launcher_prefers_complete_trusted_source_bundle(tmp_path: Path) -> None assert result.stdout == "source\n" +def test_launcher_uses_previous_generation_during_pending_recovery(tmp_path: Path) -> 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") + (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" + source_bundle_is_trusted() {{ 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), + ], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert result.stdout == "old\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 @@ -843,15 +887,37 @@ def test_failed_dependency_sync_does_not_replace_program_files(tmp_path: Path) - uv_bin = tmp_path / "bin" / "uv" uv_bin.parent.mkdir(parents=True) uv_bin.write_text( - "#!/bin/bash\nprintf '%s\\n' \"$*\" >> \"${UV_LOG}\"\nexit 1\n", + "#!/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" / "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.mkdir(parents=True) + (update_tree / "app" / "plugins").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") - copy_log = tmp_path / "copies.log" + (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"""\ @@ -859,21 +925,39 @@ def test_failed_dependency_sync_does_not_replace_program_files(tmp_path: Path) - VENV_PATH="$2" TMP_PATH="$3" UV_BIN="$4" - COPY_LOG="$5" 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; }} - cp() {{ printf '%s\n' "$*" >> "${{COPY_LOG}}"; }} + 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 || true + install_backend_and_download_resources tags/v3.0.1.zip """ ) - subprocess.run( + result = subprocess.run( [ "bash", "-c", @@ -883,21 +967,257 @@ def test_failed_dependency_sync_does_not_replace_program_files(tmp_path: Path) - str(tmp_path / "venv"), str(tmp_path / "update"), str(uv_bin), - str(copy_log), + 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, - env={**os.environ, "UV_LOG": str(uv_log)}, ) - assert not copy_log.exists() - assert uv_log.read_text(encoding="utf-8") == ( - f"sync --project {update_tree} --locked --inexact --no-dev " - f"--no-install-project --python {tmp_path / 'venv' / 'bin' / 'python3'}\n" + 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_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(