mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-16 06:58:51 +08:00
feat: expose runtime and sandbox dependency information
This commit is contained in:
@@ -2,7 +2,9 @@ from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
@@ -60,6 +62,30 @@ class StatService:
|
||||
self.config = config
|
||||
self.storage_cleaner = StorageCleaner(config)
|
||||
|
||||
# Snapshot dependency presence at startup; this does not test sandbox launch.
|
||||
system = platform.system().lower()
|
||||
sandbox = {"backend": None, "status": "unsupported"}
|
||||
if system == "linux":
|
||||
sandbox = {
|
||||
"backend": "bubblewrap",
|
||||
"status": "detected" if shutil.which("bwrap") else "missing",
|
||||
}
|
||||
elif system == "darwin":
|
||||
sandbox = {
|
||||
"backend": "seatbelt",
|
||||
"status": (
|
||||
"detected"
|
||||
if shutil.which("sandbox-exec", path="/usr/bin")
|
||||
== "/usr/bin/sandbox-exec"
|
||||
else "missing"
|
||||
),
|
||||
}
|
||||
self.runtime = {
|
||||
"os": system,
|
||||
"arch": platform.machine(),
|
||||
"sandbox": sandbox,
|
||||
}
|
||||
|
||||
async def restart_core(self) -> None:
|
||||
if DEMO_MODE:
|
||||
raise StatServiceError(
|
||||
@@ -107,6 +133,7 @@ class StatService:
|
||||
"change_pwd_hint": False,
|
||||
"md5_pwd_hint": False,
|
||||
"password_upgrade_required": False,
|
||||
"runtime": self.runtime,
|
||||
}
|
||||
storage_upgraded = await is_password_storage_upgraded(
|
||||
self.db_helper,
|
||||
@@ -124,6 +151,7 @@ class StatService:
|
||||
"change_pwd_hint": await self.is_default_cred(),
|
||||
"md5_pwd_hint": md5_pwd_hint,
|
||||
"password_upgrade_required": not storage_upgraded,
|
||||
"runtime": self.runtime,
|
||||
}
|
||||
|
||||
async def get_public_versions(
|
||||
|
||||
@@ -2647,7 +2647,8 @@ export const getProviderTokenStats = <ThrowOnError extends boolean = false>(opti
|
||||
};
|
||||
|
||||
/**
|
||||
* Get AstrBot version
|
||||
* Get AstrBot version and runtime information
|
||||
* Runtime information is detected once at application startup. Restart AstrBot after installing sandbox dependencies to refresh it.
|
||||
*/
|
||||
export const getVersion = <ThrowOnError extends boolean = false>(options?: OptionsLegacyParser<unknown, ThrowOnError>) => {
|
||||
return (options?.client ?? client).get<GetVersionResponse, GetVersionError, ThrowOnError>({
|
||||
|
||||
@@ -592,6 +592,37 @@ export type ReorderRequest = {
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The AstrBot backend runtime, including when running inside a container. Values are captured at application startup.
|
||||
*/
|
||||
export type RuntimeInfo = {
|
||||
/**
|
||||
* Lowercase platform.system() value, commonly linux, darwin, or windows.
|
||||
*/
|
||||
os: string;
|
||||
/**
|
||||
* Unmodified platform.machine() value, such as x86_64, AMD64, arm64, or aarch64. May be empty if unknown.
|
||||
*/
|
||||
arch: string;
|
||||
/**
|
||||
* Local process sandbox dependency detection. It does not describe file tool permissions or verify that sandboxed processes can launch.
|
||||
*/
|
||||
sandbox: {
|
||||
backend: ('bubblewrap' | 'seatbelt') | null;
|
||||
/**
|
||||
* detected means bwrap was found in PATH or /usr/bin/sandbox-exec was found on macOS; missing means the corresponding executable was not found; unsupported means this platform has no Local process sandbox backend. These identifiers are independent of the UI language.
|
||||
*/
|
||||
status: 'detected' | 'missing' | 'unsupported';
|
||||
};
|
||||
};
|
||||
|
||||
export type backend = 'bubblewrap' | 'seatbelt';
|
||||
|
||||
/**
|
||||
* detected means bwrap was found in PATH or /usr/bin/sandbox-exec was found on macOS; missing means the corresponding executable was not found; unsupported means this platform has no Local process sandbox backend. These identifiers are independent of the UI language.
|
||||
*/
|
||||
export type status = 'detected' | 'missing' | 'unsupported';
|
||||
|
||||
export type SessionGroupRequest = {
|
||||
name?: string;
|
||||
umos?: Array<(string)>;
|
||||
@@ -3282,7 +3313,11 @@ export type GetProviderTokenStatsResponse = (SuccessEnvelope);
|
||||
|
||||
export type GetProviderTokenStatsError = unknown;
|
||||
|
||||
export type GetVersionResponse = (SuccessEnvelope);
|
||||
export type GetVersionResponse = ((SuccessEnvelope & {
|
||||
data?: {
|
||||
runtime: RuntimeInfo;
|
||||
};
|
||||
}));
|
||||
|
||||
export type GetVersionError = unknown;
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type PluginValidateRepoRequest,
|
||||
type PluginConfigFileDeleteRequest,
|
||||
type ProviderConfigRequest,
|
||||
type RuntimeInfo,
|
||||
type BatchSessionProviderRequest,
|
||||
type BatchSessionServiceRequest,
|
||||
type SetupAuthRequest,
|
||||
@@ -120,6 +121,7 @@ export interface VersionData {
|
||||
change_pwd_hint?: boolean;
|
||||
md5_pwd_hint?: boolean;
|
||||
password_upgrade_required?: boolean;
|
||||
runtime?: RuntimeInfo;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -4389,12 +4389,26 @@ paths:
|
||||
/api/v1/stats/version:
|
||||
get:
|
||||
tags: [Stats]
|
||||
summary: Get AstrBot version
|
||||
summary: Get AstrBot version and runtime information
|
||||
description: Runtime information is detected once at application startup. Restart AstrBot after installing sandbox dependencies to refresh it.
|
||||
operationId: getVersion
|
||||
x-astrbot-scope: system
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Ok"
|
||||
description: Version information with a backend runtime snapshot
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/SuccessEnvelope"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
required: [runtime]
|
||||
properties:
|
||||
runtime:
|
||||
$ref: "#/components/schemas/RuntimeInfo"
|
||||
|
||||
/api/v1/stats/versions:
|
||||
get:
|
||||
@@ -5296,6 +5310,30 @@ components:
|
||||
type: string
|
||||
|
||||
schemas:
|
||||
RuntimeInfo:
|
||||
type: object
|
||||
description: The AstrBot backend runtime, including when running inside a container. Values are captured at application startup.
|
||||
required: [os, arch, sandbox]
|
||||
properties:
|
||||
os:
|
||||
type: string
|
||||
description: Lowercase platform.system() value, commonly linux, darwin, or windows.
|
||||
arch:
|
||||
type: string
|
||||
description: Unmodified platform.machine() value, such as x86_64, AMD64, arm64, or aarch64. May be empty if unknown.
|
||||
sandbox:
|
||||
type: object
|
||||
description: Local process sandbox dependency detection. It does not describe file tool permissions or verify that sandboxed processes can launch.
|
||||
required: [backend, status]
|
||||
properties:
|
||||
backend:
|
||||
type: [string, "null"]
|
||||
enum: [bubblewrap, seatbelt, null]
|
||||
status:
|
||||
type: string
|
||||
enum: [detected, missing, unsupported]
|
||||
description: detected means bwrap was found in PATH or /usr/bin/sandbox-exec was found on macOS; missing means the corresponding executable was not found; unsupported means this platform has no Local process sandbox backend. These identifiers are independent of the UI language.
|
||||
|
||||
SuccessEnvelope:
|
||||
type: object
|
||||
required: [status, data]
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
@@ -13,6 +13,7 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.responses import FileResponse, PlainTextResponse
|
||||
|
||||
import astrbot.dashboard.services.config_service as config_service
|
||||
import astrbot.dashboard.services.stat_service as stat_service
|
||||
from astrbot.core import file_token_service
|
||||
from astrbot.core.utils import llm_metadata
|
||||
from astrbot.dashboard.api.app import create_dashboard_asgi_app
|
||||
@@ -1042,6 +1043,70 @@ def _jwt_headers() -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("desktop_auth", [False, True])
|
||||
async def test_version_routes_return_startup_runtime_snapshot(
|
||||
monkeypatch, fake_core_lifecycle, fake_db: FakeDb, desktop_auth
|
||||
):
|
||||
"""Both version routes and auth modes expose the same startup snapshot."""
|
||||
platform = SimpleNamespace(
|
||||
system=Mock(return_value="Linux"), machine=Mock(return_value="aarch64")
|
||||
)
|
||||
which = Mock(return_value=None)
|
||||
monkeypatch.setattr(stat_service, "platform", platform)
|
||||
monkeypatch.setattr(stat_service, "shutil", SimpleNamespace(which=which))
|
||||
monkeypatch.setattr(
|
||||
stat_service, "is_desktop_session_auth_enabled", lambda: desktop_auth
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
stat_service, "get_dashboard_version", AsyncMock(return_value="v1.2.3")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
stat_service, "is_password_storage_upgraded", AsyncMock(return_value=True)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
stat_service,
|
||||
"get_dashboard_password_hash",
|
||||
lambda *args, **kwargs: "stored-hash",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
stat_service.StatService, "is_default_cred", AsyncMock(return_value=False)
|
||||
)
|
||||
app = create_dashboard_asgi_app(
|
||||
core_lifecycle=fake_core_lifecycle, db=fake_db, jwt_secret=JWT_SECRET
|
||||
)
|
||||
|
||||
# Installing a dependency after startup takes effect after restarting AstrBot.
|
||||
which.return_value = "/usr/bin/bwrap"
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://testserver"
|
||||
) as client:
|
||||
for path in ("/api/v1/stats/version", "/api/stat/version"):
|
||||
response = await client.get(path, headers=_jwt_headers())
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["data"]["runtime"] == {
|
||||
"os": "linux",
|
||||
"arch": "aarch64",
|
||||
"sandbox": {"backend": "bubblewrap", "status": "missing"},
|
||||
}
|
||||
assert payload["data"]["version"]
|
||||
assert payload["data"]["dashboard_version"] == "v1.2.3"
|
||||
assert payload["data"]["change_pwd_hint"] is False
|
||||
assert payload["data"]["md5_pwd_hint"] is False
|
||||
assert payload["data"]["password_upgrade_required"] is False
|
||||
|
||||
response = await client.get("/api/v1/stats/versions")
|
||||
assert response.status_code == 200
|
||||
assert "runtime" not in response.json()["data"]
|
||||
|
||||
which.assert_called_once_with("bwrap")
|
||||
platform.system.assert_called_once_with()
|
||||
platform.machine.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_versions_route_uses_static_folder(
|
||||
fake_core_lifecycle,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import astrbot.dashboard.services.stat_service as stat_service
|
||||
from astrbot.dashboard.services.stat_service import StatService
|
||||
|
||||
|
||||
@@ -16,6 +18,46 @@ def _make_service(db) -> StatService:
|
||||
return StatService(db_helper=db, core_lifecycle=core_lifecycle, config={})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("system", "arch", "executable", "backend", "status"),
|
||||
[
|
||||
("Linux", "x86_64", "/usr/bin/bwrap", "bubblewrap", "detected"),
|
||||
("Linux", "aarch64", None, "bubblewrap", "missing"),
|
||||
("Darwin", "arm64", "/usr/bin/sandbox-exec", "seatbelt", "detected"),
|
||||
("Darwin", "x86_64", None, "seatbelt", "missing"),
|
||||
("Darwin", "arm64", "/opt/bin/sandbox-exec", "seatbelt", "missing"),
|
||||
("Windows", "AMD64", None, None, "unsupported"),
|
||||
("Windows", "ARM64", None, None, "unsupported"),
|
||||
("FreeBSD", "", None, None, "unsupported"),
|
||||
],
|
||||
)
|
||||
def test_runtime_detects_platform_dependencies(
|
||||
monkeypatch, system, arch, executable, backend, status
|
||||
):
|
||||
"""Report dependency presence without requiring a native sandbox in CI."""
|
||||
monkeypatch.setattr(
|
||||
stat_service,
|
||||
"platform",
|
||||
SimpleNamespace(system=lambda: system, machine=lambda: arch),
|
||||
)
|
||||
which = Mock(return_value=executable)
|
||||
monkeypatch.setattr(stat_service, "shutil", SimpleNamespace(which=which))
|
||||
|
||||
service = _make_service(MagicMock())
|
||||
|
||||
assert service.runtime == {
|
||||
"os": system.lower(),
|
||||
"arch": arch,
|
||||
"sandbox": {"backend": backend, "status": status},
|
||||
}
|
||||
if system == "Linux":
|
||||
which.assert_called_once_with("bwrap")
|
||||
elif system == "Darwin":
|
||||
which.assert_called_once_with("sandbox-exec", path="/usr/bin")
|
||||
else:
|
||||
which.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_stat_aggregates_platform_stats(temp_db):
|
||||
"""Seeded rows must aggregate into windowed platform sums and a global total."""
|
||||
|
||||
Reference in New Issue
Block a user