mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 12:06:51 +08:00
Merge branch 'v3' of https://github.com/jxxghp/MoviePilot into v3
This commit is contained in:
+14
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+265
-83
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+333
-13
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user