fix(docker): keep current payload during dependency recovery

This commit is contained in:
jxxghp
2026-08-30 13:05:07 +08:00
parent b2b539626c
commit 48bf89c8ec
5 changed files with 151 additions and 7 deletions
+9
View File
@@ -595,6 +595,15 @@ usermod -o -u "${PUID}" moviepilot
# 启动前优先确认主运行环境仍然健康,避免插件依赖污染导致服务直接起不来。 # 启动前优先确认主运行环境仍然健康,避免插件依赖污染导致服务直接起不来。
ensure_backend_runtime_dependencies 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 if ! resolve_browser_cache_dir; then
exit 1 exit 1
+6 -1
View File
@@ -107,7 +107,7 @@ function pending_recovery_control_dir() {
state="$(pending_update_state 2>/dev/null || true)" state="$(pending_update_state 2>/dev/null || true)"
case "${state}" in case "${state}" in
prepared|dependencies) prepared)
[ -e "${UPDATE_PREVIOUS_APP}" ] || return 1 [ -e "${UPDATE_PREVIOUS_APP}" ] || return 1
if source_bundle_is_trusted "${previous_control_dir}"; then if source_bundle_is_trusted "${previous_control_dir}"; then
printf '%s\n' "${previous_control_dir}" printf '%s\n' "${previous_control_dir}"
@@ -118,6 +118,11 @@ function pending_recovery_control_dir() {
fi fi
return 0 return 0
;; ;;
dependencies|blocked)
# 依赖阶段可能已切换到包含新迁移的当前 /app;必须使用当前或镜像控制脚本,
# 让 update.sh 保留新代际,而不是先执行旧代控制脚本触发数据库不兼容回退。
return 1
;;
esac esac
return 1 return 1
} }
+24 -3
View File
@@ -66,6 +66,7 @@ UV_OPTIONS=()
MOVIEPILOT_UPDATE_RESULT="noop" MOVIEPILOT_UPDATE_RESULT="noop"
UPDATE_RECOVERY_REQUIRED="false" UPDATE_RECOVERY_REQUIRED="false"
UPDATE_RECOVERY_COMPLETED="false" UPDATE_RECOVERY_COMPLETED="false"
UPDATE_RECOVERY_BLOCKED="false"
DEPENDENCY_SYNC_ATTEMPTED="false" DEPENDENCY_SYNC_ATTEMPTED="false"
PACKAGE_ROUTE_READY="false" PACKAGE_ROUTE_READY="false"
@@ -268,10 +269,30 @@ function recover_pending_update() {
return 0 return 0
fi fi
WARN "→ 检测到未完成的容器更新事务,正在恢复旧版本" if [ "${state}" = "dependencies" ] || [ "${state}" = "blocked" ]; then
if [ "${state}" = "dependencies" ]; then if [ ! -e "${APP_DIR}" ] || [ ! -e "${PUBLIC_DIR}" ]; then
DEPENDENCY_SYNC_ATTEMPTED="true" # 目录切换中断可能留下不完整的当前载荷;这种情况仍需恢复完整旧代际。
if [ ! -e "${UPDATE_PREVIOUS_APP}" ] || [ ! -e "${UPDATE_PREVIOUS_PUBLIC}" ]; then
ERROR "→ 当前与更新前载荷均不完整,无法安全恢复"
UPDATE_RECOVERY_REQUIRED="true"
return 1
fi 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 rollback_update_transaction || return 1
UPDATE_RECOVERY_COMPLETED="true" UPDATE_RECOVERY_COMPLETED="true"
INFO "→ 未完成的容器更新事务已恢复" INFO "→ 未完成的容器更新事务已恢复"
+4
View File
@@ -74,6 +74,10 @@ profile,依赖名称、版本和 source 语义全部由 `pyproject.toml` 与 `
源码更新事务中的依赖同步和失败回滚使用同一 profile 选择入口。否则 V3t 在恢复时可能被普通默认组 源码更新事务中的依赖同步和失败回滚使用同一 profile 选择入口。否则 V3t 在恢复时可能被普通默认组
覆盖,得到“3.14t 解释器 + 标准原生依赖”的无效组合。 覆盖,得到“3.14t 解释器 + 标准原生依赖”的无效组合。
如果启动时发现更新事务停留在 `dependencies` 阶段,恢复流程保留当前 `/app`,只执行启动前依赖自愈,
不会自动切回旧代际。这样即使当前程序已经完成过数据库迁移,也不会把包含新结构的数据库交给缺少对应
Alembic revision 文件的旧程序;依赖自愈成功后再清理旧代际备份和事务标记。
### 4.2 插件安装后的宿主恢复 ### 4.2 插件安装后的宿主恢复
插件与主程序共享虚拟环境。插件依赖安装前后都会采集宿主健康快照,只对安装后新增的异常执行补偿: 插件与主程序共享虚拟环境。插件依赖安装前后都会采集宿主健康快照,只对安装后新增的异常执行补偿:
+108 -3
View File
@@ -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") (previous_public / "index.html").write_text("old-front", encoding="utf-8")
config_dir = tmp_path / "config" config_dir = tmp_path / "config"
(config_dir / "temp").mkdir(parents=True) (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_bin = tmp_path / "uv"
uv_log = tmp_path / "uv.log" uv_log = tmp_path / "uv.log"
uv_bin.write_text( 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 result.stdout == "old|old-front|cleared|true\n"
assert uv_log.read_text(encoding="utf-8").startswith( assert not uv_log.exists()
f"sync --project {live_app} --locked --inexact --no-dev"
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: def test_update_transaction_keeps_marker_when_backup_cleanup_fails(tmp_path: Path) -> None: