fix(docker): make source updates recoverable (#6379)

This commit is contained in:
InfinityPacer
2026-08-21 19:37:54 +08:00
committed by GitHub
parent c59a852b8d
commit 7f2de37764
4 changed files with 640 additions and 97 deletions
+14 -1
View File
@@ -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
+28
View File
@@ -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
View File
@@ -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
View File
@@ -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(