fix(update): converge stale state and Docker transaction

This commit is contained in:
moviepilot-codex[bot]
2026-09-21 15:49:21 +08:00
parent fe7c183d06
commit 240f43b327
7 changed files with 93 additions and 14 deletions

View File

@@ -317,16 +317,13 @@ class SystemUpdateManager(metaclass=SingletonClass):
}
)
changed = True
elif item.get("state") == "installing" and self._is_install_applied(item, target):
self._reset_item_after_install(item)
changed = True
elif (
target == _RESOURCES
and item.get("state") == "ready"
item.get("state") in {"installing", "ready"}
and self._is_install_applied(item, target)
):
# 容器替换或手工安装可能先让运行资源达到目标版本,需丢弃残留待安装包。
self._discard_prepared_target(target)
if item.get("state") == "ready":
# 容器替换或手工安装可能先让运行版本达到目标,需丢弃残留待安装包。
self._discard_prepared_target(target)
self._reset_item_after_install(item)
changed = True
resources_changed = (
@@ -344,7 +341,10 @@ class SystemUpdateManager(metaclass=SingletonClass):
def _is_install_applied(self, item: dict[str, Any], target: SystemUpdateType) -> bool:
"""判断启动器应用后的当前版本是否已经达到安装目标。"""
if target == _APPLICATION:
return bool(item.get("version")) and item["version"] == get_app_version()
target_version = str(item.get("version") or "")
return bool(target_version) and compare_version(
get_app_version(), ">=", target_version
) is True
current_auth, current_indexer = get_resource_versions()
checks = []
if item.get("auth_version"):

View File

@@ -12,7 +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.sh 共用容器可写层中的 Dev 事务标记;容器重建时不保留旧半成品状态。
UPDATE_PENDING_FILE="${MOVIEPILOT_UPDATE_PENDING_FILE:-/var/lib/moviepilot/update/__update_pending__}"
UPDATE_PREVIOUS_APP="/app.__update_previous__"
CONTROL_FILES=()
CONTROL_REQUIRED_FILES=(entrypoint.sh update.sh browser.sh cert.sh)

View File

@@ -28,7 +28,9 @@ 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__"
# Dev 更新只需要跨同一容器重启恢复;标记必须和 /app、/public 位于同一容器可写层,
# 避免容器重建后 /config 遗留旧标记阻断新镜像启动。
UPDATE_PENDING_FILE="${MOVIEPILOT_UPDATE_PENDING_FILE:-/var/lib/moviepilot/update/__update_pending__}"
UPDATE_PREVIOUS_APP="${APP_DIR}.__update_previous__"
UPDATE_PREVIOUS_PUBLIC="${PUBLIC_DIR}.__update_previous__"

View File

@@ -102,7 +102,7 @@ source 其他脚本,可能出现同一次启动混用新旧脚本的情况。
### 3.3 更新恢复时的代际选择
`/config/temp/__update_pending__` 的值会影响 launcher:
`/var/lib/moviepilot/update/__update_pending__` 的值会影响 launcher:
| 状态 | 控制脚本选择原则 |
| --- | --- |
@@ -169,9 +169,13 @@ entrypoint 会删除该标记,并只在本次启动中把 `MOVIEPILOT_UPDATE_D
```text
/app.__update_previous__
/public.__update_previous__
/config/temp/__update_pending__
/var/lib/moviepilot/update/__update_pending__
```
Dev 更新标记与程序载荷都位于容器可写层,只用于跨同一容器重启恢复;容器重建时它会和旧的
`/app`、`/public` 载荷一起丢弃,避免持久化卷残留旧事务标记而阻断新镜像启动。稳定版
Release/资源更新的 `prepared.json`、`install.json` 和 `state.json` 仍保存在 `/config`,不受此规则影响。
状态含义与启动处理:
| 状态 | 含义 | 下次启动处理 |
@@ -447,7 +451,7 @@ Docker restart policy。
| `/config/app.env` | Docker 启动脚本和应用配置。 |
| `/config/.cache/uv` | 默认 uv 持久缓存。 |
| `/config/.browser/cloakbrowser` | 默认 CloakBrowser 持久缓存。 |
| `/config/temp/__update_pending__` | 载荷切换事务状态。 |
| `/var/lib/moviepilot/update/__update_pending__` | Dev 载荷切换事务状态;与 `/app`、`/public` 同属容器可写层。 |
| `/app.__update_previous__` | 更新前后端备份。 |
| `/public.__update_previous__` | 更新前前端备份。 |
| `/config/temp/moviepilot-update/` | 后台下载的 Release/资源包及安装状态。 |
@@ -460,7 +464,7 @@ Docker restart policy。
| 变量 | 默认值 | 影响 |
| --- | --- | --- |
| `CONFIG_DIR` | `/config` | 配置、缓存、更新状态和证书根目录。 |
| `CONFIG_DIR` | `/config` | 配置、缓存、Release/资源更新状态和证书根目录。 |
| `PUID` / `PGID` | `0` / `0` | 映射 `moviepilot` 运行用户。 |
| `UMASK` | `000` | 后端进程文件权限掩码。 |
| `PORT` | `3001` | 后端监听和 readiness 端口。 |

View File

@@ -1186,6 +1186,7 @@ def test_failed_dependency_sync_does_not_replace_program_files(tmp_path: Path) -
UV_BIN="$4"
PIP_PROXY= PROXY_HOST=
GITHUB_PROXY= CURL_OPTIONS=
MOVIEPILOT_UPDATE_PENDING_FILE="$1/temp/__update_pending__"
source {UPDATER!s}
APP_DIR="$5"
PUBLIC_DIR="$6"
@@ -1282,6 +1283,7 @@ def test_pending_update_recovers_previous_payload_on_next_start(tmp_path: Path)
CONFIG_DIR="$1"
VENV_PATH="$2"
UV_BIN="$7"
MOVIEPILOT_UPDATE_PENDING_FILE="$1/temp/__update_pending__"
PIP_PROXY= PROXY_HOST= GITHUB_PROXY= CURL_OPTIONS=
source {UPDATER!s}
APP_DIR="$3"
@@ -1346,6 +1348,7 @@ def test_pending_dependency_update_keeps_current_payload_for_database_safety(
script = textwrap.dedent(
f"""\
CONFIG_DIR="$1"
MOVIEPILOT_UPDATE_PENDING_FILE="$1/temp/__update_pending__"
source {UPDATER!s}
APP_DIR="$2"
PUBLIC_DIR="$3"
@@ -1436,6 +1439,7 @@ def test_update_transaction_keeps_marker_when_backup_cleanup_fails(tmp_path: Pat
script = textwrap.dedent(
f"""\
CONFIG_DIR="$1"
MOVIEPILOT_UPDATE_PENDING_FILE="$2"
UPDATE_PENDING_FILE="$2"
UPDATE_PREVIOUS_APP="$3"
UPDATE_PREVIOUS_PUBLIC="$4"
@@ -1582,6 +1586,7 @@ def test_staged_payload_swap_failure_restores_previous_generation(tmp_path: Path
CONFIG_DIR="$1"
TMP_PATH="$2"
PIP_PROXY= PROXY_HOST= GITHUB_PROXY= CURL_OPTIONS=
MOVIEPILOT_UPDATE_PENDING_FILE="$1/temp/__update_pending__"
source {UPDATER!s}
APP_DIR="$3"
PUBLIC_DIR="$4"

View File

@@ -98,6 +98,18 @@ def test_plugin_runtime_updates_preserve_host_entrypoint() -> None:
assert "cp -a /app/app/plugins/." not in perf_script
def test_dev_update_marker_stays_with_container_payload() -> None:
"""Dev 更新标记必须与容器载荷同层,避免持久化卷残留半完成事务。"""
marker = "/var/lib/moviepilot/update/__update_pending__"
update_script = _read(ROOT / "docker" / "update.sh")
launcher_script = _read(ROOT / "docker" / "launcher.sh")
assert f"MOVIEPILOT_UPDATE_PENDING_FILE:-{marker}" in update_script
assert f"MOVIEPILOT_UPDATE_PENDING_FILE:-{marker}" in launcher_script
assert "/config/temp/__update_pending__" not in update_script
assert "/config/temp/__update_pending__" not in launcher_script
def test_update_script_keeps_new_host_entrypoint_during_runtime_migration(
tmp_path: Path,
) -> None:

View File

@@ -266,6 +266,61 @@ def test_interrupted_download_becomes_retryable_failure(monkeypatch, tmp_path):
assert "中断" in status.error
def test_installing_application_update_converges_when_runtime_is_newer(
monkeypatch, tmp_path
):
"""当前主程序高于残留目标版本时应清理状态并恢复自动检查。"""
manager = _manager(monkeypatch, tmp_path)
monkeypatch.setattr(update_module, "get_app_version", lambda: "v3.0.6")
manager._write_item(
"application",
state="installing",
version="v3.0.1",
can_update=False,
can_install=False,
)
checked = []
monkeypatch.setattr(manager, "_check_application", lambda: checked.append(True))
status = manager.check("application")
application = next(item for item in status.updates if item.type == "application")
assert checked == [True]
assert application.state == "idle"
assert application.version is None
assert status.state == "idle"
def test_ready_application_update_is_discarded_when_runtime_reaches_target(
monkeypatch, tmp_path
):
"""外部升级先于确认安装时应清理过期的主程序待安装包。"""
manager = _manager(monkeypatch, tmp_path)
monkeypatch.setattr(update_module, "get_app_version", lambda: "v3.1.0")
manager._merge_prepared_manifest(
{
"version": "v3.1.0",
"backend_archive": "/tmp/backend.zip",
"frontend_archive": "/tmp/frontend.zip",
}
)
manager._write_item(
"application",
state="ready",
version="v3.1.0",
can_update=False,
can_install=True,
)
status = manager.get_status()
application = next(item for item in status.updates if item.type == "application")
assert application.state == "idle"
assert application.version is None
assert application.can_install is False
assert not (manager._root / "prepared.json").exists()
def test_ready_resource_update_clears_after_loaded_version_reaches_target(
monkeypatch,
tmp_path,