From 48bf89c8ecc96226145f2e60b25633d04434c93d Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 30 Aug 2026 13:05:07 +0800 Subject: [PATCH] fix(docker): keep current payload during dependency recovery --- docker/entrypoint.sh | 9 +++ docker/launcher.sh | 7 ++- docker/update.sh | 27 +++++++- docs/v3t-runtime-governance.md | 4 ++ tests/test_docker_bootstrap.py | 111 ++++++++++++++++++++++++++++++++- 5 files changed, 151 insertions(+), 7 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 882b4d833..3153d876e 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -595,6 +595,15 @@ usermod -o -u "${PUID}" moviepilot # 启动前优先确认主运行环境仍然健康,避免插件依赖污染导致服务直接起不来。 ensure_backend_runtime_dependencies +# 依赖阶段恢复会保留当前程序,待自愈成功后再清理旧代际备份和事务标记。 +if [ "${UPDATE_RECOVERY_BLOCKED:-false}" = "true" ]; then + if finalize_update_transaction; then + INFO "→ 当前程序依赖已恢复,已清理保留当前程序的更新事务" + else + WARN "→ 当前程序依赖已恢复,但旧代际备份清理失败,将保留事务标记重试" + fi +fi + # 缓存路径解析必须晚于依赖自愈,确保有效性探针使用当前运行版本。 if ! resolve_browser_cache_dir; then exit 1 diff --git a/docker/launcher.sh b/docker/launcher.sh index 94b8847fd..d23a885e3 100755 --- a/docker/launcher.sh +++ b/docker/launcher.sh @@ -107,7 +107,7 @@ function pending_recovery_control_dir() { state="$(pending_update_state 2>/dev/null || true)" case "${state}" in - prepared|dependencies) + prepared) [ -e "${UPDATE_PREVIOUS_APP}" ] || return 1 if source_bundle_is_trusted "${previous_control_dir}"; then printf '%s\n' "${previous_control_dir}" @@ -118,6 +118,11 @@ function pending_recovery_control_dir() { fi return 0 ;; + dependencies|blocked) + # 依赖阶段可能已切换到包含新迁移的当前 /app;必须使用当前或镜像控制脚本, + # 让 update.sh 保留新代际,而不是先执行旧代控制脚本触发数据库不兼容回退。 + return 1 + ;; esac return 1 } diff --git a/docker/update.sh b/docker/update.sh index a4314b767..5ccde431d 100644 --- a/docker/update.sh +++ b/docker/update.sh @@ -66,6 +66,7 @@ UV_OPTIONS=() MOVIEPILOT_UPDATE_RESULT="noop" UPDATE_RECOVERY_REQUIRED="false" UPDATE_RECOVERY_COMPLETED="false" +UPDATE_RECOVERY_BLOCKED="false" DEPENDENCY_SYNC_ATTEMPTED="false" PACKAGE_ROUTE_READY="false" @@ -268,10 +269,30 @@ function recover_pending_update() { return 0 fi - WARN "→ 检测到未完成的容器更新事务,正在恢复旧版本" - if [ "${state}" = "dependencies" ]; then - DEPENDENCY_SYNC_ATTEMPTED="true" + if [ "${state}" = "dependencies" ] || [ "${state}" = "blocked" ]; then + if [ ! -e "${APP_DIR}" ] || [ ! -e "${PUBLIC_DIR}" ]; then + # 目录切换中断可能留下不完整的当前载荷;这种情况仍需恢复完整旧代际。 + if [ ! -e "${UPDATE_PREVIOUS_APP}" ] || [ ! -e "${UPDATE_PREVIOUS_PUBLIC}" ]; then + ERROR "→ 当前与更新前载荷均不完整,无法安全恢复" + UPDATE_RECOVERY_REQUIRED="true" + return 1 + fi + WARN "→ 当前更新载荷不完整,恢复更新前版本" + else + # 依赖阶段可能已经切换到包含新迁移的 /app;此时不能恢复旧代际, + # 否则数据库已经前进时会把旧程序交给未知的 Alembic revision。 + WARN "→ 更新事务停留在依赖阶段,保留当前程序并等待启动前依赖自愈,不回退旧版本" + if [ "${state}" = "dependencies" ] && ! set_update_pending blocked; then + ERROR "→ 无法记录保留当前程序的更新恢复状态" + return 1 + fi + UPDATE_RECOVERY_BLOCKED="true" + UPDATE_RECOVERY_COMPLETED="true" + return 0 + fi fi + + WARN "→ 检测到未完成的容器更新事务,正在恢复旧版本" rollback_update_transaction || return 1 UPDATE_RECOVERY_COMPLETED="true" INFO "→ 未完成的容器更新事务已恢复" diff --git a/docs/v3t-runtime-governance.md b/docs/v3t-runtime-governance.md index 2e8c0d7b5..2113e3bc1 100644 --- a/docs/v3t-runtime-governance.md +++ b/docs/v3t-runtime-governance.md @@ -74,6 +74,10 @@ profile,依赖名称、版本和 source 语义全部由 `pyproject.toml` 与 ` 源码更新事务中的依赖同步和失败回滚使用同一 profile 选择入口。否则 V3t 在恢复时可能被普通默认组 覆盖,得到“3.14t 解释器 + 标准原生依赖”的无效组合。 +如果启动时发现更新事务停留在 `dependencies` 阶段,恢复流程保留当前 `/app`,只执行启动前依赖自愈, +不会自动切回旧代际。这样即使当前程序已经完成过数据库迁移,也不会把包含新结构的数据库交给缺少对应 +Alembic revision 文件的旧程序;依赖自愈成功后再清理旧代际备份和事务标记。 + ### 4.2 插件安装后的宿主恢复 插件与主程序共享虚拟环境。插件依赖安装前后都会采集宿主健康快照,只对安装后新增的异常执行补偿: diff --git a/tests/test_docker_bootstrap.py b/tests/test_docker_bootstrap.py index b59a8bd53..c81038b10 100644 --- a/tests/test_docker_bootstrap.py +++ b/tests/test_docker_bootstrap.py @@ -1237,7 +1237,7 @@ def test_pending_update_recovers_previous_payload_on_next_start(tmp_path: Path) (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") + (config_dir / "temp" / "__update_pending__").write_text("prepared\n", encoding="utf-8") uv_bin = tmp_path / "uv" uv_log = tmp_path / "uv.log" uv_bin.write_text( @@ -1288,9 +1288,114 @@ def test_pending_update_recovers_previous_payload_on_next_start(tmp_path: Path) ) 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" + assert not uv_log.exists() + + +def test_pending_dependency_update_keeps_current_payload_for_database_safety( + 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") + config_dir = tmp_path / "config" + pending_file = config_dir / "temp" / "__update_pending__" + pending_file.parent.mkdir(parents=True) + pending_file.write_text("dependencies\n", encoding="utf-8") + script = textwrap.dedent( + f"""\ + CONFIG_DIR="$1" + source {UPDATER!s} + APP_DIR="$2" + PUBLIC_DIR="$3" + UPDATE_PREVIOUS_APP="$4" + UPDATE_PREVIOUS_PUBLIC="$5" + INFO() {{ :; }} + WARN() {{ :; }} + ERROR() {{ :; }} + recover_pending_update + printf '%s|%s|%s|%s|%s|%s\n' \ + "$([[ -f "${{APP_DIR}}/app/new.py" ]] && printf new || printf missing)" \ + "$([[ -f "${{PUBLIC_DIR}}/index.html" ]] && head -n1 "${{PUBLIC_DIR}}/index.html" || printf missing)" \ + "$(tr -d '\r\n' < "${{UPDATE_PENDING_FILE}}")" \ + "${{UPDATE_RECOVERY_COMPLETED}}" \ + "${{UPDATE_RECOVERY_BLOCKED}}" \ + "$([[ -f "${{UPDATE_PREVIOUS_APP}}/app/old.py" ]] && printf retained || printf absent)" + """ ) + result = subprocess.run( + [ + "bash", + "-c", + script, + "pending-dependency-recovery-test", + str(config_dir), + str(live_app), + str(live_public), + str(previous_app), + str(previous_public), + ], + text=True, + capture_output=True, + check=True, + ) + + assert result.stdout == "new|new-front|blocked|true|true|retained\n" + + +def test_launcher_uses_current_control_for_dependency_recovery( + tmp_path: Path, +) -> None: + """依赖阶段 pending 不得先选旧代控制脚本触发回退。""" + 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") + pending_file = config / "temp" / "__update_pending__" + pending_file.parent.mkdir(parents=True) + pending_file.write_text("dependencies\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( + [ + "bash", + "-c", + script, + "dependency-launcher-test", + str(source), + str(image), + str(tmp_path / "run"), + str(pending_file), + str(previous), + ], + text=True, + capture_output=True, + check=True, + ) + + assert result.stdout == "new\n" def test_update_transaction_keeps_marker_when_backup_cleanup_fails(tmp_path: Path) -> None: