diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 64f45e93a..ab4a11879 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,6 +71,15 @@ jobs: echo "${{ steps.tag.outputs.tag }}" > dist/assets/version zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist + - name: Build core package + shell: bash + run: | + git archive \ + --format=zip \ + --prefix="AstrBot-${{ steps.tag.outputs.tag }}/" \ + --output="AstrBot-${{ steps.tag.outputs.tag }}-core.zip" \ + HEAD + - name: Upload dashboard artifact uses: actions/upload-artifact@v7 with: @@ -78,11 +87,12 @@ jobs: if-no-files-found: error path: dashboard/AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip - - name: Upload dashboard package to Cloudflare R2 + - name: Upload release packages to Cloudflare R2 if: ${{ env.R2_ACCOUNT_ID != '' && env.R2_ACCESS_KEY_ID != '' && env.R2_SECRET_ACCESS_KEY != '' }} env: R2_BUCKET_NAME: "astrbot" - R2_OBJECT_NAME: "astrbot-webui-latest.zip" + DASHBOARD_LATEST_OBJECT_NAME: "astrbot-webui-latest.zip" + CORE_LATEST_OBJECT_NAME: "astrbot-core-latest.zip" VERSION_TAG: ${{ steps.tag.outputs.tag }} shell: bash run: | @@ -98,11 +108,18 @@ jobs: endpoint = https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com EOF - cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/${R2_OBJECT_NAME}" - rclone copy "dashboard/${R2_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress + cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/${DASHBOARD_LATEST_OBJECT_NAME}" + rclone copy "dashboard/${DASHBOARD_LATEST_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip" rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress + cp "AstrBot-${VERSION_TAG}-core.zip" "${CORE_LATEST_OBJECT_NAME}" + rclone copy "${CORE_LATEST_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress + cp "AstrBot-${VERSION_TAG}-core.zip" "astrbot-core-${VERSION_TAG}.zip" + rclone copy "astrbot-core-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress + rclone copyto "AstrBot-${VERSION_TAG}-core.zip" "r2:${R2_BUCKET_NAME}/astrbot-core/${VERSION_TAG}/source.zip" --progress + rclone copyto "AstrBot-${VERSION_TAG}-core.zip" "r2:${R2_BUCKET_NAME}/download/astrbot-core/${VERSION_TAG}/source.zip" --progress + publish-release: name: Publish GitHub Release if: github.repository == 'AstrBotDevs/AstrBot' diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index b60c7f230..4ba84a609 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -5,7 +5,7 @@ import os from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG from astrbot.core.utils.astrbot_path import get_astrbot_data_path -VERSION = "4.25.5" +VERSION = "4.25.6-rc.1" DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") PERSONAL_WECHAT_CONFIG_METADATA = { "weixin_oc_base_url": { diff --git a/astrbot/core/updator.py b/astrbot/core/updator.py index fab293418..3a60839f3 100644 --- a/astrbot/core/updator.py +++ b/astrbot/core/updator.py @@ -1,12 +1,15 @@ import os import sys import time +import zipfile +from pathlib import Path import psutil from astrbot.core import logger from astrbot.core.config.default import VERSION from astrbot.core.utils.astrbot_path import get_astrbot_path +from astrbot.core.utils.io import ensure_dir from .zip_updator import ReleaseInfo, RepoZipUpdator @@ -21,6 +24,30 @@ class AstrBotUpdator(RepoZipUpdator): super().__init__(repo_mirror, verify=verify) self.MAIN_PATH = get_astrbot_path() self.ASTRBOT_RELEASE_API = "https://api.soulter.top/releases" + self.CORE_PACKAGE_BASE_URL = ( + "https://astrbot-registry.soulter.top/download/astrbot-core" + ) + + def _build_core_package_url(self, version: str | None) -> str | None: + """Build the hosted core package URL for a release tag. + + Args: + version: Release tag, such as ``v4.26.6``. + + Returns: + Public package URL, or None when hosted package download is disabled. + """ + + if not version or not str(version).startswith("v"): + return None + + base_url = os.environ.get( + "ASTRBOT_CORE_PACKAGE_BASE_URL", + self.CORE_PACKAGE_BASE_URL, + ).strip() + if not base_url: + return None + return f"{base_url.rstrip('/')}/{version}/source.zip" def terminate_child_processes(self) -> None: """终止当前进程的所有子进程 @@ -151,6 +178,41 @@ class AstrBotUpdator(RepoZipUpdator): proxy="", progress_callback=None, ) -> None: + zip_path = await self.download_update_package( + latest=latest, + version=version, + proxy=proxy, + progress_callback=progress_callback, + ) + self.apply_update_package(zip_path) + + if reboot: + self._reboot() + + async def download_update_package( + self, + latest=True, + version=None, + proxy="", + path: str | Path = "temp.zip", + progress_callback=None, + ) -> Path: + """Download an AstrBot core update package without applying it. + + Args: + latest: Whether to download the latest release. + version: Specific release tag or commit hash to download. + proxy: Optional GitHub proxy prefix. + path: Destination zip path. + progress_callback: Optional callback for download progress payloads. + + Returns: + Path to the downloaded update package. + + Raises: + Exception: If update metadata cannot resolve a package URL. + """ + update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest) file_url = None @@ -159,15 +221,18 @@ class AstrBotUpdator(RepoZipUpdator): "Error: You are running AstrBot via CLI, please use `pip` or `uv tool upgrade` to update AstrBot." ) # 避免版本管理混乱 + target_version = None if latest: latest_version = update_data[0]["tag_name"] if self.compare_version(VERSION, latest_version) >= 0: raise Exception("当前已经是最新版本。") + target_version = latest_version file_url = update_data[0]["zipball_url"] elif str(version).startswith("v"): # 更新到指定版本 for data in update_data: if data["tag_name"] == version: + target_version = data["tag_name"] file_url = data["zipball_url"] if not file_url: raise Exception(f"未找到版本号为 {version} 的更新文件。") @@ -181,16 +246,49 @@ class AstrBotUpdator(RepoZipUpdator): proxy = proxy.removesuffix("/") file_url = f"{proxy}/{file_url}" - try: - await self._download_file( - file_url, - "temp.zip", - progress_callback=progress_callback, - ) - logger.info("下载 AstrBot Core 更新文件完成,正在执行解压...") - self.unzip_file("temp.zip", self.MAIN_PATH) - except BaseException as e: - raise e + zip_path = Path(path) + ensure_dir(zip_path.parent) + hosted_package_url = self._build_core_package_url(target_version) + if hosted_package_url: + try: + logger.info( + f"优先从托管存储下载 AstrBot Core 更新包: {hosted_package_url}" + ) + await self._download_file( + hosted_package_url, + str(zip_path), + progress_callback=progress_callback, + ) + if not zipfile.is_zipfile(zip_path): + raise RuntimeError( + "Downloaded hosted package is not a valid ZIP file" + ) + return zip_path + except Exception as exc: + logger.warning( + f"从托管存储下载 AstrBot Core 更新包失败: {exc}," + "将回退到当前更新源。" + ) - if reboot: - self._reboot() + await self._download_file( + file_url, + str(zip_path), + progress_callback=progress_callback, + ) + return zip_path + + def apply_update_package(self, zip_path: str | Path) -> None: + """Apply a previously downloaded AstrBot core update package. + + Args: + zip_path: Core update zip archive path. + + Returns: + None. + + Raises: + Exception: If the archive cannot be extracted or applied. + """ + + logger.info("下载 AstrBot Core 更新文件完成,正在执行解压...") + self.unzip_file(str(zip_path), self.MAIN_PATH) diff --git a/astrbot/core/utils/io.py b/astrbot/core/utils/io.py index 08c9669d1..9d05fcf2f 100644 --- a/astrbot/core/utils/io.py +++ b/astrbot/core/utils/io.py @@ -398,12 +398,27 @@ async def download_dashboard( version: str | None = None, proxy: str | None = None, progress_callback=None, + extract: bool = True, ) -> None: - """下载管理面板文件""" + """Download dashboard assets and optionally extract them. + + Args: + path: Destination zip path. Defaults to the AstrBot data directory. + extract_path: Directory where assets should be extracted. + latest: Whether to download the latest dashboard build. + version: Specific release tag or commit hash to download. + proxy: Optional download proxy prefix. + progress_callback: Optional callback for download progress payloads. + extract: Whether to extract the archive after download. + + Returns: + None. + """ if path is None: zip_path = Path(get_astrbot_data_path()).absolute() / "dashboard.zip" else: zip_path = Path(path).absolute() + ensure_dir(zip_path.parent) if latest or len(str(version)) != 40: ver_name = "latest" if latest else version @@ -456,5 +471,28 @@ async def download_dashboard( show_progress=True, progress_callback=progress_callback, ) + if extract: + extract_dashboard(zip_path, extract_path) + + +def extract_dashboard(zip_path: str | Path, extract_path: str | Path = "data") -> None: + """Extract a downloaded dashboard archive. + + Args: + zip_path: Dashboard zip archive path. + extract_path: Directory where the archive contents should be extracted. + + Returns: + None. + """ + + extract_root = Path(extract_path).resolve() + ensure_dir(extract_root) with zipfile.ZipFile(zip_path, "r") as z: - z.extractall(extract_path) + for member in z.infolist(): + target_path = (extract_root / member.filename).resolve() + if not target_path.is_relative_to(extract_root): + raise ValueError( + f"Unsafe dashboard archive path: {member.filename}", + ) + z.extract(member, extract_root) diff --git a/astrbot/dashboard/routes/update.py b/astrbot/dashboard/routes/update.py index 210eb2100..916df9881 100644 --- a/astrbot/dashboard/routes/update.py +++ b/astrbot/dashboard/routes/update.py @@ -1,5 +1,8 @@ +import asyncio import traceback import uuid +import zipfile +from pathlib import Path from quart import request @@ -8,7 +11,15 @@ from astrbot.core.config.default import VERSION from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.db.migration.helper import check_migration_needed_v4, do_migration_v4 from astrbot.core.updator import AstrBotUpdator -from astrbot.core.utils.io import download_dashboard, get_dashboard_version +from astrbot.core.utils.astrbot_path import ( + get_astrbot_data_path, + get_astrbot_system_tmp_path, +) +from astrbot.core.utils.io import ( + download_dashboard, + extract_dashboard, + get_dashboard_version, +) from .route import Response, Route, RouteContext @@ -35,6 +46,7 @@ class UpdateRoute(Route): self.astrbot_updator = astrbot_updator self.core_lifecycle = core_lifecycle self.update_progress: dict[str, dict] = {} + self._update_tasks: dict[str, asyncio.Task] = {} self.register_routes() def _init_update_progress(self, progress_id: str, version: str) -> None: @@ -198,7 +210,62 @@ class UpdateRoute(Route): if proxy: proxy = proxy.removesuffix("/") + existing_task = self._update_tasks.get(progress_id) + if existing_task and not existing_task.done(): + return ( + Response() + .ok( + {"id": progress_id, "status": "running"}, + "更新任务正在进行中。", + ) + .__dict__, + 200, + CLEAR_SITE_DATA_HEADERS, + ) + self._init_update_progress(progress_id, version) + task = asyncio.create_task( + self._run_update_project(progress_id, version, latest, reboot, proxy), + ) + self._update_tasks[progress_id] = task + task.add_done_callback(lambda _task: self._update_tasks.pop(progress_id, None)) + return ( + Response() + .ok( + {"id": progress_id, "status": "running"}, + "更新任务已开始。", + ) + .__dict__, + 200, + CLEAR_SITE_DATA_HEADERS, + ) + + async def _run_update_project( + self, + progress_id: str, + version: str, + latest: bool, + reboot: bool, + proxy: str | None, + ) -> None: + """Run an update task outside the request lifecycle. + + Args: + progress_id: Progress record id reported to the frontend. + version: Target version without the latest sentinel. + latest: Whether to install the latest release. + reboot: Whether to restart AstrBot after applying files. + proxy: Optional GitHub proxy URL. + + Returns: + None. + """ + + update_temp_dir = Path(get_astrbot_system_tmp_path()) / "updates" + update_temp_dir.mkdir(parents=True, exist_ok=True) + update_token = uuid.uuid4().hex + dashboard_zip_path = update_temp_dir / f"{update_token}-dashboard.zip" + core_zip_path = update_temp_dir / f"{update_token}-core.zip" try: self._set_update_stage( progress_id, @@ -208,15 +275,17 @@ class UpdateRoute(Route): 0, ) await download_dashboard( + path=str(dashboard_zip_path), latest=latest, version=version, - proxy=proxy, + proxy=proxy or "", progress_callback=self._make_progress_callback( progress_id, "dashboard", 0, 45, ), + extract=False, ) self._set_update_stage( progress_id, @@ -233,16 +302,19 @@ class UpdateRoute(Route): "正在下载 AstrBot 项目代码...", 45, ) - await self.astrbot_updator.update( - latest=latest, - version=version, - proxy=proxy, - progress_callback=self._make_progress_callback( - progress_id, - "core", - 45, - 45, - ), + core_zip_path = Path( + await self.astrbot_updator.download_update_package( + latest=latest, + version=version, + proxy=proxy or "", + path=core_zip_path, + progress_callback=self._make_progress_callback( + progress_id, + "core", + 45, + 45, + ), + ) ) self._set_update_stage( progress_id, @@ -252,6 +324,50 @@ class UpdateRoute(Route): 90, ) + self._set_update_stage( + progress_id, + "verify", + "running", + "下载完成,正在校验更新包...", + 90, + ) + for zip_path in (dashboard_zip_path, core_zip_path): + with zipfile.ZipFile(zip_path, "r") as archive: + corrupt_member = archive.testzip() + if corrupt_member: + raise RuntimeError(f"更新包校验失败: {corrupt_member}") + self._set_update_stage( + progress_id, + "verify", + "done", + "更新包校验完成。", + 91, + ) + + self._set_update_stage( + progress_id, + "apply", + "running", + "下载完成,正在应用更新...", + 91, + ) + await asyncio.to_thread( + self.astrbot_updator.apply_update_package, + core_zip_path, + ) + await asyncio.to_thread( + extract_dashboard, + dashboard_zip_path, + Path(get_astrbot_data_path()), + ) + self._set_update_stage( + progress_id, + "apply", + "done", + "更新文件应用完成。", + 92, + ) + # pip 更新依赖 self._set_update_stage( progress_id, @@ -290,12 +406,7 @@ class UpdateRoute(Route): "overall_percent": 100, }, ) - ret = ( - Response() - .ok(None, "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。") - .__dict__ - ) - return ret, 200, CLEAR_SITE_DATA_HEADERS + return self.update_progress[progress_id].update( { "status": "success", @@ -304,12 +415,14 @@ class UpdateRoute(Route): "overall_percent": 100, }, ) - ret = ( - Response() - .ok(None, "更新成功,AstrBot 将在下次启动时应用新的代码。") - .__dict__ + except asyncio.CancelledError: + self.update_progress[progress_id].update( + { + "status": "error", + "message": "更新任务已取消。", + }, ) - return ret, 200, CLEAR_SITE_DATA_HEADERS + logger.warning(f"Update task was cancelled: {progress_id}") except Exception as e: self.update_progress[progress_id].update( { @@ -318,7 +431,13 @@ class UpdateRoute(Route): }, ) logger.error(f"/api/update_project: {traceback.format_exc()}") - return Response().error(e.__str__()).__dict__ + finally: + for zip_path in (dashboard_zip_path, core_zip_path): + try: + if zip_path.exists(): + zip_path.unlink() + except Exception as cleanup_exc: + logger.warning(f"清理更新临时文件失败: {zip_path}, {cleanup_exc}") async def update_dashboard(self): try: diff --git a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue index c41b566e7..6b57983c8 100644 --- a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue +++ b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue @@ -616,8 +616,12 @@ function applyUpdateProgress(payload: UpdateProgress) { }, }; if (payload.status === "success" || payload.status === "error") { + installLoading.value = false; stopUpdateProgressPolling(); } + if (payload.stage === "restart") { + waitForAstrBotRestart(restartStartTime.value); + } if (payload.status === "success") { waitForAstrBotRestart(restartStartTime.value); } @@ -673,20 +677,21 @@ async function switchVersion(targetVersion: string) { }) .then((res) => { updateStatus.value = res.data.message; - updateProgress.value = { - ...updateProgress.value, - status: - res.data.status === "ok" ? "success" : updateProgress.value.status, - message: res.data.message, - overall_percent: - res.data.status === "ok" ? 100 : updateProgress.value.overall_percent, - }; - if (res.data.status == "ok") { - waitForAstrBotRestart(initialStartTime); + if (res.data.status === "error") { + stopUpdateProgressPolling(); + installLoading.value = false; + updateProgress.value = { + ...updateProgress.value, + status: "error", + message: + res.data.message || t("core.header.updateDialog.progress.failed"), + }; } }) .catch((err) => { console.log(err); + stopUpdateProgressPolling(); + installLoading.value = false; updateStatus.value = err; updateProgress.value = { ...updateProgress.value, @@ -696,10 +701,6 @@ async function switchVersion(targetVersion: string) { err?.message || t("core.header.updateDialog.progress.failed"), }; - }) - .finally(() => { - installLoading.value = false; - stopUpdateProgressPolling(); }); } diff --git a/pyproject.toml b/pyproject.toml index 6326aeaa2..e5e8c3e58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "AstrBot" -version = "4.25.5" +version = "4.25.6-rc.1" description = "Easy-to-use multi-platform LLM chatbot and development framework" readme = "README.md" license = { text = "AGPL-3.0-or-later" } @@ -120,7 +120,7 @@ exclude = ["dashboard", "node_modules", "dist", "data", "tests"] allow-direct-references = true # Include bundled dashboard dist even though it is not tracked by VCS. -[tool.hatch.build.targets.wheel] +[tool.hatch.build] artifacts = ["astrbot/dashboard/dist/**"] # Custom build hook: builds the Vue dashboard and copies dist into the package. diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 596cdb25d..5bd3fd7d2 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -59,6 +59,34 @@ def _strip_query(url: str) -> str: return urlunsplit(("", "", parsed.path, "", parsed.fragment)) +async def _wait_for_update_progress( + test_client, + authenticated_header: dict, + progress_id: str, +) -> dict: + """Wait until an update task reaches a terminal status. + + Args: + test_client: Quart test client. + authenticated_header: Headers for authenticated dashboard requests. + progress_id: Update progress id to poll. + + Returns: + The update progress response payload. + """ + + for _ in range(100): + response = await test_client.get( + f"/api/update/progress?id={progress_id}", + headers=authenticated_header, + ) + data = await response.get_json() + if data["data"].get("status") in {"success", "error"}: + return data + await asyncio.sleep(0.01) + pytest.fail(f"Update task did not finish: {progress_id}") + + @pytest.fixture def registered_plugin_page(core_lifecycle_td: AstrBotCoreLifecycle, monkeypatch): plugin_root = ( @@ -2463,40 +2491,77 @@ async def test_do_update( authenticated_header: dict, core_lifecycle_td: AstrBotCoreLifecycle, monkeypatch, - tmp_path_factory, + tmp_path, ): test_client = app.test_client() - # Use a temporary path for the mock update to avoid side effects - temp_release_dir = tmp_path_factory.mktemp("release") - release_path = temp_release_dir / "astrbot" - calls = [] + release_path = tmp_path / "astrbot" + calls: list[str] = [] - async def mock_update(*args, **kwargs): - """Mocks the update process by creating a directory in the temp path.""" - calls.append("core") + async def mock_download_update_package(*args, **kwargs): + """Mock the core package download by writing a valid ZIP archive.""" + calls.append("download-core") callback = kwargs.get("progress_callback") if callback: callback({"downloaded": 10, "total": 10, "percent": 1, "speed": 1}) + zip_path = Path(kwargs["path"]) + with zipfile.ZipFile(zip_path, "w") as archive: + archive.writestr("AstrBot-v3.4.0/README.md", "core") + return zip_path + + def mock_apply_update_package(zip_path): + """Mock applying the core package.""" + calls.append("apply-core") + assert zipfile.is_zipfile(zip_path) os.makedirs(release_path, exist_ok=True) async def mock_download_dashboard(*args, **kwargs): - """Mocks the dashboard download to prevent network access.""" - calls.append("dashboard") + """Mock the dashboard download by writing a valid ZIP archive.""" + calls.append("download-dashboard") callback = kwargs.get("progress_callback") if callback: callback({"downloaded": 10, "total": 10, "percent": 1, "speed": 1}) - return + zip_path = Path(kwargs["path"]) + with zipfile.ZipFile(zip_path, "w") as archive: + archive.writestr("dist/index.html", "dashboard") + + def mock_extract_dashboard(zip_path, extract_path): + """Mock applying the dashboard package.""" + calls.append("apply-dashboard") + assert zipfile.is_zipfile(zip_path) + assert Path(extract_path) == tmp_path / "data" async def mock_pip_install(*args, **kwargs): """Mocks pip install to prevent actual installation.""" + calls.append("pip") return - monkeypatch.setattr(core_lifecycle_td.astrbot_updator, "update", mock_update) + monkeypatch.setattr( + core_lifecycle_td.astrbot_updator, + "download_update_package", + mock_download_update_package, + ) + monkeypatch.setattr( + core_lifecycle_td.astrbot_updator, + "apply_update_package", + mock_apply_update_package, + ) monkeypatch.setattr( "astrbot.dashboard.routes.update.download_dashboard", mock_download_dashboard, ) + monkeypatch.setattr( + "astrbot.dashboard.routes.update.extract_dashboard", + mock_extract_dashboard, + ) + monkeypatch.setattr( + "astrbot.dashboard.routes.update.get_astrbot_system_tmp_path", + lambda: str(tmp_path / "tmp"), + ) + monkeypatch.setattr( + "astrbot.dashboard.routes.update.get_astrbot_data_path", + lambda: str(tmp_path / "data"), + ) monkeypatch.setattr( "astrbot.dashboard.routes.update.pip_installer.install", mock_pip_install, @@ -2510,19 +2575,101 @@ async def test_do_update( assert response.status_code == 200 data = await response.get_json() assert data["status"] == "ok" - assert os.path.exists(release_path) - assert calls[:2] == ["dashboard", "core"] + assert data["data"]["id"] == "test-progress" + assert data["data"]["status"] == "running" - progress_response = await test_client.get( - "/api/update/progress?id=test-progress", - headers=authenticated_header, + progress_data = await _wait_for_update_progress( + test_client, + authenticated_header, + "test-progress", ) - progress_data = await progress_response.get_json() + assert os.path.exists(release_path) + assert calls == [ + "download-dashboard", + "download-core", + "apply-core", + "apply-dashboard", + "pip", + ] assert progress_data["status"] == "ok" assert progress_data["data"]["status"] == "success" assert progress_data["data"]["overall_percent"] == 100 +@pytest.mark.asyncio +async def test_do_update_does_not_apply_files_when_core_download_fails( + app: Quart, + authenticated_header: dict, + core_lifecycle_td: AstrBotCoreLifecycle, + monkeypatch, + tmp_path, +): + test_client = app.test_client() + calls: list[str] = [] + + async def mock_download_dashboard(*args, **kwargs): + """Mock the dashboard download by writing a valid ZIP archive.""" + calls.append("download-dashboard") + zip_path = Path(kwargs["path"]) + with zipfile.ZipFile(zip_path, "w") as archive: + archive.writestr("dist/index.html", "dashboard") + + async def mock_download_update_package(*args, **kwargs): + """Mock a core package download failure.""" + calls.append("download-core") + raise RuntimeError("core download failed") + + def fail_apply_update_package(*args, **kwargs): + """Ensure core files are not applied after a download failure.""" + calls.append("apply-core") + raise AssertionError("core package should not be applied") + + def fail_extract_dashboard(*args, **kwargs): + """Ensure dashboard files are not applied after a download failure.""" + calls.append("apply-dashboard") + raise AssertionError("dashboard package should not be applied") + + monkeypatch.setattr( + "astrbot.dashboard.routes.update.get_astrbot_system_tmp_path", + lambda: str(tmp_path / "tmp"), + ) + monkeypatch.setattr( + "astrbot.dashboard.routes.update.download_dashboard", + mock_download_dashboard, + ) + monkeypatch.setattr( + "astrbot.dashboard.routes.update.extract_dashboard", + fail_extract_dashboard, + ) + monkeypatch.setattr( + core_lifecycle_td.astrbot_updator, + "download_update_package", + mock_download_update_package, + ) + monkeypatch.setattr( + core_lifecycle_td.astrbot_updator, + "apply_update_package", + fail_apply_update_package, + ) + + response = await test_client.post( + "/api/update/do", + headers=authenticated_header, + json={"version": "v3.4.0", "reboot": False, "progress_id": "atomic-fail"}, + ) + data = await response.get_json() + + assert response.status_code == 200 + assert data["status"] == "ok" + progress_data = await _wait_for_update_progress( + test_client, + authenticated_header, + "atomic-fail", + ) + assert progress_data["data"]["status"] == "error" + assert calls == ["download-dashboard", "download-core"] + + @pytest.mark.asyncio async def test_install_pip_package_returns_pip_install_error_message( app: Quart, diff --git a/tests/test_updator_socks.py b/tests/test_updator_socks.py index dc4c6e250..f5471f64a 100644 --- a/tests/test_updator_socks.py +++ b/tests/test_updator_socks.py @@ -1,14 +1,17 @@ import ntpath import posixpath +import zipfile from dataclasses import dataclass, field from pathlib import Path from types import SimpleNamespace +from urllib.parse import urlparse import certifi import httpx import pytest from astrbot.core.star.updator import PluginUpdator +from astrbot.core.updator import AstrBotUpdator from astrbot.core.zip_updator import RepoZipUpdator @@ -286,6 +289,144 @@ async def test_plugin_updator_install_prefers_download_url( assert calls["unzip"] == (str(expected_path) + ".zip", str(expected_path)) +@pytest.mark.asyncio +async def test_astrbot_updator_prefers_hosted_core_package( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.delenv("ASTRBOT_CLI", raising=False) + monkeypatch.delenv("ASTRBOT_LAUNCHER", raising=False) + monkeypatch.setenv("ASTRBOT_CORE_PACKAGE_BASE_URL", "https://cdn.example/core") + + updator = AstrBotUpdator() + calls: list[str] = [] + + async def fake_fetch_release_info(url: str, latest: bool = True): # noqa: ARG001 + return [ + { + "version": "AstrBot v99.0.0", + "published_at": "2026-06-19T00:00:00Z", + "body": "hosted core package", + "tag_name": "v99.0.0", + "zipball_url": "https://github.example/archive.zip", + } + ] + + async def fake_download_file(url: str, path: str, progress_callback=None): # noqa: ARG001 + calls.append(url) + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("AstrBot-v99.0.0/README.md", "hosted-core") + + monkeypatch.setattr(updator, "fetch_release_info", fake_fetch_release_info) + monkeypatch.setattr(updator, "_download_file", fake_download_file) + + zip_path = await updator.download_update_package( + latest=False, + version="v99.0.0", + path=tmp_path / "core.zip", + ) + + assert zip_path == tmp_path / "core.zip" + assert zipfile.is_zipfile(zip_path) + assert calls == ["https://cdn.example/core/v99.0.0/source.zip"] + + +@pytest.mark.asyncio +async def test_astrbot_updator_falls_back_when_hosted_core_package_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.delenv("ASTRBOT_CLI", raising=False) + monkeypatch.delenv("ASTRBOT_LAUNCHER", raising=False) + monkeypatch.setenv("ASTRBOT_CORE_PACKAGE_BASE_URL", "https://cdn.example/core") + + updator = AstrBotUpdator() + calls: list[str] = [] + + async def fake_fetch_release_info(url: str, latest: bool = True): # noqa: ARG001 + return [ + { + "version": "AstrBot v99.0.0", + "published_at": "2026-06-19T00:00:00Z", + "body": "hosted core package", + "tag_name": "v99.0.0", + "zipball_url": "https://github.example/archive.zip", + } + ] + + async def fake_download_file(url: str, path: str, progress_callback=None): # noqa: ARG001 + calls.append(url) + parsed = urlparse(url) + if parsed.scheme == "https" and parsed.hostname == "cdn.example": + raise RuntimeError("404") + Path(path).write_bytes(b"github-core") + + monkeypatch.setattr(updator, "fetch_release_info", fake_fetch_release_info) + monkeypatch.setattr(updator, "_download_file", fake_download_file) + + zip_path = await updator.download_update_package( + latest=False, + version="v99.0.0", + path=tmp_path / "core.zip", + ) + + assert zip_path == tmp_path / "core.zip" + assert zip_path.read_bytes() == b"github-core" + assert calls == [ + "https://cdn.example/core/v99.0.0/source.zip", + "https://github.example/archive.zip", + ] + + +@pytest.mark.asyncio +async def test_astrbot_updator_falls_back_when_hosted_core_package_is_not_zip( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.delenv("ASTRBOT_CLI", raising=False) + monkeypatch.delenv("ASTRBOT_LAUNCHER", raising=False) + monkeypatch.setenv("ASTRBOT_CORE_PACKAGE_BASE_URL", "https://cdn.example/core") + + updator = AstrBotUpdator() + calls: list[str] = [] + + async def fake_fetch_release_info(url: str, latest: bool = True): # noqa: ARG001 + return [ + { + "version": "AstrBot v99.0.0", + "published_at": "2026-06-19T00:00:00Z", + "body": "hosted core package", + "tag_name": "v99.0.0", + "zipball_url": "https://github.example/archive.zip", + } + ] + + async def fake_download_file(url: str, path: str, progress_callback=None): # noqa: ARG001 + calls.append(url) + parsed = urlparse(url) + if parsed.scheme == "https" and parsed.hostname == "cdn.example": + Path(path).write_bytes(b"not a zip") + return + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("AstrBot-v99.0.0/README.md", "github-core") + + monkeypatch.setattr(updator, "fetch_release_info", fake_fetch_release_info) + monkeypatch.setattr(updator, "_download_file", fake_download_file) + + zip_path = await updator.download_update_package( + latest=False, + version="v99.0.0", + path=tmp_path / "core.zip", + ) + + assert zip_path == tmp_path / "core.zip" + assert zipfile.is_zipfile(zip_path) + assert calls == [ + "https://cdn.example/core/v99.0.0/source.zip", + "https://github.example/archive.zip", + ] + + @pytest.mark.asyncio async def test_fetch_release_info_uses_httpx_client_with_env_proxy_support( monkeypatch: pytest.MonkeyPatch,