mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 04:47:03 +08:00
Reuse the checked resource manifest within each download and retain phase-specific retry errors. Preserve application update error contracts. Refs #7185
1926 lines
79 KiB
Python
1926 lines
79 KiB
Python
"""
|
||
MoviePilot 主程序与站点资源的后台检查、下载和待安装状态管理。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import errno
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import stat
|
||
import subprocess
|
||
import threading
|
||
import zipfile
|
||
from datetime import date, datetime, timezone
|
||
from pathlib import Path, PurePosixPath
|
||
from tempfile import TemporaryDirectory
|
||
from typing import Any, cast
|
||
|
||
from app.adapters.network.http import RequestUtils
|
||
from app.adapters.system.resource import ResourceHelper, get_resource_versions
|
||
from app.foundation.environment import is_docker, is_exe
|
||
from app.foundation.singleton import SingletonClass
|
||
from app.foundation.url import UrlUtils
|
||
from app.foundation.version import compare_version
|
||
from app.runtime.dependencies.profile import runtime_sync_arguments
|
||
from app.runtime.log import logger
|
||
from app.runtime.settings import get_runtime_setting
|
||
from app.runtime.thread import ThreadHelper
|
||
from app.runtime.version import get_app_version
|
||
from app.schemas.system import SystemUpdateStatus, SystemUpdateTarget, SystemUpdateType
|
||
|
||
_APPLICATION: SystemUpdateType = "application"
|
||
_RESOURCES: SystemUpdateType = "resources"
|
||
_TARGETS: tuple[SystemUpdateType, ...] = (_APPLICATION, _RESOURCES)
|
||
# 下载中或等待用户确认安装的状态:检查不得覆盖,也无须重复发起检查
|
||
_PENDING_STATES = frozenset({"downloading", "ready", "installing"})
|
||
_ITEM_FIELDS = {
|
||
"state",
|
||
"current_version",
|
||
"version",
|
||
"frontend_version",
|
||
"current_auth_version",
|
||
"auth_version",
|
||
"current_indexer_version",
|
||
"indexer_version",
|
||
"release_name",
|
||
"release_notes",
|
||
"published_at",
|
||
"checked_at",
|
||
"downloaded_bytes",
|
||
"total_bytes",
|
||
"progress",
|
||
"error",
|
||
"can_update",
|
||
"can_install",
|
||
}
|
||
|
||
|
||
class SystemUpdateManager(metaclass=SingletonClass):
|
||
"""
|
||
持久化两类升级状态,并保证同一时刻只有一个后台下载任务。
|
||
"""
|
||
|
||
_BACKEND_RELEASES_API = "https://api.github.com/repos/jxxghp/MoviePilot/releases"
|
||
_FRONTEND_RELEASE_API = (
|
||
"https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/tags/{tag}"
|
||
)
|
||
_BACKEND_ARCHIVE_URL = (
|
||
"https://github.com/jxxghp/MoviePilot/archive/refs/tags/{tag}.zip"
|
||
)
|
||
_VERSION_PATTERN = re.compile(r"^v3\.\d+\.\d+(?:[-.](?:alpha|beta|rc)\d*)?$", re.I)
|
||
# 镜像构建时写入的系统基线(ISO 日期),记录镜像系统软件包刷新到的安全基线。
|
||
_SYSTEM_BASELINE_FILE = Path("/etc/moviepilot/system-baseline")
|
||
# 基线机制引入前的下限:没有基线文件的旧镜像按此值比较,因此只有目标版本把
|
||
# MIN_SYSTEM_BASELINE 提高到该日期之后才会拦截旧镜像。
|
||
_LEGACY_SYSTEM_BASELINE = "2026-01-01"
|
||
_SYSTEM_BASELINE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||
# 稳定版允许 -N 后缀,用于不升小版本的临时修复(如 v3.0.10-1)
|
||
_STABLE_VERSION_PATTERN = re.compile(r"^v3\.\d+\.\d+(?:-\d+)?$", re.I)
|
||
|
||
def __init__(self) -> None:
|
||
"""
|
||
初始化状态写入锁、下载任务标记及当前进程已检查的资源清单快照。
|
||
"""
|
||
self._lock = threading.RLock()
|
||
self._download_active = False
|
||
self._active_target: SystemUpdateType | None = None
|
||
self._resource_update_info: tuple[tuple[str, str], dict[str, Any]] | None = None
|
||
|
||
@property
|
||
def _root(self) -> Path:
|
||
"""
|
||
返回跨重启保留更新制品的暂存根目录。
|
||
"""
|
||
return Path(get_runtime_setting("TEMP_PATH")) / "moviepilot-update"
|
||
|
||
@property
|
||
def _state_file(self) -> Path:
|
||
"""
|
||
返回更新状态文件路径。
|
||
"""
|
||
return self._root / "state.json"
|
||
|
||
@property
|
||
def _install_file(self) -> Path:
|
||
"""
|
||
返回 Docker 特权更新进程 或本地 CLI 消费的安装意图文件路径。
|
||
"""
|
||
return self._root / "install.json"
|
||
|
||
@property
|
||
def _backend_archive(self) -> Path:
|
||
"""
|
||
返回后端 Release 压缩包路径。
|
||
"""
|
||
return self._root / "backend.zip"
|
||
|
||
@property
|
||
def _frontend_archive(self) -> Path:
|
||
"""
|
||
返回后端版本声明对应的前端压缩包路径。
|
||
"""
|
||
return self._root / "frontend.zip"
|
||
|
||
@property
|
||
def _resource_dir(self) -> Path:
|
||
"""
|
||
返回站点资源包暂存目录。
|
||
"""
|
||
return self._root / "resources"
|
||
|
||
@property
|
||
def _docker_app_dir(self) -> Path:
|
||
"""
|
||
返回 Docker 当前后端源码目录。
|
||
"""
|
||
return Path(get_runtime_setting("ROOT_PATH"))
|
||
|
||
@property
|
||
def _docker_public_dir(self) -> Path:
|
||
"""
|
||
返回 Docker 当前前端静态文件目录。
|
||
"""
|
||
return Path(get_runtime_setting("FRONTEND_PATH"))
|
||
|
||
@property
|
||
def _docker_pending_file(self) -> Path:
|
||
"""
|
||
返回 Docker 载荷切换事务标记路径。
|
||
"""
|
||
return Path(get_runtime_setting("TEMP_PATH")) / "__update_pending__"
|
||
|
||
@property
|
||
def _docker_previous_app_dir(self) -> Path:
|
||
"""
|
||
返回 Docker 更新前后端源码备份目录。
|
||
"""
|
||
app_dir = self._docker_app_dir
|
||
return app_dir.with_name(f"{app_dir.name}.__update_previous__")
|
||
|
||
@property
|
||
def _docker_previous_public_dir(self) -> Path:
|
||
"""
|
||
返回 Docker 更新前前端静态文件备份目录。
|
||
"""
|
||
public_dir = self._docker_public_dir
|
||
return public_dir.with_name(f"{public_dir.name}.__update_previous__")
|
||
|
||
@staticmethod
|
||
def _now() -> str:
|
||
"""
|
||
返回 UTC ISO 时间戳。
|
||
"""
|
||
return datetime.now(timezone.utc).isoformat()
|
||
|
||
def _default_item(self, target: SystemUpdateType) -> dict[str, Any]:
|
||
"""
|
||
创建单类升级的初始状态。
|
||
"""
|
||
if target == _APPLICATION:
|
||
return {
|
||
"type": target,
|
||
"state": "idle",
|
||
"current_version": get_app_version(),
|
||
}
|
||
auth_version, indexer_version = get_resource_versions()
|
||
return {
|
||
"type": target,
|
||
"state": "idle",
|
||
"current_auth_version": auth_version,
|
||
"current_indexer_version": indexer_version,
|
||
}
|
||
|
||
def _default_state(self) -> dict[str, Any]:
|
||
"""
|
||
创建兼容旧客户端字段的聚合状态。
|
||
"""
|
||
application = self._default_item(_APPLICATION)
|
||
resources = self._default_item(_RESOURCES)
|
||
return self._sync_aggregate(
|
||
{
|
||
"updates": [application, resources],
|
||
"current_version": get_app_version(),
|
||
}
|
||
)
|
||
|
||
def _read_state(self) -> dict[str, Any]:
|
||
"""
|
||
读取并规范化状态文件,兼容只有主程序字段的旧状态。
|
||
"""
|
||
payload: dict[str, Any] = {}
|
||
try:
|
||
loaded = json.loads(self._state_file.read_text(encoding="utf-8"))
|
||
if isinstance(loaded, dict):
|
||
payload = loaded
|
||
except (OSError, json.JSONDecodeError):
|
||
pass
|
||
|
||
defaults = self._default_state()
|
||
updates_payload = payload.get("updates")
|
||
if not isinstance(updates_payload, list):
|
||
application = self._default_item(_APPLICATION)
|
||
application.update(
|
||
{key: payload[key] for key in _ITEM_FIELDS if key in payload}
|
||
)
|
||
updates_payload = [application, self._default_item(_RESOURCES)]
|
||
|
||
normalized_updates: list[dict[str, Any]] = []
|
||
for target in _TARGETS:
|
||
item = next(
|
||
(
|
||
value
|
||
for value in updates_payload
|
||
if isinstance(value, dict) and value.get("type") == target
|
||
),
|
||
None,
|
||
)
|
||
normalized = self._default_item(target)
|
||
if item:
|
||
normalized.update(item)
|
||
normalized["type"] = target
|
||
normalized_updates.append(normalized)
|
||
|
||
state = {**defaults, **payload, "updates": normalized_updates}
|
||
return self._sync_aggregate(state)
|
||
|
||
def _write_state(self, **changes: Any) -> dict[str, Any]:
|
||
"""
|
||
原子写入聚合状态,并保留旧版直接修改主程序字段的调用语义。
|
||
"""
|
||
with self._lock:
|
||
state = self._read_state()
|
||
application = self._get_item(state, _APPLICATION)
|
||
for key, value in changes.items():
|
||
if key in _ITEM_FIELDS:
|
||
application[key] = value
|
||
else:
|
||
state[key] = value
|
||
state = self._sync_aggregate(state)
|
||
return self._persist_state(state)
|
||
|
||
def _write_item(self, target: SystemUpdateType, **changes: Any) -> dict[str, Any]:
|
||
"""
|
||
更新一个升级类型并同步聚合字段。
|
||
"""
|
||
with self._lock:
|
||
state = self._read_state()
|
||
self._get_item(state, target).update(changes)
|
||
return self._persist_state(self._sync_aggregate(state))
|
||
|
||
def _write_check_result(
|
||
self, target: SystemUpdateType, *, ready_version: str | None = None, **changes: Any
|
||
) -> bool:
|
||
"""
|
||
仅允许手动检查替换请求开始时的旧待安装目标,保护交错下载及安装。
|
||
|
||
:return: 检查结果是否写入;调用方仅可为已接受的目标保留下载快照
|
||
"""
|
||
with self._lock:
|
||
item = self._get_item(self.get_status().model_dump(), target)
|
||
if item["state"] in _PENDING_STATES:
|
||
if (
|
||
item["state"] != "ready"
|
||
or not ready_version
|
||
or item.get("version") != ready_version
|
||
or changes.get("state") != "available"
|
||
or compare_version(str(changes.get("version") or ""), ">", ready_version) is not True
|
||
):
|
||
return False
|
||
self._discard_prepared_target(target)
|
||
self._write_item(target, **changes)
|
||
return True
|
||
|
||
def _persist_state(self, state: dict[str, Any]) -> dict[str, Any]:
|
||
"""
|
||
校验并原子替换状态 JSON。
|
||
"""
|
||
validated = SystemUpdateStatus.model_validate(state).model_dump()
|
||
self._root.mkdir(parents=True, exist_ok=True)
|
||
temporary = self._state_file.with_suffix(".tmp")
|
||
temporary.write_text(
|
||
json.dumps(validated, ensure_ascii=False, indent=2), encoding="utf-8"
|
||
)
|
||
temporary.replace(self._state_file)
|
||
return validated
|
||
|
||
@staticmethod
|
||
def _get_item(state: dict[str, Any], target: SystemUpdateType) -> dict[str, Any]:
|
||
"""
|
||
从聚合状态中取得指定升级类型。
|
||
"""
|
||
return next(item for item in state["updates"] if item["type"] == target)
|
||
|
||
def _sync_aggregate(self, state: dict[str, Any]) -> dict[str, Any]:
|
||
"""
|
||
从两类明细重新计算旧版顶层字段、当前版本和汇总进度。
|
||
"""
|
||
application = self._get_item(state, _APPLICATION)
|
||
current_version = get_app_version()
|
||
application["current_version"] = current_version
|
||
state.update(
|
||
{
|
||
"state": application.get("state", "idle"),
|
||
"current_version": current_version,
|
||
"version": application.get("version"),
|
||
"frontend_version": application.get("frontend_version"),
|
||
"release_name": application.get("release_name"),
|
||
"release_notes": application.get("release_notes"),
|
||
"published_at": application.get("published_at"),
|
||
"checked_at": application.get("checked_at"),
|
||
"downloaded_bytes": sum(
|
||
int(item.get("downloaded_bytes") or 0) for item in state["updates"]
|
||
),
|
||
"total_bytes": sum(
|
||
int(item.get("total_bytes") or 0) for item in state["updates"]
|
||
),
|
||
"error": next(
|
||
(item.get("error") for item in state["updates"] if item.get("error")),
|
||
None,
|
||
),
|
||
"can_update": any(item.get("can_update") for item in state["updates"]),
|
||
"can_install": any(item.get("can_install") for item in state["updates"]),
|
||
}
|
||
)
|
||
priorities = ("failed", "installing", "downloading", "ready", "available", "idle")
|
||
state["state"] = next(
|
||
(
|
||
priority
|
||
for priority in priorities
|
||
if any(item.get("state") == priority for item in state["updates"])
|
||
),
|
||
"idle",
|
||
)
|
||
state["progress"] = self._progress(
|
||
state["downloaded_bytes"], state["total_bytes"]
|
||
)
|
||
for item in state["updates"]:
|
||
item["progress"] = self._progress(
|
||
item.get("downloaded_bytes", 0), item.get("total_bytes", 0)
|
||
)
|
||
return state
|
||
|
||
@staticmethod
|
||
def _progress(downloaded: Any, total: Any) -> int:
|
||
"""
|
||
将下载字节数转换为 0 到 100 的整数进度。
|
||
"""
|
||
try:
|
||
downloaded_value = max(0, int(downloaded))
|
||
total_value = max(0, int(total))
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
if total_value <= 0:
|
||
return 0
|
||
return min(100, int(downloaded_value * 100 / total_value))
|
||
|
||
def get_status(self) -> SystemUpdateStatus:
|
||
"""
|
||
收敛安装状态并返回快照;提醒开关实时读取,避免缓存绕过关闭设置。
|
||
"""
|
||
with self._lock:
|
||
state = self._read_state()
|
||
changed = False
|
||
auth_version, indexer_version = get_resource_versions()
|
||
resources = self._get_item(state, _RESOURCES)
|
||
previous_auth_version = resources.get("current_auth_version")
|
||
previous_indexer_version = resources.get("current_indexer_version")
|
||
resources["current_auth_version"] = auth_version
|
||
resources["current_indexer_version"] = indexer_version
|
||
for item in state["updates"]:
|
||
target = item["type"]
|
||
if item.get("state") == "downloading" and not self._download_active:
|
||
item.update(
|
||
{
|
||
"state": "failed",
|
||
"error": "更新包下载因服务重启而中断,请重试",
|
||
"can_update": True,
|
||
"can_install": False,
|
||
}
|
||
)
|
||
changed = True
|
||
elif (
|
||
item.get("state") in {"available", "ready", "installing", "failed"}
|
||
and self._is_install_applied(item, target)
|
||
):
|
||
if item.get("state") in {"available", "ready", "failed"}:
|
||
# 容器替换或手工安装可能先让运行版本达到目标,需丢弃残留待安装包;
|
||
# 失败或旧检查覆盖后的可用记录也可能保留下载包,外部升级后已无意义。
|
||
self._discard_prepared_target(target)
|
||
self._reset_item_after_install(item)
|
||
changed = True
|
||
if resources.get("state") == "available":
|
||
changed = self._restore_prepared_resources(resources) or changed
|
||
resources_changed = (
|
||
previous_auth_version != auth_version
|
||
or previous_indexer_version != indexer_version
|
||
)
|
||
if changed or resources_changed:
|
||
state = self._persist_state(self._sync_aggregate(state))
|
||
else:
|
||
state = self._sync_aggregate(state)
|
||
state["auto_update"] = get_runtime_setting("MOVIEPILOT_AUTO_UPDATE") is True
|
||
state["auto_update_resource"] = get_runtime_setting("AUTO_UPDATE_RESOURCE") is True
|
||
state["supports_batch_update"] = True
|
||
return SystemUpdateStatus.model_validate(state)
|
||
|
||
def _restore_prepared_resources(self, item: dict[str, Any]) -> bool:
|
||
"""
|
||
旧检查覆盖状态后,仅恢复目标包版本一致且文件完整、摘要正确的待安装资源。
|
||
"""
|
||
prepared = self._read_prepared_manifest_optional()
|
||
if not item.get("version") or prepared.get("resource_package_version") != item["version"]:
|
||
return False
|
||
try:
|
||
self._validate_resource_manifest(prepared)
|
||
size = sum(Path(file["path"]).stat().st_size for file in prepared["resource_files"])
|
||
except (OSError, RuntimeError):
|
||
return False
|
||
item.update(
|
||
state="ready",
|
||
downloaded_bytes=size,
|
||
total_bytes=size,
|
||
error=None,
|
||
can_update=False,
|
||
can_install=True,
|
||
)
|
||
return True
|
||
|
||
def _is_install_applied(self, item: dict[str, Any], target: SystemUpdateType) -> bool:
|
||
"""
|
||
判断启动器应用后的当前版本是否已经达到安装目标。
|
||
"""
|
||
if target == _APPLICATION:
|
||
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"):
|
||
checks.append(compare_version(current_auth, ">=", item["auth_version"]) is True)
|
||
if item.get("indexer_version"):
|
||
checks.append(compare_version(current_indexer, ">=", item["indexer_version"]) is True)
|
||
return bool(checks) and all(checks)
|
||
|
||
@staticmethod
|
||
def _reset_item_after_install(item: dict[str, Any]) -> None:
|
||
"""
|
||
清理已在启动前应用完成的单类安装状态。
|
||
"""
|
||
current_values = {
|
||
key: item.get(key)
|
||
for key in (
|
||
"type",
|
||
"current_version",
|
||
"current_auth_version",
|
||
"current_indexer_version",
|
||
)
|
||
}
|
||
item.clear()
|
||
item.update(
|
||
{
|
||
**current_values,
|
||
"state": "idle",
|
||
"downloaded_bytes": 0,
|
||
"total_bytes": 0,
|
||
"progress": 0,
|
||
"error": None,
|
||
"can_update": False,
|
||
"can_install": False,
|
||
}
|
||
)
|
||
|
||
def check_scheduled(self) -> SystemUpdateStatus:
|
||
"""
|
||
按实时开关分别检查主程序和资源,避免热重载前的排队任务越过关闭设置。
|
||
"""
|
||
for target, setting in (
|
||
(_APPLICATION, "MOVIEPILOT_AUTO_UPDATE"),
|
||
(_RESOURCES, "AUTO_UPDATE_RESOURCE"),
|
||
):
|
||
if get_runtime_setting(setting) is True:
|
||
self.check(target)
|
||
return self.get_status()
|
||
|
||
def check(
|
||
self, target: SystemUpdateType | None = None, *, refresh_ready: bool = False
|
||
) -> SystemUpdateStatus:
|
||
"""
|
||
手动检查可刷新旧待安装目标;检查失败保留已下载制品并标明失败阶段。
|
||
"""
|
||
targets = (target,) if target else _TARGETS
|
||
for update_target in targets:
|
||
current = self.get_status()
|
||
item = self._get_item(current.model_dump(), update_target)
|
||
if item["state"] in _PENDING_STATES and not (refresh_ready and item["state"] == "ready"):
|
||
continue
|
||
ready_version = item.get("version") if item["state"] == "ready" else None
|
||
try:
|
||
checker = self._check_application if update_target == _APPLICATION else self._check_resources
|
||
if ready_version:
|
||
checker(ready_version=ready_version)
|
||
else:
|
||
checker()
|
||
# 定时检查不得打扰当前任务
|
||
except Exception as error: # noqa: BLE001
|
||
logger.warning(f"检查 {update_target} 更新失败:{error}")
|
||
message = f"检查站点资源更新失败:{error}" if update_target == _RESOURCES else str(error)
|
||
self._write_check_result(
|
||
update_target,
|
||
state="idle",
|
||
checked_at=self._now(),
|
||
error=message,
|
||
can_update=False,
|
||
can_install=False,
|
||
)
|
||
return self.get_status()
|
||
|
||
def _latest_application_release(self) -> dict[str, Any] | None:
|
||
"""
|
||
分页读取正式 V3 发布并按版本号选取最高版本,不依赖发布时间排序。
|
||
"""
|
||
release: dict[str, Any] | None = None
|
||
page = 1
|
||
while True:
|
||
response = self._request().get_res(f"{self._BACKEND_RELEASES_API}?per_page=100&page={page}")
|
||
if response is None or response.status_code != 200:
|
||
raise RuntimeError("GitHub Release 请求失败")
|
||
releases = response.json()
|
||
if not isinstance(releases, list):
|
||
raise RuntimeError("GitHub Release 列表格式无效")
|
||
for item in releases:
|
||
if (
|
||
isinstance(item, dict)
|
||
and not item.get("draft")
|
||
and not item.get("prerelease")
|
||
and self._STABLE_VERSION_PATTERN.fullmatch(str(item.get("tag_name") or ""))
|
||
and (release is None or compare_version(item["tag_name"], ">", release["tag_name"]) is True)
|
||
):
|
||
release = item
|
||
if not getattr(response, "links", {}).get("next"):
|
||
return release
|
||
page += 1
|
||
|
||
def _check_application(self, *, ready_version: str | None = None) -> None:
|
||
"""
|
||
查询最高稳定版本,可直接跨多个 V3 小版本准备完整更新包。
|
||
"""
|
||
release = self._latest_application_release()
|
||
if not release:
|
||
raise RuntimeError("未找到可用的 v3 稳定版本")
|
||
version = str(release["tag_name"])
|
||
current_version = get_app_version()
|
||
has_update = compare_version(version, "gt", current_version) is True
|
||
self._write_check_result(
|
||
_APPLICATION,
|
||
ready_version=ready_version,
|
||
state="available" if has_update else "idle",
|
||
version=version if has_update else None,
|
||
frontend_version=None,
|
||
release_name=str(release.get("name") or version) if has_update else None,
|
||
release_notes=str(release.get("body") or "") if has_update else None,
|
||
published_at=release.get("published_at") if has_update else None,
|
||
checked_at=self._now(),
|
||
downloaded_bytes=0,
|
||
total_bytes=0,
|
||
error=None,
|
||
can_update=has_update,
|
||
can_install=False,
|
||
)
|
||
if has_update:
|
||
logger.info(f"发现 MoviePilot 主程序更新:{current_version} -> {version}")
|
||
else:
|
||
logger.info(f"MoviePilot 主程序已是最新版本:{current_version}")
|
||
|
||
def _check_resources(self, *, ready_version: str | None = None) -> None:
|
||
"""
|
||
查询最新资源版本并保留清单快照,供随后下载复用同一个目标。
|
||
"""
|
||
current_auth, current_indexer = get_resource_versions()
|
||
info = ResourceHelper().get_update_info(
|
||
auth_version=current_auth,
|
||
indexer_version=current_indexer,
|
||
)
|
||
resource = info or {}
|
||
# 状态与清单快照在同一锁内发布,拒绝过期检查时也不得覆盖在途下载的快照。
|
||
with self._lock:
|
||
accepted = self._write_check_result(
|
||
_RESOURCES,
|
||
ready_version=ready_version,
|
||
state="available" if info is not None else "idle",
|
||
version=resource.get("package_version") or None,
|
||
auth_version=resource.get("auth_version"),
|
||
indexer_version=resource.get("indexer_version"),
|
||
current_auth_version=current_auth,
|
||
current_indexer_version=current_indexer,
|
||
checked_at=self._now(),
|
||
downloaded_bytes=0,
|
||
total_bytes=0,
|
||
error=None,
|
||
can_update=info is not None,
|
||
can_install=False,
|
||
)
|
||
if accepted:
|
||
self._resource_update_info = ((current_auth, current_indexer), info) if info is not None else None
|
||
|
||
def start_download(self, target: SystemUpdateTarget = _APPLICATION) -> SystemUpdateStatus:
|
||
"""
|
||
资源下载前刷新一次清单并供后台复用;合并更新保留已下载且仍为最新的包。
|
||
"""
|
||
if target not in (*_TARGETS, "all"):
|
||
raise ValueError(f"未知升级类型:{target}")
|
||
with self._lock:
|
||
state = self.get_status()
|
||
if self._download_active or any(item.state == "installing" for item in state.updates):
|
||
return state
|
||
if target == "all":
|
||
state = self.check(refresh_ready=True)
|
||
if any(item.error for item in state.updates):
|
||
return state
|
||
targets = tuple(item.type for item in state.updates if item.state == "available")
|
||
else:
|
||
item = self._get_item(state.model_dump(), target)
|
||
if target == _RESOURCES or item["state"] not in {"available", "ready"}:
|
||
state = self.check(target)
|
||
targets = tuple(item.type for item in state.updates if item.type == target and item.state == "available")
|
||
if not targets:
|
||
return state
|
||
for download_target in targets:
|
||
self._prepare_download(download_target)
|
||
self._download_active = True
|
||
self._active_target = targets[0]
|
||
try:
|
||
ThreadHelper().submit(self._download_update, target)
|
||
except RuntimeError as error:
|
||
self._download_active = False
|
||
self._active_target = None
|
||
for download_target in targets:
|
||
self._write_item(
|
||
download_target, state="failed", error=f"无法启动更新包下载:{error}",
|
||
can_update=True, can_install=False,
|
||
)
|
||
return self.get_status()
|
||
|
||
def _prepare_download(self, target: SystemUpdateType) -> None:
|
||
"""
|
||
清理单类旧制品并发布下载中状态,不触碰另一类更新。
|
||
"""
|
||
self._discard_prepared_target(target)
|
||
if target == _APPLICATION:
|
||
self._backend_archive.unlink(missing_ok=True)
|
||
self._frontend_archive.unlink(missing_ok=True)
|
||
elif self._resource_dir.exists():
|
||
for path in self._resource_dir.iterdir():
|
||
if path.is_file():
|
||
path.unlink()
|
||
self._write_item(
|
||
target, state="downloading", downloaded_bytes=0, total_bytes=0,
|
||
error=None, can_update=False, can_install=False,
|
||
)
|
||
|
||
def _install_targets(self, state: SystemUpdateStatus, target: SystemUpdateTarget) -> list[SystemUpdateType]:
|
||
"""
|
||
合并安装必须等待全部已发现更新就绪,禁止下载中提前重启或静默遗漏失败项。
|
||
"""
|
||
if self._download_active:
|
||
raise RuntimeError("更新包仍在下载,请等待全部下载完成")
|
||
items = [item for item in state.updates if target == "all" or item.type == target]
|
||
if target == "all":
|
||
if any(item.state == "idle" and item.error for item in items):
|
||
raise RuntimeError("部分更新检查失败,请重新检查最新版本")
|
||
items = [item for item in items if item.state != "idle"]
|
||
if not items or any(item.state != "ready" for item in items):
|
||
raise RuntimeError("更新包尚未全部下载完成")
|
||
return [item.type for item in items]
|
||
|
||
def request_install(self, target: SystemUpdateTarget = _APPLICATION) -> tuple[bool, str]:
|
||
"""
|
||
全部制品校验成功后原子写入一次安装意图,由宿主发起一次重启。
|
||
"""
|
||
if target not in (*_TARGETS, "all"):
|
||
return False, f"未知升级类型:{target}"
|
||
with self._lock:
|
||
temporary: Path | None = None
|
||
previous_install = self._read_install_manifest_optional()
|
||
state = self.get_status()
|
||
try:
|
||
selected = self._install_targets(state, target)
|
||
except RuntimeError as error:
|
||
return False, str(error)
|
||
validating = selected[0]
|
||
try:
|
||
prepared = self._read_prepared_manifest()
|
||
for validating in selected:
|
||
if validating == _APPLICATION and not is_exe():
|
||
self._validate_application_manifest(prepared)
|
||
elif validating == _RESOURCES:
|
||
self._validate_resource_manifest(prepared)
|
||
targets: list[SystemUpdateType] = [
|
||
cast(SystemUpdateType, value)
|
||
for value in previous_install.get("targets", [])
|
||
if value in _TARGETS
|
||
]
|
||
targets = list(dict.fromkeys([*targets, *selected]))
|
||
prepared["targets"] = targets
|
||
self._install_file.parent.mkdir(parents=True, exist_ok=True)
|
||
temporary = self._install_file.with_suffix(f".tmp.{os.getpid()}")
|
||
temporary.write_text(
|
||
json.dumps(prepared, ensure_ascii=False, indent=2), encoding="utf-8"
|
||
)
|
||
snapshot = state.model_dump()
|
||
for selected_target in selected:
|
||
self._get_item(snapshot, selected_target).update(state="installing", can_install=False, error=None)
|
||
self._persist_state(self._sync_aggregate(snapshot))
|
||
temporary.replace(self._install_file)
|
||
return True, "更新包已就绪,正在一次重启中安装所选更新"
|
||
except (OSError, RuntimeError, json.JSONDecodeError) as error:
|
||
if temporary is not None:
|
||
temporary.unlink(missing_ok=True)
|
||
# 安装意图是事务提交点;写入失败恢复两类状态,不能留下另一类安装中。
|
||
self._persist_state(state.model_dump())
|
||
self._write_item(validating, state="failed", error=str(error), can_install=False)
|
||
return False, str(error)
|
||
|
||
def apply_prepared_update(self) -> tuple[bool, str]:
|
||
"""
|
||
由 Docker root 更新 worker 将已确认制品替换到当前运行目录。
|
||
"""
|
||
if not is_docker():
|
||
return False, "当前运行环境不是 Docker"
|
||
|
||
targets: set[SystemUpdateType] = set()
|
||
with self._lock:
|
||
try:
|
||
prepared = self._read_install_manifest()
|
||
targets = self._prepared_targets(prepared)
|
||
if not targets:
|
||
raise RuntimeError("更新清单缺少可安装目标")
|
||
if _APPLICATION in targets:
|
||
self._validate_application_manifest(prepared)
|
||
version = str(prepared.get("version") or "")
|
||
frontend_version = str(prepared.get("frontend_version") or "")
|
||
if self._validate_backend_archive(version) != frontend_version:
|
||
raise RuntimeError("后端更新包声明的前端版本不匹配")
|
||
self._validate_frontend_archive(frontend_version)
|
||
if _RESOURCES in targets:
|
||
self._validate_resource_manifest(prepared)
|
||
|
||
if _APPLICATION in targets:
|
||
self._apply_docker_application(
|
||
prepared,
|
||
include_resources=_RESOURCES in targets,
|
||
)
|
||
elif _RESOURCES in targets:
|
||
self._apply_docker_resources(prepared)
|
||
|
||
try:
|
||
for target in (_APPLICATION, _RESOURCES):
|
||
if target in targets:
|
||
self._consume_prepared_target(target)
|
||
except (OSError, RuntimeError, ValueError) as error:
|
||
# 载荷已经替换成功,清单清理失败不能阻止 worker 通知入口重启。
|
||
logger.warning(f"更新载荷已替换,但清理下载清单失败:{error}")
|
||
try:
|
||
self._install_file.unlink(missing_ok=True)
|
||
except OSError as error:
|
||
logger.warning(f"清理 Docker 更新安装清单失败:{error}")
|
||
return True, "已下载的更新已替换到 Docker 程序目录"
|
||
except (OSError, RuntimeError, ValueError, zipfile.BadZipFile) as error:
|
||
message = f"Docker 更新包替换失败:{error}"
|
||
self._mark_install_failed(targets, message)
|
||
logger.error(message)
|
||
return False, message
|
||
|
||
def _read_install_manifest(self) -> dict[str, Any]:
|
||
"""
|
||
读取必须存在的 Docker 更新安装清单。
|
||
"""
|
||
payload = json.loads(self._install_file.read_text(encoding="utf-8"))
|
||
if not isinstance(payload, dict):
|
||
raise RuntimeError("更新安装清单格式无效")
|
||
return payload
|
||
|
||
@staticmethod
|
||
def _prepared_targets(prepared: dict[str, Any]) -> set[SystemUpdateType]:
|
||
"""
|
||
解析更新清单中的主程序和站点资源安装目标。
|
||
"""
|
||
raw_targets = prepared.get("targets")
|
||
if raw_targets is not None and not isinstance(raw_targets, list):
|
||
raise RuntimeError("更新清单目标格式无效")
|
||
if isinstance(raw_targets, list) and any(
|
||
not isinstance(target, str) or target not in _TARGETS
|
||
for target in raw_targets
|
||
):
|
||
raise RuntimeError("更新清单包含未知安装目标")
|
||
targets = {
|
||
cast(SystemUpdateType, target)
|
||
for target in raw_targets or []
|
||
}
|
||
if not targets and prepared.get("backend_archive"):
|
||
targets.add(_APPLICATION)
|
||
return targets
|
||
|
||
def _mark_install_failed(
|
||
self, targets: set[SystemUpdateType], message: str
|
||
) -> None:
|
||
"""
|
||
记录 Docker 更新失败并撤销本次安装意图,保留下载包供重试。
|
||
"""
|
||
try:
|
||
self._install_file.unlink(missing_ok=True)
|
||
except OSError as error:
|
||
logger.warning(f"清理 Docker 更新安装清单失败:{error}")
|
||
if not targets:
|
||
try:
|
||
state = self._read_state()
|
||
targets = {
|
||
cast(SystemUpdateType, item["type"])
|
||
for item in state.get("updates", [])
|
||
if item.get("state") == "installing" and item.get("type") in _TARGETS
|
||
}
|
||
# 失败路径只记录,不能遮蔽原始错误
|
||
except Exception as error: # noqa: BLE001
|
||
logger.warning(f"读取待安装更新状态失败:{error}")
|
||
for target in targets:
|
||
try:
|
||
self._write_item(
|
||
target,
|
||
state="failed",
|
||
error=message,
|
||
can_update=True,
|
||
can_install=False,
|
||
)
|
||
# 失败路径不得遮蔽原始安装错误
|
||
except Exception as error: # noqa: BLE001
|
||
logger.error(f"记录 {target} 更新失败状态失败:{error}")
|
||
|
||
def _consume_prepared_target(self, target: SystemUpdateType) -> None:
|
||
"""
|
||
从持久化下载清单移除已替换目标,保留另一类下载制品。
|
||
"""
|
||
prepared = self._read_prepared_manifest_optional()
|
||
if target == _APPLICATION:
|
||
for key in (
|
||
"version",
|
||
"frontend_version",
|
||
"backend_archive",
|
||
"frontend_archive",
|
||
"backend_sha256",
|
||
"frontend_sha256",
|
||
):
|
||
prepared.pop(key, None)
|
||
elif target == _RESOURCES:
|
||
for key in ("resource_package_version", "resource_files"):
|
||
prepared.pop(key, None)
|
||
else:
|
||
raise ValueError(f"未知升级类型:{target}")
|
||
|
||
targets = [
|
||
value
|
||
for value in prepared.get("targets", [])
|
||
if value in _TARGETS and value != target
|
||
]
|
||
if targets:
|
||
prepared["targets"] = targets
|
||
else:
|
||
prepared.pop("targets", None)
|
||
|
||
prepared_file = self._root / "prepared.json"
|
||
if prepared.get("backend_archive") or prepared.get("resource_files"):
|
||
temporary = prepared_file.with_suffix(f".tmp.{os.getpid()}")
|
||
temporary.write_text(
|
||
json.dumps(prepared, ensure_ascii=False, indent=2), encoding="utf-8"
|
||
)
|
||
temporary.replace(prepared_file)
|
||
else:
|
||
prepared_file.unlink(missing_ok=True)
|
||
|
||
def _set_docker_pending(self, state: str) -> None:
|
||
"""
|
||
原子写入 Docker 载荷切换状态,供入口脚本在异常重启时恢复。
|
||
"""
|
||
self._docker_pending_file.parent.mkdir(parents=True, exist_ok=True)
|
||
temporary = self._docker_pending_file.with_suffix(f".tmp.{os.getpid()}")
|
||
temporary.write_text(f"{state}\n", encoding="utf-8")
|
||
temporary.replace(self._docker_pending_file)
|
||
|
||
def _clear_docker_pending(self) -> None:
|
||
"""
|
||
清除已经提交完成的 Docker 载荷切换状态。
|
||
"""
|
||
self._docker_pending_file.unlink(missing_ok=True)
|
||
|
||
@staticmethod
|
||
def _setting_text(key: str, default: str = "") -> str:
|
||
"""
|
||
读取更新 worker 所需的运行配置,并兼容独立 root 子进程环境。
|
||
"""
|
||
try:
|
||
value = get_runtime_setting(key)
|
||
except AttributeError:
|
||
value = None
|
||
return str(value or os.getenv(key, default) or default).strip()
|
||
|
||
def _sync_docker_dependencies(self, project_dir: Path, *, force: bool = False) -> bool:
|
||
"""
|
||
按新后端清单同步依赖,自定义包源时冻结锁文件避免重新求解。
|
||
"""
|
||
current_dir = self._docker_app_dir
|
||
if not force and all(
|
||
(current_dir / name).read_bytes() == (project_dir / name).read_bytes()
|
||
for name in ("pyproject.toml", "uv.lock")
|
||
):
|
||
return False
|
||
|
||
venv_path = self._setting_text("VENV_PATH", "/opt/venv")
|
||
uv_bin = self._setting_text("UV_BIN", "/usr/local/bin/uv")
|
||
package_index = self._setting_text("PIP_PROXY")
|
||
command = [
|
||
uv_bin,
|
||
"sync",
|
||
"--project",
|
||
str(project_dir),
|
||
"--frozen" if package_index else "--locked",
|
||
"--inexact",
|
||
"--no-dev",
|
||
"--no-install-project",
|
||
"--python",
|
||
f"{venv_path}/bin/python3",
|
||
*runtime_sync_arguments(),
|
||
]
|
||
if package_index:
|
||
command.extend(("--default-index", package_index))
|
||
environment = os.environ.copy()
|
||
proxy = self._setting_text("PROXY_HOST")
|
||
if proxy:
|
||
for key in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
|
||
environment[key] = proxy
|
||
environment.update(
|
||
{
|
||
"UV_PROJECT_ENVIRONMENT": venv_path,
|
||
"UV_LINK_MODE": "copy",
|
||
}
|
||
)
|
||
try:
|
||
result = subprocess.run(
|
||
command,
|
||
cwd=str(project_dir),
|
||
env=environment,
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
)
|
||
except OSError as error:
|
||
raise RuntimeError(f"依赖同步执行失败:{error}") from error
|
||
if result.returncode != 0:
|
||
raise RuntimeError(f"依赖同步失败,退出码:{result.returncode}")
|
||
return True
|
||
|
||
def _extract_backend_archive(self, archive_path: Path, destination: Path) -> Path:
|
||
"""
|
||
安全解压后端 Release,并返回唯一的源码根目录。
|
||
"""
|
||
with zipfile.ZipFile(archive_path) as archive:
|
||
self._validate_zip_members(archive)
|
||
roots = {
|
||
PurePosixPath(name).parts[0]
|
||
for name in archive.namelist()
|
||
if PurePosixPath(name).parts
|
||
}
|
||
if len(roots) != 1:
|
||
raise RuntimeError("后端更新包源码根目录无效")
|
||
archive.extractall(destination)
|
||
source_root = destination / next(iter(roots))
|
||
if not source_root.is_dir():
|
||
raise RuntimeError("后端更新包源码目录不存在")
|
||
return source_root
|
||
|
||
def _extract_frontend_archive(self, archive_path: Path, destination: Path) -> Path:
|
||
"""
|
||
安全解压前端 dist.zip,并返回静态文件目录。
|
||
"""
|
||
with zipfile.ZipFile(archive_path) as archive:
|
||
self._validate_zip_members(archive)
|
||
archive.extractall(destination)
|
||
frontend_dir = destination / "dist"
|
||
if not frontend_dir.is_dir():
|
||
raise RuntimeError("前端更新包缺少 dist 目录")
|
||
return frontend_dir
|
||
|
||
@staticmethod
|
||
def _remove_path(path: Path) -> None:
|
||
"""
|
||
删除 Docker 更新事务中的文件或目录。
|
||
"""
|
||
if path.is_dir() and not path.is_symlink():
|
||
shutil.rmtree(path)
|
||
elif path.exists() or path.is_symlink():
|
||
path.unlink()
|
||
|
||
@staticmethod
|
||
def _preserve_tree_ownership(source: Path, destination: Path) -> None:
|
||
"""
|
||
复制运行时目录后恢复原目录的所有者,避免插件变成 root 不可写。
|
||
"""
|
||
source_paths = (source, *source.rglob("*"))
|
||
for source_path in source_paths:
|
||
destination_path = destination / source_path.relative_to(source)
|
||
source_stat = source_path.lstat()
|
||
os.chown(
|
||
destination_path,
|
||
source_stat.st_uid,
|
||
source_stat.st_gid,
|
||
follow_symlinks=False,
|
||
)
|
||
|
||
def _copy_plugin_runtime_payload(self, source: Path, destination: Path) -> None:
|
||
"""
|
||
迁移插件运行时内容,但保留新版宿主的包根入口。
|
||
|
||
``app/plugins/__init__.py`` 属于后端源码提供的兼容入口,不属于持久化插件;
|
||
旧版本该文件包含宿主实现,随插件目录迁移会遮蔽新版 SDK 的兼容符号。
|
||
"""
|
||
destination.mkdir(parents=True, exist_ok=True)
|
||
for destination_path in destination.iterdir():
|
||
if destination_path.name != "__init__.py":
|
||
self._remove_path(destination_path)
|
||
|
||
for source_path in source.iterdir():
|
||
if source_path.name == "__init__.py":
|
||
continue
|
||
destination_path = destination / source_path.name
|
||
if source_path.is_dir() and not source_path.is_symlink():
|
||
shutil.copytree(source_path, destination_path, symlinks=True)
|
||
else:
|
||
shutil.copy2(source_path, destination_path, follow_symlinks=False)
|
||
self._preserve_tree_ownership(source_path, destination_path)
|
||
|
||
@staticmethod
|
||
def _clear_staged_native_resources(resource_dir: Path) -> None:
|
||
"""
|
||
清除暂存目录中的旧平台原生站点资源。
|
||
"""
|
||
if not resource_dir.is_dir():
|
||
return
|
||
for path in resource_dir.iterdir():
|
||
if path.is_file() and path.name.startswith("sites.") and path.suffix in {
|
||
".so",
|
||
".pyd",
|
||
".dylib",
|
||
}:
|
||
path.unlink()
|
||
|
||
def _resource_source_dir(self, app_dir: Path) -> Path:
|
||
"""
|
||
定位当前后端携带的站点资源目录,并兼容历史目录。
|
||
"""
|
||
resource_dir = app_dir / "app" / "application" / "site"
|
||
for legacy_dir in (
|
||
app_dir / "app" / "infrastructure",
|
||
app_dir / "app" / "adapters" / "network",
|
||
app_dir / "app" / "helper",
|
||
):
|
||
if not resource_dir.is_dir() and legacy_dir.is_dir():
|
||
resource_dir = legacy_dir
|
||
return resource_dir
|
||
|
||
@staticmethod
|
||
def _is_site_runtime_resource(path: Path) -> bool:
|
||
"""
|
||
判断文件是否属于需要跨 Docker 应用升级继承的站点运行时资源。
|
||
"""
|
||
return path.is_file() and (
|
||
(path.name.startswith("user.sites.") and path.suffix == ".bin")
|
||
or (
|
||
path.name.startswith("sites.")
|
||
and path.suffix in {".so", ".pyd", ".dylib"}
|
||
)
|
||
)
|
||
|
||
def _copy_site_runtime_resources(
|
||
self, source_dir: Path, destination_dir: Path
|
||
) -> None:
|
||
"""
|
||
只把旧版本的站点索引和原生资源叠加到新版源码目录。
|
||
"""
|
||
if not source_dir.is_dir():
|
||
return
|
||
destination_dir.mkdir(parents=True, exist_ok=True)
|
||
for path in source_dir.iterdir():
|
||
if self._is_site_runtime_resource(path):
|
||
shutil.copy2(path, destination_dir / path.name)
|
||
|
||
def _copy_prepared_resources(
|
||
self, prepared: dict[str, Any], resource_dir: Path
|
||
) -> None:
|
||
"""
|
||
把已校验的完整站点资源包复制到指定源码目录。
|
||
"""
|
||
self._clear_staged_native_resources(resource_dir)
|
||
for item in prepared.get("resource_files", []):
|
||
name = Path(str(item.get("name") or ""))
|
||
if name.name != str(name):
|
||
raise RuntimeError("站点资源文件名不安全")
|
||
shutil.copy2(str(item["path"]), resource_dir / name)
|
||
|
||
def _prepare_docker_application(
|
||
self,
|
||
prepared: dict[str, Any],
|
||
temporary_root: Path,
|
||
*,
|
||
include_resources: bool,
|
||
) -> tuple[Path, Path]:
|
||
"""
|
||
解压并组装待切换的 Docker 后端、前端和插件资源载荷。
|
||
|
||
新版本归档是 Python 源码的唯一来源,旧版本目录只迁移插件和站点运行时内容,
|
||
不覆盖新版的插件包根兼容入口或新增应用模块。
|
||
"""
|
||
backend_extract = temporary_root / "backend"
|
||
frontend_extract = temporary_root / "frontend"
|
||
backend_extract.mkdir()
|
||
frontend_extract.mkdir()
|
||
source_app = self._extract_backend_archive(
|
||
Path(str(prepared["backend_archive"])), backend_extract
|
||
)
|
||
source_public = self._extract_frontend_archive(
|
||
Path(str(prepared["frontend_archive"])), frontend_extract
|
||
)
|
||
stage_app = temporary_root / "App"
|
||
stage_public = temporary_root / "public"
|
||
source_app.replace(stage_app)
|
||
source_public.replace(stage_public)
|
||
|
||
current_app = self._docker_app_dir
|
||
current_plugins = current_app / "app" / "plugins"
|
||
stage_plugins = stage_app / "app" / "plugins"
|
||
if stage_plugins.is_symlink():
|
||
self._remove_path(stage_plugins)
|
||
if stage_plugins.exists() and not stage_plugins.is_dir():
|
||
raise RuntimeError("插件运行目录不是目录")
|
||
stage_plugins.mkdir(parents=True, exist_ok=True)
|
||
if current_plugins.is_dir():
|
||
self._copy_plugin_runtime_payload(current_plugins, stage_plugins)
|
||
if not (stage_plugins / "__init__.py").is_file():
|
||
raise RuntimeError("插件运行目录缺少 app.plugins 兼容入口")
|
||
|
||
stage_resources = stage_app / "app" / "application" / "site"
|
||
current_resources = self._resource_source_dir(current_app)
|
||
self._copy_site_runtime_resources(current_resources, stage_resources)
|
||
if include_resources:
|
||
self._copy_prepared_resources(prepared, stage_resources)
|
||
return stage_app, stage_public
|
||
|
||
def _restore_docker_payload(self) -> None:
|
||
"""
|
||
在 Docker 载荷切换失败时恢复更新前的源码和前端目录。
|
||
"""
|
||
for current, previous in (
|
||
(self._docker_app_dir, self._docker_previous_app_dir),
|
||
(self._docker_public_dir, self._docker_previous_public_dir),
|
||
):
|
||
if not previous.exists():
|
||
continue
|
||
if current.exists() or current.is_symlink():
|
||
self._remove_path(current)
|
||
previous.replace(current)
|
||
|
||
def _backup_docker_current(
|
||
self, current: Path, previous: Path, temporary_root: Path
|
||
) -> None:
|
||
"""
|
||
将 Docker 当前目录移入回滚备份,并兼容 OverlayFS 的目录重命名限制。
|
||
|
||
OverlayFS 的镜像层目录可能无法直接 ``rename`` 到 upper 层。此时先完整复制
|
||
到更新事务的临时目录,复制成功后再把临时备份提升为正式回滚目录,避免将未
|
||
完成的备份交给回滚逻辑。
|
||
"""
|
||
try:
|
||
current.replace(previous)
|
||
return
|
||
except OSError as error:
|
||
if error.errno != errno.EXDEV:
|
||
raise
|
||
|
||
temporary_previous = temporary_root / f"{current.name}.__update_previous__"
|
||
shutil.copytree(current, temporary_previous, symlinks=True)
|
||
self._preserve_tree_ownership(current, temporary_previous)
|
||
temporary_previous.replace(previous)
|
||
self._remove_path(current)
|
||
|
||
def _apply_docker_application(
|
||
self, prepared: dict[str, Any], *, include_resources: bool
|
||
) -> None:
|
||
"""
|
||
Docker 事务替换后端源码和前端静态目录,并同步依赖。
|
||
"""
|
||
app_dir = self._docker_app_dir
|
||
public_dir = self._docker_public_dir
|
||
previous_app = self._docker_previous_app_dir
|
||
previous_public = self._docker_previous_public_dir
|
||
if not app_dir.is_dir() or not public_dir.is_dir():
|
||
raise RuntimeError("Docker 当前程序目录不完整")
|
||
if previous_app.exists() or previous_public.exists():
|
||
raise RuntimeError("存在未完成的 Docker 更新事务")
|
||
|
||
with TemporaryDirectory(prefix=".moviepilot-update-", dir=str(app_dir.parent)) as temp:
|
||
temporary_root = Path(temp)
|
||
stage_app, stage_public = self._prepare_docker_application(
|
||
prepared,
|
||
temporary_root,
|
||
include_resources=include_resources,
|
||
)
|
||
dependencies_changed = any(
|
||
(app_dir / name).read_bytes() != (stage_app / name).read_bytes()
|
||
for name in ("pyproject.toml", "uv.lock")
|
||
)
|
||
dependency_sync_started = False
|
||
self._set_docker_pending("prepared")
|
||
try:
|
||
if dependencies_changed:
|
||
self._set_docker_pending("dependencies")
|
||
dependency_sync_started = True
|
||
self._sync_docker_dependencies(stage_app)
|
||
self._set_docker_pending("prepared")
|
||
self._backup_docker_current(app_dir, previous_app, temporary_root)
|
||
try:
|
||
self._backup_docker_current(
|
||
public_dir, previous_public, temporary_root
|
||
)
|
||
stage_app.replace(app_dir)
|
||
stage_public.replace(public_dir)
|
||
except OSError:
|
||
self._restore_docker_payload()
|
||
raise
|
||
self._set_docker_pending("committed")
|
||
except Exception:
|
||
rollback_failed = False
|
||
try:
|
||
self._restore_docker_payload()
|
||
except OSError as error:
|
||
logger.error(f"Docker 更新回滚失败:{error}")
|
||
rollback_failed = True
|
||
if dependency_sync_started:
|
||
try:
|
||
self._set_docker_pending("dependencies")
|
||
self._sync_docker_dependencies(app_dir, force=True)
|
||
except (OSError, RuntimeError) as error:
|
||
logger.error(f"Docker 更新依赖回滚失败:{error}")
|
||
rollback_failed = True
|
||
if rollback_failed:
|
||
raise
|
||
try:
|
||
self._clear_docker_pending()
|
||
except OSError as error:
|
||
logger.error(f"清理 Docker 更新事务标记失败:{error}")
|
||
raise
|
||
raise
|
||
|
||
try:
|
||
self._remove_path(previous_app)
|
||
self._remove_path(previous_public)
|
||
self._clear_docker_pending()
|
||
except OSError as error:
|
||
logger.warning(f"Docker 更新已完成但旧载荷清理失败:{error}")
|
||
|
||
def _apply_docker_resources(self, prepared: dict[str, Any]) -> None:
|
||
"""
|
||
备份并替换 Docker 站点资源,失败时恢复旧目录。
|
||
"""
|
||
resource_dir = self._resource_source_dir(self._docker_app_dir)
|
||
resource_dir.parent.mkdir(parents=True, exist_ok=True)
|
||
with TemporaryDirectory(
|
||
prefix=".moviepilot-resource-update-", dir=str(resource_dir.parent)
|
||
) as temp:
|
||
stage_dir = Path(temp) / "site"
|
||
if resource_dir.is_dir():
|
||
shutil.copytree(resource_dir, stage_dir, symlinks=True)
|
||
else:
|
||
stage_dir.mkdir()
|
||
self._copy_prepared_resources(prepared, stage_dir)
|
||
backup_dir = resource_dir.with_name(f"{resource_dir.name}.__prepared_previous__")
|
||
self._remove_path(backup_dir)
|
||
if resource_dir.exists() or resource_dir.is_symlink():
|
||
# OverlayFS 下镜像层目录不能直接重命名,先复制完整备份再移除。
|
||
shutil.copytree(resource_dir, backup_dir, symlinks=True)
|
||
try:
|
||
self._remove_path(resource_dir)
|
||
stage_dir.replace(resource_dir)
|
||
except OSError:
|
||
if backup_dir.exists():
|
||
self._remove_path(resource_dir)
|
||
backup_dir.replace(resource_dir)
|
||
raise
|
||
self._remove_path(backup_dir)
|
||
|
||
def cancel_install(self, reason: str) -> None:
|
||
"""
|
||
重启请求失败时撤销全部已选安装意图,避免下次普通启动意外安装。
|
||
"""
|
||
with self._lock:
|
||
targets: list[SystemUpdateType] = []
|
||
try:
|
||
payload = json.loads(self._install_file.read_text(encoding="utf-8"))
|
||
targets = [cast(SystemUpdateType, target) for target in payload.get("targets", []) if target in _TARGETS]
|
||
except (OSError, json.JSONDecodeError):
|
||
pass
|
||
self._install_file.unlink(missing_ok=True)
|
||
state = self._read_state()
|
||
for target in targets or _TARGETS:
|
||
item = self._get_item(state, target)
|
||
if item.get("state") == "installing":
|
||
item.update(
|
||
{
|
||
"state": "ready",
|
||
"error": reason,
|
||
"can_update": False,
|
||
"can_install": True,
|
||
}
|
||
)
|
||
self._persist_state(self._sync_aggregate(state))
|
||
|
||
def _download_update(self, target: SystemUpdateTarget) -> None:
|
||
"""
|
||
在同一个后台线程准备两类制品,单类失败不丢弃另一类成功的下载包。
|
||
"""
|
||
try:
|
||
targets = (
|
||
[item["type"] for item in self._read_state()["updates"] if item["state"] == "downloading"]
|
||
if target == "all" else [target]
|
||
)
|
||
for download_target in targets:
|
||
with self._lock:
|
||
self._active_target = download_target
|
||
self._download_target(download_target)
|
||
finally:
|
||
with self._lock:
|
||
self._download_active = False
|
||
self._active_target = None
|
||
|
||
def _download_target(self, target: SystemUpdateType) -> None:
|
||
"""
|
||
将单类后台下载错误沉淀为独立失败状态,供合并更新重试。
|
||
"""
|
||
try:
|
||
if target == _APPLICATION:
|
||
downloader = self._simulate_application_download if is_exe() else self._download_application
|
||
else:
|
||
downloader = self._download_resources
|
||
downloader()
|
||
# 后台线程必须沉淀为可查询失败
|
||
except Exception as error: # noqa: BLE001
|
||
logger.error(f"下载 {target} 更新包失败:{error}")
|
||
message = f"下载站点资源更新包失败:{error}" if target == _RESOURCES else str(error)
|
||
self._write_item(
|
||
target, state="failed", error=message,
|
||
can_update=True, can_install=False,
|
||
)
|
||
|
||
def _simulate_application_download(self) -> None:
|
||
"""
|
||
exe 部署下模拟主程序下载,直接写入准备清单并反馈完成。
|
||
"""
|
||
if get_runtime_setting("MOVIEPILOT_AUTO_UPDATE") is not True:
|
||
raise RuntimeError("请先在高级设置里启用自动检查版本更新")
|
||
target_item = self._get_item(self._read_state(), _APPLICATION)
|
||
version = str(target_item.get("version") or "")
|
||
if not version:
|
||
raise RuntimeError("主程序更新缺少目标版本")
|
||
self._merge_prepared_manifest(
|
||
{
|
||
"version": version,
|
||
"frontend_version": version,
|
||
"backend_archive": str(self._backend_archive),
|
||
"frontend_archive": str(self._frontend_archive),
|
||
"backend_sha256": "",
|
||
"frontend_sha256": "",
|
||
"prepared_at": self._now(),
|
||
},
|
||
)
|
||
self._write_item(
|
||
_APPLICATION,
|
||
state="ready",
|
||
downloaded_bytes=100,
|
||
total_bytes=100,
|
||
error=None,
|
||
can_update=False,
|
||
can_install=True,
|
||
)
|
||
|
||
def _download_application(self) -> None:
|
||
"""
|
||
下载后端 Release 和其 version.py 声明的前端 dist.zip。
|
||
"""
|
||
target = self._get_item(self._read_state(), _APPLICATION)
|
||
version = str(target.get("version") or "")
|
||
if not version:
|
||
raise RuntimeError("主程序更新缺少目标版本")
|
||
downloaded, backend_total = self._download_file(
|
||
self._proxied(self._BACKEND_ARCHIVE_URL.format(tag=version)),
|
||
self._backend_archive,
|
||
0,
|
||
0,
|
||
)
|
||
frontend_version = self._validate_backend_archive(version)
|
||
# 先于前端下载判断镜像系统基线,避免不满足要求时继续占用带宽或进入可安装状态。
|
||
self._check_system_baseline()
|
||
frontend_release = self._fetch_frontend_release(frontend_version)
|
||
frontend_asset = next(
|
||
(
|
||
item
|
||
for item in frontend_release.get("assets") or []
|
||
if item.get("name") == "dist.zip" and item.get("browser_download_url")
|
||
),
|
||
None,
|
||
)
|
||
if not frontend_asset:
|
||
raise RuntimeError(f"前端 {frontend_version} 缺少 dist.zip 发布资产")
|
||
frontend_total = int(frontend_asset.get("size") or 0)
|
||
total = backend_total + frontend_total
|
||
self._write_item(
|
||
_APPLICATION,
|
||
frontend_version=frontend_version,
|
||
downloaded_bytes=downloaded,
|
||
total_bytes=total,
|
||
)
|
||
downloaded, _ = self._download_file(
|
||
self._proxied(str(frontend_asset["browser_download_url"])),
|
||
self._frontend_archive,
|
||
downloaded,
|
||
total,
|
||
)
|
||
self._validate_frontend_archive(frontend_version)
|
||
expected_digest = str(frontend_asset.get("digest") or "")
|
||
frontend_sha256 = self._sha256(self._frontend_archive)
|
||
if expected_digest.startswith("sha256:") and frontend_sha256 != expected_digest.removeprefix("sha256:"):
|
||
raise RuntimeError("前端更新包与 GitHub Release 摘要不一致")
|
||
if not is_docker():
|
||
self._prepare_local_backend_ref(version)
|
||
|
||
self._merge_prepared_manifest(
|
||
{
|
||
"version": version,
|
||
"frontend_version": frontend_version,
|
||
"backend_archive": str(self._backend_archive),
|
||
"frontend_archive": str(self._frontend_archive),
|
||
"backend_sha256": self._sha256(self._backend_archive),
|
||
"frontend_sha256": frontend_sha256,
|
||
"prepared_at": self._now(),
|
||
},
|
||
)
|
||
self._write_item(
|
||
_APPLICATION,
|
||
state="ready",
|
||
downloaded_bytes=downloaded,
|
||
total_bytes=max(total, downloaded),
|
||
error=None,
|
||
can_update=False,
|
||
can_install=True,
|
||
)
|
||
logger.info(f"MoviePilot {version} 更新包已下载完成,等待用户确认重启")
|
||
|
||
def _download_resources(self) -> None:
|
||
"""
|
||
复用与当前状态匹配的检查快照下载资源;无匹配快照时才重读清单。
|
||
"""
|
||
current_auth, current_indexer = get_resource_versions()
|
||
helper = ResourceHelper()
|
||
cached = getattr(self, "_resource_update_info", None)
|
||
target = self._get_item(self._read_state(), _RESOURCES)
|
||
if cached and cached[0] == (current_auth, current_indexer) and all(
|
||
cached[1].get(info_key) == target.get(state_key)
|
||
for info_key, state_key in (
|
||
("package_version", "version"), ("auth_version", "auth_version"),
|
||
("indexer_version", "indexer_version"),
|
||
)
|
||
):
|
||
info = cached[1]
|
||
else:
|
||
info = helper.get_update_info(
|
||
auth_version=current_auth,
|
||
indexer_version=current_indexer,
|
||
)
|
||
if info is None:
|
||
raise RuntimeError("站点资源已是最新版本")
|
||
files = helper.get_download_files(info)
|
||
total = sum(int(item.get("size") or 0) for item in files)
|
||
self._write_item(_RESOURCES, total_bytes=total)
|
||
downloaded = 0
|
||
|
||
def on_progress(delta: int, _file_total: int) -> None:
|
||
"""
|
||
把资源文件下载进度映射到统一状态。
|
||
"""
|
||
nonlocal downloaded
|
||
downloaded += delta
|
||
self._write_item(
|
||
_RESOURCES,
|
||
downloaded_bytes=downloaded,
|
||
total_bytes=total,
|
||
)
|
||
|
||
prepared_files = helper.download_files(files, self._resource_dir, on_progress)
|
||
self._merge_prepared_manifest(
|
||
{
|
||
"resource_package_version": info.get("package_version"),
|
||
"resource_files": prepared_files,
|
||
"prepared_at": self._now(),
|
||
},
|
||
)
|
||
self._write_item(
|
||
_RESOURCES,
|
||
state="ready",
|
||
version=info.get("package_version") or None,
|
||
auth_version=info.get("auth_version"),
|
||
indexer_version=info.get("indexer_version"),
|
||
current_auth_version=current_auth,
|
||
current_indexer_version=current_indexer,
|
||
downloaded_bytes=downloaded,
|
||
total_bytes=max(total, downloaded),
|
||
error=None,
|
||
can_update=False,
|
||
can_install=True,
|
||
)
|
||
logger.info("站点资源包已下载完成,等待用户确认重启")
|
||
|
||
def _merge_prepared_manifest(
|
||
self,
|
||
changes: dict[str, Any],
|
||
*,
|
||
remove_keys: set[str] | None = None,
|
||
) -> None:
|
||
"""
|
||
合并不同升级类型的已下载制品,避免覆盖另一类待安装包。
|
||
"""
|
||
prepared = self._read_prepared_manifest_optional()
|
||
for key in remove_keys or set():
|
||
prepared.pop(key, None)
|
||
prepared.update(changes)
|
||
self._root.mkdir(parents=True, exist_ok=True)
|
||
(self._root / "prepared.json").write_text(
|
||
json.dumps(prepared, ensure_ascii=False, indent=2), encoding="utf-8"
|
||
)
|
||
|
||
def _discard_prepared_target(self, target: SystemUpdateType) -> None:
|
||
"""
|
||
重新下载一类制品时只清理该类型的旧清单,保留另一类制品。
|
||
"""
|
||
prepared = self._read_prepared_manifest_optional()
|
||
if target == _APPLICATION:
|
||
for key in (
|
||
"version",
|
||
"frontend_version",
|
||
"backend_archive",
|
||
"frontend_archive",
|
||
"backend_sha256",
|
||
"frontend_sha256",
|
||
):
|
||
prepared.pop(key, None)
|
||
else:
|
||
for key in ("resource_package_version", "resource_files"):
|
||
prepared.pop(key, None)
|
||
targets = [
|
||
value
|
||
for value in prepared.get("targets", [])
|
||
if value in _TARGETS and value != target
|
||
]
|
||
if targets:
|
||
prepared["targets"] = targets
|
||
else:
|
||
prepared.pop("targets", None)
|
||
if prepared:
|
||
(self._root / "prepared.json").write_text(
|
||
json.dumps(prepared, ensure_ascii=False, indent=2), encoding="utf-8"
|
||
)
|
||
else:
|
||
(self._root / "prepared.json").unlink(missing_ok=True)
|
||
|
||
def _read_prepared_manifest_optional(self) -> dict[str, Any]:
|
||
"""
|
||
读取可选的已下载制品清单。
|
||
"""
|
||
try:
|
||
payload = json.loads((self._root / "prepared.json").read_text(encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError):
|
||
return {}
|
||
return payload if isinstance(payload, dict) else {}
|
||
|
||
def _read_prepared_manifest(self) -> dict[str, Any]:
|
||
"""
|
||
读取必须存在的已下载制品清单。
|
||
"""
|
||
payload = json.loads((self._root / "prepared.json").read_text(encoding="utf-8"))
|
||
if not isinstance(payload, dict):
|
||
raise RuntimeError("更新包清单格式无效")
|
||
return payload
|
||
|
||
def _read_install_manifest_optional(self) -> dict[str, Any]:
|
||
"""
|
||
读取当前重启请求,避免把历史安装目标重新加入本次请求。
|
||
"""
|
||
try:
|
||
payload = json.loads(self._install_file.read_text(encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError):
|
||
return {}
|
||
return payload if isinstance(payload, dict) else {}
|
||
|
||
def _validate_application_manifest(self, prepared: dict[str, Any]) -> None:
|
||
"""
|
||
校验主程序后端包和配套前端包的路径、摘要与版本。
|
||
"""
|
||
backend_archive = Path(str(prepared.get("backend_archive") or ""))
|
||
frontend_archive = Path(str(prepared.get("frontend_archive") or ""))
|
||
if not prepared.get("version") or not prepared.get("frontend_version"):
|
||
raise RuntimeError("主程序更新清单缺少版本信息")
|
||
for archive, expected in (
|
||
(backend_archive, self._backend_archive),
|
||
(frontend_archive, self._frontend_archive),
|
||
):
|
||
if archive.resolve() != expected.resolve():
|
||
raise RuntimeError("主程序更新包路径不安全")
|
||
if not backend_archive.is_file() or self._sha256(backend_archive) != prepared.get("backend_sha256"):
|
||
raise RuntimeError("后端更新包校验失败")
|
||
if not frontend_archive.is_file() or self._sha256(frontend_archive) != prepared.get("frontend_sha256"):
|
||
raise RuntimeError("前端更新包校验失败")
|
||
|
||
def _validate_resource_manifest(self, prepared: dict[str, Any]) -> None:
|
||
"""
|
||
校验完整资源文件只能来自更新暂存目录且摘要匹配。
|
||
"""
|
||
files = prepared.get("resource_files")
|
||
if not isinstance(files, list) or not files:
|
||
raise RuntimeError("站点资源更新清单为空")
|
||
expected_names = set(ResourceHelper._get_needed_files())
|
||
actual_names = {
|
||
str(item.get("name") or "") for item in files if isinstance(item, dict)
|
||
}
|
||
if len(files) != len(expected_names) or actual_names != expected_names:
|
||
raise RuntimeError("站点资源更新清单不是当前平台的完整资源包")
|
||
root = self._root.resolve()
|
||
for item in files:
|
||
if not isinstance(item, dict):
|
||
raise RuntimeError("站点资源更新清单格式无效")
|
||
path = Path(str(item.get("path") or ""))
|
||
try:
|
||
path.resolve().relative_to(root)
|
||
except ValueError as error:
|
||
raise RuntimeError("站点资源文件路径不安全") from error
|
||
if not path.is_file() or self._sha256(path) != item.get("sha256"):
|
||
raise RuntimeError(f"站点资源文件校验失败:{item.get('name')}")
|
||
|
||
def _request(self) -> RequestUtils:
|
||
"""
|
||
创建访问 GitHub Release API 的认证请求客户端。
|
||
"""
|
||
return RequestUtils(
|
||
proxies=get_runtime_setting("PROXY"),
|
||
headers=get_runtime_setting("GITHUB_HEADERS"),
|
||
timeout=60,
|
||
)
|
||
|
||
def _download_request(self) -> RequestUtils:
|
||
"""
|
||
创建公开 GitHub 归档下载客户端,避免认证头传递到 codeload。
|
||
"""
|
||
return RequestUtils(
|
||
proxies=get_runtime_setting("PROXY"),
|
||
timeout=60,
|
||
)
|
||
|
||
def _download_file(
|
||
self, url: str, destination: Path, downloaded_before: int, total_hint: int
|
||
) -> tuple[int, int]:
|
||
"""
|
||
使用公开下载客户端流式下载文件并把进度写入当前升级类型。
|
||
"""
|
||
temporary = destination.with_suffix(".part")
|
||
temporary.unlink(missing_ok=True)
|
||
with self._download_request().get_stream(url) as response:
|
||
if response is None or response.status_code != 200:
|
||
raise RuntimeError(
|
||
f"下载更新包失败:HTTP {getattr(response, 'status_code', '无响应')}"
|
||
)
|
||
content_length = int(response.headers.get("content-length") or 0)
|
||
total = total_hint or content_length
|
||
current = downloaded_before
|
||
with temporary.open("wb") as output:
|
||
for chunk in response.iter_content(chunk_size=256 * 1024):
|
||
if not chunk:
|
||
continue
|
||
output.write(chunk)
|
||
current += len(chunk)
|
||
self._write_item(
|
||
self._active_target or _APPLICATION,
|
||
downloaded_bytes=current,
|
||
total_bytes=total,
|
||
)
|
||
temporary.replace(destination)
|
||
return current, content_length
|
||
|
||
def _fetch_frontend_release(self, version: str) -> dict[str, Any]:
|
||
"""
|
||
读取后端版本声明对应的前端 Release。
|
||
"""
|
||
response = self._request().get_res(self._FRONTEND_RELEASE_API.format(tag=version))
|
||
if response is None or response.status_code != 200:
|
||
raise RuntimeError(f"无法获取前端 {version} Release")
|
||
payload = response.json()
|
||
if not isinstance(payload, dict):
|
||
raise RuntimeError("前端 Release 返回格式异常")
|
||
return payload
|
||
|
||
def _validate_backend_archive(self, version: str) -> str:
|
||
"""
|
||
校验后端压缩包结构,并只读取其声明的前端版本。
|
||
"""
|
||
with zipfile.ZipFile(self._backend_archive) as archive:
|
||
self._validate_zip_members(archive)
|
||
version_name = next(
|
||
(
|
||
name
|
||
for name in archive.namelist()
|
||
if name.count("/") == 1 and name.endswith("/version.py")
|
||
),
|
||
None,
|
||
)
|
||
if not version_name:
|
||
raise RuntimeError("后端更新包缺少 version.py")
|
||
version_source = archive.read(version_name).decode("utf-8")
|
||
app_match = re.search(r"^APP_VERSION\s*=\s*['\"]([^'\"]+)", version_source, re.M)
|
||
frontend_match = re.search(r"^FRONTEND_VERSION\s*=\s*['\"]([^'\"]+)", version_source, re.M)
|
||
if not app_match or app_match.group(1) != version:
|
||
raise RuntimeError("后端更新包版本与目标 Release 不一致")
|
||
if not frontend_match or not self._VERSION_PATTERN.fullmatch(frontend_match.group(1)):
|
||
raise RuntimeError("后端更新包声明的前端版本无效")
|
||
required = ("pyproject.toml", "uv.lock")
|
||
names = archive.namelist()
|
||
if any(not any(name.endswith(f"/{item}") for name in names) for item in required):
|
||
raise RuntimeError("后端更新包缺少依赖锁定文件")
|
||
return frontend_match.group(1)
|
||
|
||
def _check_system_baseline(self) -> None:
|
||
"""
|
||
拒绝把要求更高系统基线的版本安装到旧 Docker 镜像。
|
||
|
||
仅 Docker 部署执行:系统组件随镜像发布,应用内更新无法替换;本地源码与 exe 部署跳过。
|
||
目标版本未声明 ``MIN_SYSTEM_BASELINE`` 时视为无要求,声明格式无效时按失败处理。
|
||
|
||
:raises RuntimeError: 目标版本声明无效,或当前镜像系统基线低于目标要求
|
||
"""
|
||
if not is_docker():
|
||
return
|
||
required = self._backend_min_system_baseline()
|
||
if required is None:
|
||
return
|
||
current = self._image_system_baseline()
|
||
compared = current or self._LEGACY_SYSTEM_BASELINE
|
||
# 基线均已校验为 YYYY-MM-DD,字符串顺序与日期顺序一致。
|
||
if required > compared:
|
||
raise RuntimeError(
|
||
"该版本包含系统组件安全更新,需要先更新 Docker 镜像"
|
||
f"(要求系统基线 {required},当前 {current or '未标记'})"
|
||
)
|
||
|
||
def _backend_min_system_baseline(self) -> str | None:
|
||
"""
|
||
读取已下载后端归档 version.py 中声明的最低系统基线。
|
||
|
||
:return: 规范化的 ISO 日期;旧版本未声明时返回 ``None``
|
||
:raises RuntimeError: 声明存在但不是有效 ISO 日期
|
||
"""
|
||
with zipfile.ZipFile(self._backend_archive) as archive:
|
||
version_name = next(
|
||
name
|
||
for name in archive.namelist()
|
||
if name.count("/") == 1 and name.endswith("/version.py")
|
||
)
|
||
version_source = archive.read(version_name).decode("utf-8")
|
||
match = re.search(r"^MIN_SYSTEM_BASELINE\s*=\s*['\"]([^'\"]*)['\"]", version_source, re.M)
|
||
if not match:
|
||
return None
|
||
required = self._parse_system_baseline(match.group(1))
|
||
if required is None:
|
||
raise RuntimeError(f"目标版本声明的系统基线无效:{match.group(1)}")
|
||
return required
|
||
|
||
def _image_system_baseline(self) -> str | None:
|
||
"""
|
||
读取当前镜像构建时写入的系统基线。
|
||
|
||
:return: 规范化的 ISO 日期;旧镜像缺少文件或内容无效时返回 ``None``,按最旧基线处理
|
||
"""
|
||
try:
|
||
raw = self._SYSTEM_BASELINE_FILE.read_text(encoding="utf-8")
|
||
except OSError:
|
||
return None
|
||
baseline = self._parse_system_baseline(raw)
|
||
if baseline is None:
|
||
logger.warning(f"镜像系统基线文件内容无效,按旧镜像处理:{raw.strip()[:32]}")
|
||
return baseline
|
||
|
||
@classmethod
|
||
def _parse_system_baseline(cls, value: str) -> str | None:
|
||
"""
|
||
校验系统基线为真实存在的 YYYY-MM-DD 日期。
|
||
|
||
:param value: 原始基线文本
|
||
:return: 去除空白后的基线;格式或日期无效时返回 ``None``
|
||
"""
|
||
text = value.strip()
|
||
if not cls._SYSTEM_BASELINE_PATTERN.fullmatch(text):
|
||
return None
|
||
try:
|
||
date.fromisoformat(text)
|
||
except ValueError:
|
||
return None
|
||
return text
|
||
|
||
def _validate_frontend_archive(self, version: str) -> None:
|
||
"""
|
||
校验前端包只包含可接受的发布目录和匹配版本。
|
||
"""
|
||
with zipfile.ZipFile(self._frontend_archive) as archive:
|
||
self._validate_zip_members(archive)
|
||
names = set(archive.namelist())
|
||
if "dist/index.html" not in names or "dist/version.txt" not in names:
|
||
raise RuntimeError("前端更新包结构无效")
|
||
archived_version = archive.read("dist/version.txt").decode("utf-8").strip()
|
||
if archived_version != version:
|
||
raise RuntimeError("前端更新包版本与后端声明不一致")
|
||
|
||
@staticmethod
|
||
def _validate_zip_members(archive: zipfile.ZipFile) -> None:
|
||
"""
|
||
拒绝绝对路径、目录穿越和符号链接成员。
|
||
"""
|
||
for item in archive.infolist():
|
||
path = PurePosixPath(item.filename)
|
||
file_type = (item.external_attr >> 16) & 0o170000
|
||
if path.is_absolute() or ".." in path.parts or file_type == stat.S_IFLNK:
|
||
raise RuntimeError("更新包包含不安全路径")
|
||
|
||
@staticmethod
|
||
def _prepare_local_backend_ref(version: str) -> None:
|
||
"""
|
||
本地 CLI 在下载阶段获取标签,使重启后的代码切换不再联网。
|
||
"""
|
||
root = Path(__file__).resolve().parents[3]
|
||
if not (root / ".git").is_dir():
|
||
raise RuntimeError("本地安装目录不是 Git 仓库,无法准备 Release 更新")
|
||
try:
|
||
worktree = subprocess.run(
|
||
["git", "status", "--porcelain", "--untracked-files=no"],
|
||
cwd=root,
|
||
check=True,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=30,
|
||
)
|
||
if worktree.stdout.strip():
|
||
raise RuntimeError("本地源码存在未提交改动,无法准备 Release 更新")
|
||
subprocess.run(
|
||
["git", "fetch", "--no-tags", "origin", "tag", version],
|
||
cwd=root,
|
||
check=True,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=180,
|
||
)
|
||
subprocess.run(
|
||
["git", "rev-parse", "--verify", f"{version}^{{commit}}"],
|
||
cwd=root,
|
||
check=True,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=30,
|
||
)
|
||
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
|
||
raise RuntimeError(f"无法准备本地 Release 标签 {version}") from error
|
||
|
||
@staticmethod
|
||
def _sha256(path: Path) -> str:
|
||
"""
|
||
计算文件 SHA256。
|
||
"""
|
||
digest = hashlib.sha256()
|
||
with path.open("rb") as file_handle:
|
||
for chunk in iter(lambda: file_handle.read(1024 * 1024), b""):
|
||
digest.update(chunk)
|
||
return digest.hexdigest()
|
||
|
||
@staticmethod
|
||
def _proxied(url: str) -> str:
|
||
"""
|
||
规范化 GitHub 代理前缀,避免缺少尾斜杠时拼出无效主机名。
|
||
"""
|
||
proxy = str(get_runtime_setting("GITHUB_PROXY") or "").strip()
|
||
return f"{UrlUtils.standardize_base_url(proxy)}{url}" if proxy else url
|
||
|
||
|
||
system_update_manager = SystemUpdateManager()
|