Files
zhenxun_bot/zhenxun/services/ai/sandbox/models.py
T

465 lines
16 KiB
Python
Raw Normal View History

"""
沙箱相关核心类型定义
"""
from abc import ABC, abstractmethod
import asyncio
import hashlib
import json
from pathlib import Path
import re
from typing import TYPE_CHECKING, Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from zhenxun.services.ai.utils.logger import log_sandbox as logger
from zhenxun.utils.pydantic_compat import model_dump
if TYPE_CHECKING:
from .drivers.base import BaseSandboxSession
class LanguageProfile(BaseModel):
"""语言执行配置模板"""
language: str
"""语言名称"""
aliases: list[str] = Field(default_factory=list)
"""语言别名列表"""
source_ext: str
"""源文件后缀名"""
compile_cmd: str | None = None
"""编译命令"""
run_cmd: str
"""运行命令"""
deps_install_cmd: str | None = None
"""依赖安装命令"""
class BaseEntry(BaseModel, ABC):
"""沙箱物化项(Artifacts)抽象基类"""
@abstractmethod
async def apply(self, session: "BaseSandboxSession", dest: str) -> None:
"""将当前项物化(应用)到沙箱中的指定目标路径"""
pass
class MemoryFile(BaseEntry):
"""基于内存字符串或二进制的数据物化"""
type: Literal["memory_file"] = "memory_file"
content: str | bytes
async def apply(self, session: "BaseSandboxSession", dest: str) -> None:
data = (
self.content.encode("utf-8")
if isinstance(self.content, str)
else self.content
)
await session.write(dest, data)
class LocalFile(BaseEntry):
"""基于宿主机本地单文件的物化"""
type: Literal["local_file"] = "local_file"
src: str
async def apply(self, session: "BaseSandboxSession", dest: str) -> None:
local_path = Path(self.src)
if await asyncio.to_thread(local_path.is_file):
content = await asyncio.to_thread(local_path.read_bytes)
await session.write(dest, content)
else:
logger.warning(f"[Sandbox] 本地文件 {self.src} 不存在,已跳过物化。")
class LocalDir(BaseEntry):
"""基于宿主机本地目录的物化 (会自动打包上传)"""
type: Literal["local_dir"] = "local_dir"
src: str
async def apply(self, session: "BaseSandboxSession", dest: str) -> None:
success = await session.upload_raw_dir(self.src, dest)
if not success:
logger.warning(f"[Sandbox] 本地目录 {self.src} 上传失败,已跳过物化。")
EntryUnion = Annotated[MemoryFile | LocalFile | LocalDir, Field(discriminator="type")]
class BaseSetupStep(BaseModel, ABC):
"""环境装配步骤基类,多态支持任何第三方语言的安装"""
model_config = ConfigDict(arbitrary_types_allowed=True)
@abstractmethod
async def apply(self, session: Any) -> None:
"""在沙箱会话中执行该装配步骤"""
pass
class AptSetup(BaseSetupStep):
type: Literal["apt"] = "apt"
packages: list[str]
async def apply(self, session: Any) -> None:
if not self.packages:
return
res = await session.run_process("dpkg -l", timeout=10)
installed_pkgs = set()
if res.exit_code == 0:
for line in res.stdout.splitlines():
if line.startswith("ii "):
parts = line.split()
if len(parts) >= 2:
pkg_name = parts[1].split(":")[0].strip().lower()
installed_pkgs.add(pkg_name)
missing_packages = [
pkg for pkg in self.packages if pkg.strip().lower() not in installed_pkgs
]
if not missing_packages:
logger.debug(f"[Sandbox] Apt 依赖已全部满足,跳过安装: {self.packages}")
return
pkg_str = " ".join(missing_packages)
logger.info(f"[Sandbox] 正在安装缺失的系统级依赖 (Apt): {pkg_str}")
await session.run_process(
"sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq && "
f"sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq {pkg_str}",
timeout=300,
)
class PythonSetup(BaseSetupStep):
type: Literal["python"] = "python"
packages: list[str]
async def apply(self, session: Any) -> None:
if not self.packages:
return
res = await session.run_process("python3 -m pip freeze", timeout=10)
installed_pkgs = set()
if res.exit_code == 0:
for line in res.stdout.splitlines():
if "==" not in line and "@" not in line:
continue
base_name = re.split(r"[=><~!@\[]", line)[0].strip().lower()
installed_pkgs.add(base_name)
missing_packages = [
pkg
for pkg in self.packages
if re.split(r"[=><~!@\[]", pkg)[0].strip().lower() not in installed_pkgs
]
if not missing_packages:
logger.debug(f"[Sandbox] Python 依赖已全部满足,跳过安装: {self.packages}")
return
pkg_str = " ".join(missing_packages)
check_uv = await session.run_process("command -v uv")
if check_uv.exit_code != 0:
logger.info("[Sandbox] 未检测到 uv,正在极速下载 uv...")
await session.run_process(
"pip install uv --disable-pip-version-check -q", timeout=60
)
logger.info(f"[Sandbox] 正在安装缺失的 Python 依赖: {pkg_str}")
res = await session.run_process(f"uv pip install {pkg_str}", timeout=120)
if res.exit_code != 0:
logger.warning(f"[Sandbox] uv 安装报错,尝试降级使用 pip: {res.stderr}")
await session.run_process(f"pip install {pkg_str} -q", timeout=180)
class NodeSetup(BaseSetupStep):
type: Literal["node"] = "node"
packages: list[str]
async def apply(self, session: Any) -> None:
if not self.packages:
return
res = await session.run_process("npm list -g --depth=0", timeout=15)
installed_pkgs = set()
if res.exit_code == 0:
for line in res.stdout.splitlines():
match = re.search(r"[├└]──\s+((?:@[^/]+/)?[^@\s]+)", line)
if match:
installed_pkgs.add(match.group(1).strip().lower())
missing_packages = []
for pkg in self.packages:
base_name = (
("@" + pkg[1:].split("@")[0])
if pkg.startswith("@")
else pkg.split("@")[0]
)
if base_name.strip().lower() not in installed_pkgs:
missing_packages.append(pkg)
if not missing_packages:
logger.debug(f"[Sandbox] Node 依赖已全部满足,跳过安装: {self.packages}")
return
pkg_str = " ".join(missing_packages)
logger.info(f"[Sandbox] 正在安装缺失的 Node 依赖: {pkg_str}")
res = await session.run_process(
f"npm install -g {pkg_str} --no-fund --no-audit --cache /tmp/npm-cache",
timeout=180,
)
if res.exit_code != 0:
from zhenxun.services.ai.core.exceptions import SandboxFatalError
raise SandboxFatalError(
f"Node 依赖安装失败 (Exit Code: {res.exit_code}): "
f"{res.stderr or res.error}"
)
class ShellSetup(BaseSetupStep):
type: Literal["shell"] = "shell"
scripts: list[str]
async def apply(self, session: Any) -> None:
for script in self.scripts:
logger.info(f"[Sandbox] 正在执行自定义装配脚本: {script}")
res = await session.run_process(script, timeout=300)
if res.exit_code != 0:
from zhenxun.services.ai.core.exceptions import SandboxFatalError
raise SandboxFatalError(
f"自定义装配脚本执行失败: {res.stderr or res.error}"
)
class BindMount(BaseModel):
"""宿主机物理目录绑定映射配置"""
host_path: str
"""宿主机绝对路径"""
sandbox_path: str = "/workspace"
"""沙箱内目标路径,默认覆盖 /workspace"""
read_only: bool = False
"""是否以只读模式挂载"""
class SandboxBlueprint(BaseModel):
"""沙箱大一统声明式蓝图配置"""
sandbox_type: str = Field(default="auto")
"""强制驱动类型,如 docker 或 local"""
enable_network: bool = Field(default=False)
"""是否允许访问外网"""
needs_state: bool = Field(default=False)
"""是否需要持久化状态"""
image: str | None = Field(default=None)
"""自定义镜像名称。为空则使用全局默认配置"""
container_name: str = Field(default="global")
"""容器/命名空间名称。相同名称将共享同一个物理容器和依赖环境"""
setup_steps: list[BaseSetupStep] = Field(default_factory=list)
"""多态环境装配图元管线"""
entries: dict[str, EntryUnion] = Field(default_factory=dict)
"""沙箱物化节点树(Artifacts),键为沙箱内的相对目标路径"""
env: dict[str, str] = Field(default_factory=dict)
"""环境变量"""
required_extensions: list[str] = Field(default_factory=list)
"""需要自动挂载的扩展"""
bind_mounts: list[BindMount] = Field(default_factory=list)
"""宿主机目录物理挂载映射列表 (Bind Mounts)"""
def with_sandbox_type(self, sandbox_type: str) -> "SandboxBlueprint":
"""设置沙箱驱动类型"""
self.sandbox_type = sandbox_type
return self
def with_network(self, enable: bool = True) -> "SandboxBlueprint":
"""设置是否启用网络访问"""
self.enable_network = enable
return self
def with_state(self, enable: bool = True) -> "SandboxBlueprint":
"""设置是否启用状态持久化"""
self.needs_state = enable
return self
def with_image(self, image: str) -> "SandboxBlueprint":
"""声明使用的自定义 Docker 镜像"""
self.image = image
return self
def with_container_name(self, name: str) -> "SandboxBlueprint":
"""声明沙箱使用的物理容器命名空间(实现环境隔离)"""
self.container_name = name
return self
def with_setup_step(self, step: BaseSetupStep) -> "SandboxBlueprint":
"""声明一个多态环境装配步骤"""
self.setup_steps.append(step)
return self
def with_python_packages(self, packages: list[str]) -> "SandboxBlueprint":
"""追加预置 Python 依赖包"""
self.setup_steps.append(PythonSetup(packages=packages))
return self
def with_system_packages(self, packages: list[str]) -> "SandboxBlueprint":
"""追加预置系统包依赖"""
self.setup_steps.append(AptSetup(packages=packages))
return self
def with_node_packages(self, packages: list[str]) -> "SandboxBlueprint":
"""追加预置 Node 依赖包"""
self.setup_steps.append(NodeSetup(packages=packages))
return self
def with_install_scripts(self, scripts: list[str]) -> "SandboxBlueprint":
"""追加自定义安装脚本"""
self.setup_steps.append(ShellSetup(scripts=scripts))
return self
def with_file(self, path: str, content: str | bytes) -> "SandboxBlueprint":
"""声明预置内存字符串或二进制文件"""
self.entries[path] = MemoryFile(content=content)
return self
def with_local_file(self, path: str, local_path: str) -> "SandboxBlueprint":
"""声明预置本地宿主机单文件"""
self.entries[path] = LocalFile(src=local_path)
return self
def with_local_dir(self, path: str, local_dir: str) -> "SandboxBlueprint":
"""声明预置本地宿主机完整目录"""
for bm in self.bind_mounts:
if path.startswith(bm.sandbox_path) or bm.sandbox_path.startswith(path):
logger.warning(
f"⚠️ [Sandbox] 路径 '{path}' 和物理挂载映射 "
f"'{bm.sandbox_path}' 存在重叠!\n"
"继续使用 with_local_dir 打包上传可能会引发冗余上传"
"或双向覆盖冲突。建议直接使用物理挂载。"
)
self.entries[path] = LocalDir(src=local_dir)
return self
def with_env(self, key: str, value: str) -> "SandboxBlueprint":
"""设置环境变量"""
self.env[key] = value
return self
def with_extension(self, extension: str) -> "SandboxBlueprint":
"""声明需要挂载的扩展"""
if extension not in self.required_extensions:
self.required_extensions.append(extension)
return self
def with_workspace(
self,
local_path: str | Path,
remote_path: str = "/workspace",
read_only: bool = False,
) -> "SandboxBlueprint":
"""声明宿主机物理目录双向挂载 (Bind Mount)"""
from pathlib import Path
if remote_path in self.entries:
logger.warning(
f"⚠️ [Sandbox] 目标沙箱路径 '{remote_path}' "
"已包含普通实体映射(如 LocalDir/File)。\n"
"强制绑定物理挂载 (Bind Mount) 将会遮蔽原有实体。"
)
abs_path = Path(local_path).resolve().as_posix()
self.bind_mounts.append(
BindMount(host_path=abs_path, sandbox_path=remote_path, read_only=read_only)
)
return self
def calculate_hash(self) -> str:
"""计算当前环境配置的 MD5 指纹,用于缓存命中"""
entries_dict = {
k: model_dump(self.entries[k]) for k in sorted(self.entries.keys())
}
data_to_hash = {
"image": self.image,
"container_name": self.container_name,
"steps": [model_dump(s) for s in self.setup_steps],
"entries": entries_dict,
"bind_mounts": [model_dump(m) for m in self.bind_mounts],
}
json_str = json.dumps(data_to_hash, separators=(",", ":"))
return hashlib.md5(json_str.encode("utf-8")).hexdigest()
def merge(self, other: "SandboxBlueprint") -> "SandboxBlueprint":
"""合并另一个蓝图配置"""
if not other:
return self
self.enable_network = self.enable_network or other.enable_network
self.needs_state = self.needs_state or other.needs_state
self.sandbox_type = (
other.sandbox_type if other.sandbox_type != "auto" else self.sandbox_type
)
self.image = other.image or self.image
self.container_name = (
other.container_name
if other.container_name != "global"
else self.container_name
)
self.setup_steps.extend(other.setup_steps)
self.entries.update(other.entries)
self.env.update(other.env)
self.required_extensions = list(
dict.fromkeys(self.required_extensions + other.required_extensions)
)
self.bind_mounts.extend(other.bind_mounts)
return self
class SandboxExecutionResult(BaseModel):
"""沙箱统一的执行结果数据结构"""
stdout: str = Field(default="")
"""标准输出流"""
stderr: str = Field(default="")
"""标准错误流"""
exit_code: int = Field(default=0)
"""进程退出码"""
error: str | None = Field(default=None)
"""框架执行错误信息"""
is_timeout: bool = Field(default=False)
"""进程是否因超时被挂起(仍在运行)"""
images: list[str] = Field(default_factory=list)
"""Base64 图片列表"""
artifacts: dict[str, bytes] = Field(default_factory=dict)
"""生成的文件工件"""
@property
def is_success(self) -> bool:
return self.exit_code == 0 and self.error is None
class SandboxSessionState(BaseModel):
"""沙箱会话的序列化状态,用于无状态恢复"""
session_id: str
"""会话唯一标识符"""
backend_id: str
"""后端驱动分配的容器/沙箱 ID"""
container_name: str = "global"
"""底层所关联的物理容器名称"""
sandbox_type: str = "docker"
"""沙箱驱动类型"""
workspace_root_ready: bool = False
"""工作区目录是否已初始化完成"""
extra_data: dict[str, Any] = Field(default_factory=dict)
"""会话的其它序列化元数据"""