Commit fc70c504 by DigHuang Committed by GitHub

fix(sandbox): handle OpenSandbox PVC lifecycle races and UID preconditions (#7435)

* fix(sandbox): handle OpenSandbox PVC lifecycle races and UID preconditions (#7423)

* fix(sandbox): handle OpenSandbox PVC lifecycle races and UID preconditions

* refactor(sandbox): operate volume manager on explicit claimName

* refactor(sandbox): configure OpenSandbox image and compact volume names

refactor(sandbox): unify volume schemas and volume-manager contracts

fix(sandbox): harden OpenSandbox volume lifecycle

* fix(sandbox): handle stale restore rollback phases and legacy volume fallback
parent 93955e04
...@@ -7,6 +7,9 @@ ...@@ -7,6 +7,9 @@
用户级实例、生命周期、Legacy 迁移以及本分支后续变更的最终契约统一见 用户级实例、生命周期、Legacy 迁移以及本分支后续变更的最终契约统一见
[用户级 Sandbox 最终方案](./user-level-sandbox.md)。本文只维护当前代码入口和运行行为索引。 [用户级 Sandbox 最终方案](./user-level-sandbox.md)。本文只维护当前代码入口和运行行为索引。
OpenSandbox Kubernetes PVC 的问题定论、生命周期设计和验证记录统一见
[OpenSandbox Kubernetes PVC 生命周期问题与设计](./opensandbox-pvc-lifecycle.md)
## 目标与边界 ## 目标与边界
Agent Sandbox 为 Agent 提供隔离的 Linux 运行环境、文件系统和工具调用能力,同时维护物理实例、业务归属、Provider 生命周期、归档恢复和 Skill 部署。 Agent Sandbox 为 Agent 提供隔离的 Linux 运行环境、文件系统和工具调用能力,同时维护物理实例、业务归属、Provider 生命周期、归档恢复和 Skill 部署。
......
# OpenSandbox Kubernetes PVC 生命周期问题与设计
状态:已完成
最后核对:2026-08-04
## 定论
OpenSandbox 的 Kubernetes 归档链路存在真实竞态,但不是两个同名 PVC 会同时存在。旧 PVC
处于 `Terminating` 时,Kubernetes 不会让引用它的新 Pod 正常运行;PVC 对象删除也不等于
PV、VolumeAttachment 或存储后端已经完成卸载。
原实现把 DELETE 的 2xx 当作删除完成、把任意 GET 200 当作可用 PVC,并在 lifecycle lease 外
预取 restore volume 配置,因此可能过早发布 `archived` 或把正在删除的 claim 带入恢复。
## 目标与边界
- 删除完成条件与 PVC API 对象生命周期一致,避免同名 PVC generation 竞态。
- restore 获取 volume 配置必须发生在 Sandbox lifecycle lease 和 operation claim 之后。
- archive 的 Provider 删除、volume 删除分别持久化 checkpoint,支持中断后恢复。
- Kubernetes PVC generation 生命周期处理不改变 Docker 的删除完成语义。
- 当前只保证 PVC API generation 结束,不承诺底层存储 detach/unmount 已完成;若未来复用静态
PV 或后端卷,需要增加 provider-specific 完成条件。
## 核心不变量
1. `remove()` 返回时,调用开始时锁定的 PVC UID 已消失或已被新 UID 替换。
2. DELETE 携带 UID precondition,不能误删 GET 之后新建的同名 PVC。
3. `ensure()` 不返回带 `deletionTimestamp` 的 PVC。
4. 并发 ensure 通过 Kubernetes API 的 404/409/UID 状态收敛,不依赖进程内锁。
5. restore 在 lease 内、claim 成功后获取 volume,并复用本次实际配置。
6. archive 只有完成 Provider 和 volume 两个 checkpoint 后才进入 `archived`
## 实现方案
### K8s volume-manager
只读取 `metadata.uid``metadata.deletionTimestamp`,统一转换为 `absent``active(uid)`
`deleting(uid)`
`ensure`:活动 PVC 直接复用;删除中的 PVC 等待目标 UID 消失或被替换后重新读取;不存在时
创建,201 返回成功,409 退避后重读,其他错误立即失败。
`remove`:不存在幂等成功;删除中的 PVC 等待目标 UID 结束;活动 PVC 使用 UID precondition
删除,并等待目标 UID 消失或被替换。默认最长等待 5 分钟、轮询间隔 500 毫秒,超时交给上层
durable lifecycle 记录失败并重试。
### Sandbox 生命周期
archive 阶段:
```text
claimed -> archiveUploaded -> providerDeleted -> volumeDeleted -> archived
```
- `providerDeleted` 只删除 Provider。
- `volumeDeleted` 删除并等待 OpenSandbox volume;其他 Provider 通过空操作完成该 checkpoint。
- 旧 operation 停在 `providerDeleted` 时,重试会继续 volume 删除,不重放归档上传。
restore 不再在调用 restore 前预取 volume。创建恢复 Sandbox 的 step 在 lease、状态重读和
operation claim 完成后调用 `getSessionVolumeConfig()`;恢复函数返回实际 `VolumeManagerResult`
runtime client 直接复用。若恢复没有执行创建 step,再在外层获取当前配置。
## 兼容性
- volume HTTP API 从 `sessionId` 切换为 app 预先持久化的 `claimName`;FastGPT app
与 volume-manager 按同一版本整体升级,不支持混用版本。
- `DockerVolumeDriver` 不执行 Kubernetes 状态解析、轮询或 UID precondition。
- Sandbox application 只对 `opensandbox` 使用 volume-manager;Docker 不产生额外 volume 请求。
## 验证
- volume-manager 与 Sandbox service 相关测试通过;TypeScript、构建和格式检查通过。
- 变更通过 ESLint、Prettier 和 `git diff --check`
## Review 补充设计
### 发布边界
- FastGPT app 与 volume-manager 按同一版本同步升级,`claimName` HTTP 合同不增加旧版
`sessionId` 兼容层。
- compose、Helm、环境变量模板和中英文部署文档必须与新合同同步更新。旧的
`VM_VOLUME_NAME_PREFIX` 配置需以原值迁移到
`AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX`
### 运行态 storage 并发保护
running 快路径只能在 Mongo 中的 storage 与 client 预读 storage 一致时刷新活跃时间,并且不能
通过 touch 回写 storage。若 CAS 失败,必须进入 lifecycle lease,在锁内重读实例,并以当前已提交的
workspace claim 重建 OpenSandbox provider。这样旧 client 既不能覆盖 restore 提交的新 generation,
也不能继续用旧 generation 自愈远端资源。
### 旧迁移 checkpoint 恢复
旧版本可能停在 `legacyMigrating/targetEnsured`,但尚未把旧确定性 volume 名写入 storage。新版本
检测到该 checkpoint 且缺少 workspace claim 时,按旧命名规则恢复 claim,并以同阶段 CAS 原子补写
storage,再继续安装归档。`claimed` 阶段仍使用 generation `0` 的新命名规则。
### 镜像环境变量兼容
`AGENT_SANDBOX_OPENSANDBOX_IMAGE` 是新配置入口;未配置时回退到旧的
`AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO``AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG`
新变量优先,旧 tag 缺失时沿用 `latest`。只有新旧入口都没有可用 repository 时才报告缺失。
### volume 删除边界
- `claimName` 由 app 生成并持久化,volume-manager 不恢复 `VM_VOLUME_NAME_PREFIX` 配置,也不参与
命名或资源归属判断。
- `volumeNamePrefix` 只用于生成新 generation 的 `claimName`;删除时直接使用 Mongo 中持久化的
完整 `claimName`,不依赖当前 prefix 配置。这样配置变更不会阻断旧 PVC 的清理。
- Kubernetes 和 Docker driver 不写入或检查 managed label,只保留名称合法性、幂等删除以及
Kubernetes UID precondition 等底层资源生命周期约束。
## TODO
- [x] running touch 使用 storage CAS,并在 lease 内按最新 storage 重建 provider。
- [x] 修复旧 `targetEnsured` checkpoint 缺少 storage 的恢复路径。
- [x] 增加 OpenSandbox 新旧镜像环境变量兼容层。
- [x] 删除调用使用 Mongo 持久化的完整 claimName,不依赖当前 volume name prefix。
- [x] 补齐并运行上述并发、兼容和权限边界单元测试。
- [x] 运行最终全量测试、构建和差异检查。
...@@ -46,8 +46,7 @@ OpenSandbox 使用 FastGPT 自己维护的 `fastgpt-agent-sandbox` 镜像。 ...@@ -46,8 +46,7 @@ OpenSandbox 使用 FastGPT 自己维护的 `fastgpt-agent-sandbox` 镜像。
相关环境变量: 相关环境变量:
- `AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO` - `AGENT_SANDBOX_OPENSANDBOX_IMAGE`
- `AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG`
- `FASTGPT_WORKDIR` - `FASTGPT_WORKDIR`
- `FASTGPT_ENABLE_CODE_SERVER` - `FASTGPT_ENABLE_CODE_SERVER`
...@@ -191,8 +190,7 @@ AGENT_SANDBOX_SEALOS_IMAGE ...@@ -191,8 +190,7 @@ AGENT_SANDBOX_SEALOS_IMAGE
不要复用: 不要复用:
```txt ```txt
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO AGENT_SANDBOX_OPENSANDBOX_IMAGE
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG
``` ```
## 4. 编辑器访问与文件通道 ## 4. 编辑器访问与文件通道
......
...@@ -359,7 +359,6 @@ services: ...@@ -359,7 +359,6 @@ services:
PORT: 3000 PORT: 3000
VM_RUNTIME: docker VM_RUNTIME: docker
VM_AUTH_TOKEN: *x-volume-manager-auth-token # 对应 AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN VM_AUTH_TOKEN: *x-volume-manager-auth-token # 对应 AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN
VM_VOLUME_NAME_PREFIX: fastgpt-session # volume 名称前缀
VM_LOG_LEVEL: info VM_LOG_LEVEL: info
healthcheck: healthcheck:
test: test:
......
...@@ -359,7 +359,6 @@ services: ...@@ -359,7 +359,6 @@ services:
PORT: 3000 PORT: 3000
VM_RUNTIME: docker VM_RUNTIME: docker
VM_AUTH_TOKEN: *x-volume-manager-auth-token # 对应 AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN VM_AUTH_TOKEN: *x-volume-manager-auth-token # 对应 AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN
VM_VOLUME_NAME_PREFIX: fastgpt-session # volume 名称前缀
VM_LOG_LEVEL: info VM_LOG_LEVEL: info
healthcheck: healthcheck:
test: test:
......
...@@ -359,7 +359,6 @@ services: ...@@ -359,7 +359,6 @@ services:
PORT: 3000 PORT: 3000
VM_RUNTIME: docker VM_RUNTIME: docker
VM_AUTH_TOKEN: *x-volume-manager-auth-token # 对应 AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN VM_AUTH_TOKEN: *x-volume-manager-auth-token # 对应 AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN
VM_VOLUME_NAME_PREFIX: fastgpt-session # volume 名称前缀
VM_LOG_LEVEL: info VM_LOG_LEVEL: info
healthcheck: healthcheck:
test: test:
......
...@@ -63,11 +63,11 @@ These variables are mainly validated by `packages/service/env.ts` and apply to ` ...@@ -63,11 +63,11 @@ These variables are mainly validated by `packages/service/env.ts` and apply to `
| `AGENT_SANDBOX_OPENSANDBOX_BASEURL` | Empty | OpenSandbox service URL. | | `AGENT_SANDBOX_OPENSANDBOX_BASEURL` | Empty | OpenSandbox service URL. |
| `AGENT_SANDBOX_OPENSANDBOX_API_KEY` | Empty | OpenSandbox API key. Required when OpenSandbox is enabled, and must match OpenSandbox server `[server].api_key`. | | `AGENT_SANDBOX_OPENSANDBOX_API_KEY` | Empty | OpenSandbox API key. Required when OpenSandbox is enabled, and must match OpenSandbox server `[server].api_key`. |
| `AGENT_SANDBOX_OPENSANDBOX_RUNTIME` | `docker` | OpenSandbox runtime, either `docker` or `kubernetes`. | | `AGENT_SANDBOX_OPENSANDBOX_RUNTIME` | `docker` | OpenSandbox runtime, either `docker` or `kubernetes`. |
| `AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO` | `fastgpt-agent-sandbox` | Image repository used by OpenSandbox. | | `AGENT_SANDBOX_OPENSANDBOX_IMAGE` | Empty | Full runtime image used by OpenSandbox. Required when `opensandbox` is enabled. |
| `AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG` | `latest` | Image tag used by OpenSandbox. |
| `AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY` | `true` | Whether OpenSandbox access goes through the server proxy. | | `AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY` | `true` | Whether OpenSandbox access goes through the server proxy. |
| `AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL` | Empty | Required in OpenSandbox mode. Volume Manager service URL. | | `AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL` | Empty | Required in OpenSandbox mode. Volume Manager service URL. |
| `AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN` | Empty | Required in OpenSandbox mode. Volume Manager authentication token. | | `AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN` | Empty | Required in OpenSandbox mode. Volume Manager authentication token. |
| `AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX` | `fastgpt-session` | Prefix used by the FastGPT app when generating persistent OpenSandbox volume `claimName` values. When upgrading, reuse the previous `VM_VOLUME_NAME_PREFIX` value. |
| `AGENT_SANDBOX_PROXY_SECRET` | Empty | Shared HMAC secret for the app and agent-sandbox-proxy. Required by `fastgpt-app` when Agent Sandbox is enabled; must be at least 32 bytes. | | `AGENT_SANDBOX_PROXY_SECRET` | Empty | Shared HMAC secret for the app and agent-sandbox-proxy. Required by `fastgpt-app` when Agent Sandbox is enabled; must be at least 32 bytes. |
| `AGENT_SANDBOX_PROXY_URL` | Empty | Browser-accessible WebSocket URL for agent-sandbox-proxy. Required by `fastgpt-app` when Agent Sandbox is enabled; must start with `ws://` or `wss://`. | | `AGENT_SANDBOX_PROXY_URL` | Empty | Browser-accessible WebSocket URL for agent-sandbox-proxy. Required by `fastgpt-app` when Agent Sandbox is enabled; must start with `ws://` or `wss://`. |
| `AGENT_SANDBOX_PREVIEW_PROXY_URL` | Empty | Browser-accessible HTTP(S) URL for Sandbox file previews. You must add it to both `fastgpt-app` and `fastgpt-pro` when Agent Sandbox is enabled. Use an origin separate from the FastGPT application. | | `AGENT_SANDBOX_PREVIEW_PROXY_URL` | Empty | Browser-accessible HTTP(S) URL for Sandbox file previews. You must add it to both `fastgpt-app` and `fastgpt-pro` when Agent Sandbox is enabled. Use an origin separate from the FastGPT application. |
...@@ -366,14 +366,13 @@ These variables are loaded and validated by `projects/code-sandbox/src/env.ts`. ...@@ -366,14 +366,13 @@ These variables are loaded and validated by `projects/code-sandbox/src/env.ts`.
These variables are loaded and validated by `projects/volume-manager/src/env.ts`. The `AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN` used by FastGPT for persistent OpenSandbox volumes must match `VM_AUTH_TOKEN`. These variables are loaded and validated by `projects/volume-manager/src/env.ts`. The `AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN` used by FastGPT for persistent OpenSandbox volumes must match `VM_AUTH_TOKEN`.
| Variable | Default | Description | | Variable | Default | Description |
| -------------------------- | ---------------------- | ------------------------------------------------------------------- | | -------------------------- | ---------------------- | ---------------------------------------------------------------- |
| `PORT` | `3000` | Volume Manager listening port. | | `PORT` | `3000` | Volume Manager listening port. |
| `VM_AUTH_TOKEN` | None, **required** | API authentication token for Volume Manager. | | `VM_AUTH_TOKEN` | None, **required** | API authentication token for Volume Manager. |
| `VM_RUNTIME` | `kubernetes` | Runtime type, either `docker` or `kubernetes`. | | `VM_RUNTIME` | `kubernetes` | Runtime type, either `docker` or `kubernetes`. |
| `VM_DOCKER_SOCKET` | `/var/run/docker.sock` | Docker socket path. Required only in `docker` mode. | | `VM_DOCKER_SOCKET` | `/var/run/docker.sock` | Docker socket path. Required only in `docker` mode. |
| `VM_DOCKER_API_VERSION` | `v1.44` | Docker API version. Required only in `docker` mode. | | `VM_DOCKER_API_VERSION` | `v1.44` | Docker API version. Required only in `docker` mode. |
| `VM_K8S_NAMESPACE` | `opensandbox` | Kubernetes namespace. Required only in `kubernetes` mode. | | `VM_K8S_NAMESPACE` | `opensandbox` | Kubernetes namespace. Required only in `kubernetes` mode. |
| `VM_K8S_PVC_STORAGE_CLASS` | `standard` | Kubernetes PVC StorageClass. Required only in `kubernetes` mode. | | `VM_K8S_PVC_STORAGE_CLASS` | `standard` | Kubernetes PVC StorageClass. Required only in `kubernetes` mode. |
| `VM_VOLUME_NAME_PREFIX` | `fastgpt-session` | Volume/PVC name prefix. The final name includes the sessionId hash. | | `VM_LOG_LEVEL` | `info` | Log level. Supported values are `debug`, `info`, and `none`. |
| `VM_LOG_LEVEL` | `info` | Log level. Supported values are `debug`, `info`, and `none`. |
...@@ -63,11 +63,11 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说 ...@@ -63,11 +63,11 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说
| `AGENT_SANDBOX_OPENSANDBOX_BASEURL` | 空 | OpenSandbox 服务地址。 | | `AGENT_SANDBOX_OPENSANDBOX_BASEURL` | 空 | OpenSandbox 服务地址。 |
| `AGENT_SANDBOX_OPENSANDBOX_API_KEY` | 空 | OpenSandbox API Key;启用 OpenSandbox 时必填,并且必须与 OpenSandbox server 的 `[server].api_key` 一致。 | | `AGENT_SANDBOX_OPENSANDBOX_API_KEY` | 空 | OpenSandbox API Key;启用 OpenSandbox 时必填,并且必须与 OpenSandbox server 的 `[server].api_key` 一致。 |
| `AGENT_SANDBOX_OPENSANDBOX_RUNTIME` | `docker` | OpenSandbox 运行时,可选 `docker` 或 `kubernetes`。 | | `AGENT_SANDBOX_OPENSANDBOX_RUNTIME` | `docker` | OpenSandbox 运行时,可选 `docker` 或 `kubernetes`。 |
| `AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO` | `fastgpt-agent-sandbox` | OpenSandbox 使用的镜像仓库。 | | `AGENT_SANDBOX_OPENSANDBOX_IMAGE` | 空 | OpenSandbox 使用的完整运行态镜像;启用 `opensandbox` 时必填。 |
| `AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG` | `latest` | OpenSandbox 使用的镜像标签。 |
| `AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY` | `true` | OpenSandbox 是否通过服务端代理访问。 | | `AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY` | `true` | OpenSandbox 是否通过服务端代理访问。 |
| `AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL` | 空 | OpenSandbox 模式下必填,Volume Manager 服务地址。 | | `AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL` | 空 | OpenSandbox 模式下必填,Volume Manager 服务地址。 |
| `AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN` | 空 | OpenSandbox 模式下必填,Volume Manager 认证 Token。 | | `AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN` | 空 | OpenSandbox 模式下必填,Volume Manager 认证 Token。 |
| `AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX` | `fastgpt-session` | FastGPT app 生成 OpenSandbox 持久卷 `claimName` 时使用的前缀;升级时需沿用旧 `VM_VOLUME_NAME_PREFIX` 的值。 |
| `AGENT_SANDBOX_PROXY_SECRET` | 空 | agent-sandbox-proxy 与主站共用的 HMAC 密钥;`fastgpt-app` 启用 Agent Sandbox 时必填,至少 32 字节。 | | `AGENT_SANDBOX_PROXY_SECRET` | 空 | agent-sandbox-proxy 与主站共用的 HMAC 密钥;`fastgpt-app` 启用 Agent Sandbox 时必填,至少 32 字节。 |
| `AGENT_SANDBOX_PROXY_URL` | 空 | 浏览器访问 agent-sandbox-proxy 的 WebSocket 地址;`fastgpt-app` 启用 Agent Sandbox 时必填,必须以 `ws://` 或 `wss://` 开头。 | | `AGENT_SANDBOX_PROXY_URL` | 空 | 浏览器访问 agent-sandbox-proxy 的 WebSocket 地址;`fastgpt-app` 启用 Agent Sandbox 时必填,必须以 `ws://` 或 `wss://` 开头。 |
| `AGENT_SANDBOX_PREVIEW_PROXY_URL` | 空 | 浏览器访问 Sandbox 文件预览的 HTTP(S) 地址;`fastgpt-app` 和 `fastgpt-pro` 启用 Agent Sandbox 时都必须增加。建议使用与 FastGPT 主站不同的 origin。 | | `AGENT_SANDBOX_PREVIEW_PROXY_URL` | 空 | 浏览器访问 Sandbox 文件预览的 HTTP(S) 地址;`fastgpt-app` 和 `fastgpt-pro` 启用 Agent Sandbox 时都必须增加。建议使用与 FastGPT 主站不同的 origin。 |
...@@ -375,5 +375,4 @@ DOC2X_KEY=your-api-key ...@@ -375,5 +375,4 @@ DOC2X_KEY=your-api-key
| `VM_DOCKER_API_VERSION` | `v1.44` | Docker API 版本,仅 `docker` 模式需要。 | | `VM_DOCKER_API_VERSION` | `v1.44` | Docker API 版本,仅 `docker` 模式需要。 |
| `VM_K8S_NAMESPACE` | `opensandbox` | Kubernetes 命名空间,仅 `kubernetes` 模式需要。 | | `VM_K8S_NAMESPACE` | `opensandbox` | Kubernetes 命名空间,仅 `kubernetes` 模式需要。 |
| `VM_K8S_PVC_STORAGE_CLASS` | `standard` | Kubernetes PVC StorageClass,仅 `kubernetes` 模式需要。 | | `VM_K8S_PVC_STORAGE_CLASS` | `standard` | Kubernetes PVC StorageClass,仅 `kubernetes` 模式需要。 |
| `VM_VOLUME_NAME_PREFIX` | `fastgpt-session` | Volume/PVC 名称前缀,最终名称会包含 sessionId hash。 |
| `VM_LOG_LEVEL` | `info` | 日志等级,可选 `debug`、`info` 或 `none`。 | | `VM_LOG_LEVEL` | `info` | 日志等级,可选 `debug`、`info` 或 `none`。 |
...@@ -12,6 +12,12 @@ import { Alert } from '@/components/docs/Alert'; ...@@ -12,6 +12,12 @@ import { Alert } from '@/components/docs/Alert';
OpenSandbox is suitable when you want to self-host the Agent/Skill sandbox runtime. Before starting, complete [General Sandbox Configuration](./common) and make sure `fastgpt-agent-sandbox-proxy` is deployed. Configure the proxy secret, WebSocket URL, and preview URL in `fastgpt-app`; you must configure the preview URL in `fastgpt-pro`. OpenSandbox is suitable when you want to self-host the Agent/Skill sandbox runtime. Before starting, complete [General Sandbox Configuration](./common) and make sure `fastgpt-agent-sandbox-proxy` is deployed. Configure the proxy secret, WebSocket URL, and preview URL in `fastgpt-app`; you must configure the preview URL in `fastgpt-pro`.
<Alert icon="⚠️" context="warning">
When upgrading from an earlier Volume Manager release, set
`AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX` to the previous `VM_VOLUME_NAME_PREFIX` value so
existing persistent volumes can still be cleaned up by their original names.
</Alert>
The OpenSandbox setup flow is below. The OpenSandbox setup flow is below.
## 1. Add yml services ## 1. Add yml services
...@@ -54,12 +60,13 @@ AGENT_SANDBOX_OPENSANDBOX_API_KEY=replace_with_opensandbox_api_key ...@@ -54,12 +60,13 @@ AGENT_SANDBOX_OPENSANDBOX_API_KEY=replace_with_opensandbox_api_key
# Docker compose deployments use docker runtime # Docker compose deployments use docker runtime
AGENT_SANDBOX_OPENSANDBOX_RUNTIME=docker AGENT_SANDBOX_OPENSANDBOX_RUNTIME=docker
# Runtime image used when OpenSandbox creates Agent Sandbox instances # Runtime image used when OpenSandbox creates Agent Sandbox instances
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO=registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt-agent-sandbox AGENT_SANDBOX_OPENSANDBOX_IMAGE=registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt-agent-sandbox:v0.2.0
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG=v0.2.0
AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY=true AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY=true
# Persistent volume manager URL and token. The token must match x-volume-manager-auth-token. # Persistent volume manager URL and token. The token must match x-volume-manager-auth-token.
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL=http://fastgpt-volume-manager:3000 AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL=http://fastgpt-volume-manager:3000
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN=replace_with_volume_manager_token AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN=replace_with_volume_manager_token
# Prefix used by the FastGPT app when generating persistent volume claimName values
AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX=fastgpt-session
# Per-instance Agent Sandbox CPU count and memory limit (MiB) # Per-instance Agent Sandbox CPU count and memory limit (MiB)
AGENT_SANDBOX_CPU_COUNT=1 AGENT_SANDBOX_CPU_COUNT=1
AGENT_SANDBOX_MEMORY_MIB=2048 AGENT_SANDBOX_MEMORY_MIB=2048
......
...@@ -11,6 +11,11 @@ import { Alert } from '@/components/docs/Alert'; ...@@ -11,6 +11,11 @@ import { Alert } from '@/components/docs/Alert';
OpenSandbox 适合需要自托管 Agent/Skill 沙盒运行环境的场景。开始前,请先完成[沙盒通用配置](./common),确保 `fastgpt-agent-sandbox-proxy` 已部署;`fastgpt-app` 需配置 Proxy Secret、WebSocket URL 和预览 URL,`fastgpt-pro` 必须配置预览 URL。 OpenSandbox 适合需要自托管 Agent/Skill 沙盒运行环境的场景。开始前,请先完成[沙盒通用配置](./common),确保 `fastgpt-agent-sandbox-proxy` 已部署;`fastgpt-app` 需配置 Proxy Secret、WebSocket URL 和预览 URL,`fastgpt-pro` 必须配置预览 URL。
<Alert icon="⚠️" context="warning">
从旧版 Volume Manager 升级时,请将原 `VM_VOLUME_NAME_PREFIX` 的值配置到
`AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX`,避免历史持久卷无法按原名称清理。
</Alert>
下面是 OpenSandbox 部署和配置流程。 下面是 OpenSandbox 部署和配置流程。
## 1. 添加 yml service ## 1. 添加 yml service
...@@ -53,12 +58,13 @@ AGENT_SANDBOX_OPENSANDBOX_API_KEY=replace_with_opensandbox_api_key ...@@ -53,12 +58,13 @@ AGENT_SANDBOX_OPENSANDBOX_API_KEY=replace_with_opensandbox_api_key
# Docker compose 部署使用 docker runtime # Docker compose 部署使用 docker runtime
AGENT_SANDBOX_OPENSANDBOX_RUNTIME=docker AGENT_SANDBOX_OPENSANDBOX_RUNTIME=docker
# OpenSandbox 创建 Agent Sandbox 时使用的运行态镜像 # OpenSandbox 创建 Agent Sandbox 时使用的运行态镜像
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO=registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt-agent-sandbox AGENT_SANDBOX_OPENSANDBOX_IMAGE=registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt-agent-sandbox:v0.2.0
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG=v0.2.0
AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY=true AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY=true
# 持久卷管理服务地址和 Token,需要与 x-volume-manager-auth-token 一致。 # 持久卷管理服务地址和 Token,需要与 x-volume-manager-auth-token 一致。
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL=http://fastgpt-volume-manager:3000 AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL=http://fastgpt-volume-manager:3000
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN=replace_with_volume_manager_token AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN=replace_with_volume_manager_token
# FastGPT app 生成持久卷 claimName 时使用的前缀
AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX=fastgpt-session
# Agent Sandbox 单实例 CPU 核数和内存上限(MiB) # Agent Sandbox 单实例 CPU 核数和内存上限(MiB)
AGENT_SANDBOX_CPU_COUNT=1 AGENT_SANDBOX_CPU_COUNT=1
AGENT_SANDBOX_MEMORY_MIB=2048 AGENT_SANDBOX_MEMORY_MIB=2048
......
...@@ -211,7 +211,7 @@ If you use an internal Harbor, private registry, or image mirror, download `dock ...@@ -211,7 +211,7 @@ If you use an internal Harbor, private registry, or image mirror, download `dock
FASTGPT_LOCAL_COMPOSE_PATH=./docker-compose.yml bash install.sh FASTGPT_LOCAL_COMPOSE_PATH=./docker-compose.yml bash install.sh
``` ```
If Agent/Skill Sandbox is enabled, deploy `fastgpt-agent-sandbox-proxy` separately and also replace `AGENT_SANDBOX_SEALOS_IMAGE` or `AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO` so the sandbox provider can pull the `fastgpt-agent-sandbox` image. The v4.15.0 default deployment file does not include `fastgpt-agent-sandbox-proxy` or start OpenSandbox by default. See the [V4.15.0 upgrade notes](../upgrading/4-15/41500.en.mdx#5-agent-sandbox-deployment-options) for sandbox provider setup. If Agent/Skill Sandbox is enabled, deploy `fastgpt-agent-sandbox-proxy` separately and also replace `AGENT_SANDBOX_SEALOS_IMAGE` or `AGENT_SANDBOX_OPENSANDBOX_IMAGE` so the sandbox provider can pull the `fastgpt-agent-sandbox` image. The v4.15.0 default deployment file does not include `fastgpt-agent-sandbox-proxy` or start OpenSandbox by default. See the [V4.15.0 upgrade notes](../upgrading/4-15/41500.en.mdx#5-agent-sandbox-deployment-options) for sandbox provider setup.
### 2. Modify Environment Variables ### 2. Modify Environment Variables
......
...@@ -203,7 +203,7 @@ FASTGPT_LOCAL_COMPOSE_PATH=./docker-compose.source.yml bash install.sh ...@@ -203,7 +203,7 @@ FASTGPT_LOCAL_COMPOSE_PATH=./docker-compose.source.yml bash install.sh
FASTGPT_LOCAL_COMPOSE_PATH=./docker-compose.yml bash install.sh FASTGPT_LOCAL_COMPOSE_PATH=./docker-compose.yml bash install.sh
``` ```
如果启用 Agent/Skill 沙盒,还需要单独部署 `fastgpt-agent-sandbox-proxy`,并同步替换 `AGENT_SANDBOX_SEALOS_IMAGE` 或 `AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO`,确保沙盒 provider 可以拉取 `fastgpt-agent-sandbox` 镜像。v4.15.0 默认部署文件不内置 `fastgpt-agent-sandbox-proxy`,也不会默认启动 OpenSandbox;沙盒 provider 的接入方式见 [V4.15.0 升级说明](../upgrading/4-15/41500.mdx#5-agent-sandbox-部署方案)。 如果启用 Agent/Skill 沙盒,还需要单独部署 `fastgpt-agent-sandbox-proxy`,并同步替换 `AGENT_SANDBOX_SEALOS_IMAGE` 或 `AGENT_SANDBOX_OPENSANDBOX_IMAGE`,确保沙盒 provider 可以拉取 `fastgpt-agent-sandbox` 镜像。v4.15.0 默认部署文件不内置 `fastgpt-agent-sandbox-proxy`,也不会默认启动 OpenSandbox;沙盒 provider 的接入方式见 [V4.15.0 升级说明](../upgrading/4-15/41500.mdx#5-agent-sandbox-部署方案)。
### 2. 修改环境变量 ### 2. 修改环境变量
......
...@@ -111,8 +111,8 @@ ...@@ -111,8 +111,8 @@
"content/guide/dataset/faq.mdx": "2026-06-04T16:10:15+08:00", "content/guide/dataset/faq.mdx": "2026-06-04T16:10:15+08:00",
"content/guide/dataset/rag.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/dataset/rag.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/dataset/rag.mdx": "2026-05-07T15:06:40+08:00", "content/guide/dataset/rag.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/dataset/template.en.mdx": "2026-06-04T16:10:15+08:00", "content/guide/dataset/template.en.mdx": "2026-08-04T22:08:29+08:00",
"content/guide/dataset/template.mdx": "2026-06-04T16:10:15+08:00", "content/guide/dataset/template.mdx": "2026-08-04T22:08:29+08:00",
"content/guide/dataset/third-party/api_dataset.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/dataset/third-party/api_dataset.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/dataset/third-party/api_dataset.mdx": "2026-05-07T15:06:40+08:00", "content/guide/dataset/third-party/api_dataset.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/dataset/third-party/dingtalk_dataset.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/dataset/third-party/dingtalk_dataset.en.mdx": "2026-05-07T15:06:40+08:00",
...@@ -169,8 +169,8 @@ ...@@ -169,8 +169,8 @@
"content/plugin/model-presets.mdx": "2026-06-04T16:10:15+08:00", "content/plugin/model-presets.mdx": "2026-06-04T16:10:15+08:00",
"content/plugin/system-tool-development.en.mdx": "2026-07-02T11:54:55+08:00", "content/plugin/system-tool-development.en.mdx": "2026-07-02T11:54:55+08:00",
"content/plugin/system-tool-development.mdx": "2026-07-02T11:54:55+08:00", "content/plugin/system-tool-development.mdx": "2026-07-02T11:54:55+08:00",
"content/self-host/config/env.en.mdx": "2026-07-30T15:22:52+08:00", "content/self-host/config/env.en.mdx": "2026-08-04T12:18:23+08:00",
"content/self-host/config/env.mdx": "2026-07-30T15:22:52+08:00", "content/self-host/config/env.mdx": "2026-08-04T12:18:23+08:00",
"content/self-host/config/model/intro.en.mdx": "2026-06-04T16:10:15+08:00", "content/self-host/config/model/intro.en.mdx": "2026-06-04T16:10:15+08:00",
"content/self-host/config/model/intro.mdx": "2026-06-04T16:10:15+08:00", "content/self-host/config/model/intro.mdx": "2026-06-04T16:10:15+08:00",
"content/self-host/config/model/minimax.en.mdx": "2026-06-03T10:40:17+08:00", "content/self-host/config/model/minimax.en.mdx": "2026-06-03T10:40:17+08:00",
...@@ -183,8 +183,8 @@ ...@@ -183,8 +183,8 @@
"content/self-host/config/remote-debug-suite.mdx": "2026-06-27T22:05:51+08:00", "content/self-host/config/remote-debug-suite.mdx": "2026-06-27T22:05:51+08:00",
"content/self-host/config/sandbox/common.en.mdx": "2026-07-30T15:22:52+08:00", "content/self-host/config/sandbox/common.en.mdx": "2026-07-30T15:22:52+08:00",
"content/self-host/config/sandbox/common.mdx": "2026-07-30T15:22:52+08:00", "content/self-host/config/sandbox/common.mdx": "2026-07-30T15:22:52+08:00",
"content/self-host/config/sandbox/opensandbox.en.mdx": "2026-07-30T15:22:52+08:00", "content/self-host/config/sandbox/opensandbox.en.mdx": "2026-08-04T12:18:23+08:00",
"content/self-host/config/sandbox/opensandbox.mdx": "2026-07-30T15:22:52+08:00", "content/self-host/config/sandbox/opensandbox.mdx": "2026-08-04T12:18:23+08:00",
"content/self-host/config/sandbox/sealosdevbox.en.mdx": "2026-07-30T15:22:52+08:00", "content/self-host/config/sandbox/sealosdevbox.en.mdx": "2026-07-30T15:22:52+08:00",
"content/self-host/config/sandbox/sealosdevbox.mdx": "2026-07-30T15:22:52+08:00", "content/self-host/config/sandbox/sealosdevbox.mdx": "2026-07-30T15:22:52+08:00",
"content/self-host/config/signoz.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/config/signoz.en.mdx": "2026-04-26T21:08:47+08:00",
...@@ -205,8 +205,8 @@ ...@@ -205,8 +205,8 @@
"content/self-host/custom-models/ollama.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/custom-models/ollama.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/custom-models/xinference.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/custom-models/xinference.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/custom-models/xinference.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/custom-models/xinference.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/deploy/docker.en.mdx": "2026-07-04T00:03:29+08:00", "content/self-host/deploy/docker.en.mdx": "2026-08-03T18:37:56+08:00",
"content/self-host/deploy/docker.mdx": "2026-07-04T00:03:29+08:00", "content/self-host/deploy/docker.mdx": "2026-08-03T18:37:56+08:00",
"content/self-host/deploy/sealos.en.mdx": "2026-06-30T22:10:03+08:00", "content/self-host/deploy/sealos.en.mdx": "2026-06-30T22:10:03+08:00",
"content/self-host/deploy/sealos.mdx": "2026-06-30T22:10:03+08:00", "content/self-host/deploy/sealos.mdx": "2026-06-30T22:10:03+08:00",
"content/self-host/design/dataset.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/design/dataset.en.mdx": "2026-04-26T21:08:47+08:00",
...@@ -333,8 +333,8 @@ ...@@ -333,8 +333,8 @@
"content/self-host/upgrading/4-15/4155.mdx": "2026-07-30T11:22:58+08:00", "content/self-host/upgrading/4-15/4155.mdx": "2026-07-30T11:22:58+08:00",
"content/self-host/upgrading/4-15/4156.en.mdx": "2026-07-31T17:46:31+08:00", "content/self-host/upgrading/4-15/4156.en.mdx": "2026-07-31T17:46:31+08:00",
"content/self-host/upgrading/4-15/4156.mdx": "2026-07-31T17:46:31+08:00", "content/self-host/upgrading/4-15/4156.mdx": "2026-07-31T17:46:31+08:00",
"content/self-host/upgrading/4-16/41601.en.mdx": "2026-08-04T20:18:35+08:00", "content/self-host/upgrading/4-16/41601.en.mdx": "2026-08-04T22:08:29+08:00",
"content/self-host/upgrading/4-16/41601.mdx": "2026-08-04T20:18:35+08:00", "content/self-host/upgrading/4-16/41601.mdx": "2026-08-04T22:08:29+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-07-25T00:27:20+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-07-25T00:27:20+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-07-25T00:27:20+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-07-25T00:27:20+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-07-25T00:27:20+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-07-25T00:27:20+08:00",
...@@ -475,6 +475,6 @@ ...@@ -475,6 +475,6 @@
"content/self-host/upgrading/outdated/499.mdx": "2026-05-07T15:06:40+08:00", "content/self-host/upgrading/outdated/499.mdx": "2026-05-07T15:06:40+08:00",
"content/self-host/upgrading/upgrade-intruction.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/upgrade-intruction.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/upgrade-intruction.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/upgrade-intruction.mdx": "2026-04-26T21:08:47+08:00",
"content/toc.en.mdx": "2026-07-31T17:46:31+08:00", "content/toc.en.mdx": "2026-08-04T22:08:29+08:00",
"content/toc.mdx": "2026-07-31T17:46:31+08:00" "content/toc.mdx": "2026-08-04T22:08:29+08:00"
} }
\ No newline at end of file
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
# AGENT_SANDBOX_OPENSANDBOX_API_KEY=fastgpt-opensandbox-api-key # AGENT_SANDBOX_OPENSANDBOX_API_KEY=fastgpt-opensandbox-api-key
# AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL=http://fastgpt-volume-manager:3000 # AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL=http://fastgpt-volume-manager:3000
# AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN=vmtoken # AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN=vmtoken
# AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX=fastgpt-session
x-volume-manager-auth-token: &x-volume-manager-auth-token "vmtoken" x-volume-manager-auth-token: &x-volume-manager-auth-token "vmtoken"
x-no-proxy-config: &x-no-proxy-config x-no-proxy-config: &x-no-proxy-config
...@@ -55,7 +56,6 @@ services: ...@@ -55,7 +56,6 @@ services:
PORT: 3000 PORT: 3000
VM_RUNTIME: docker VM_RUNTIME: docker
VM_AUTH_TOKEN: *x-volume-manager-auth-token VM_AUTH_TOKEN: *x-volume-manager-auth-token
VM_VOLUME_NAME_PREFIX: fastgpt-session
VM_LOG_LEVEL: info VM_LOG_LEVEL: info
healthcheck: healthcheck:
test: test:
......
import z from 'zod';
export const SANDBOX_WORKSPACE_VOLUME_NAME = 'workspace';
// Kubernetes PVC 与 Docker named volume 统一使用 DNS label 子集。
export const SANDBOX_VOLUME_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,251}[a-z0-9])?$/;
export const SandboxVolumeNameSchema = z.string().trim().regex(SANDBOX_VOLUME_NAME_RE, {
message: 'Volume name must use lowercase alphanumeric characters and hyphens'
});
export const SandboxVolumeEnsureRequestSchema = z.object({
claimName: SandboxVolumeNameSchema,
storageSize: z.string().trim().min(1).max(64).optional()
});
export type SandboxVolumeEnsureRequest = z.infer<typeof SandboxVolumeEnsureRequestSchema>;
export const SandboxVolumeEnsureResponseSchema = z.object({
claimName: SandboxVolumeNameSchema,
created: z.boolean()
});
export type SandboxVolumeEnsureResponse = z.infer<typeof SandboxVolumeEnsureResponseSchema>;
...@@ -13,7 +13,10 @@ import { ...@@ -13,7 +13,10 @@ import {
} from '../../infrastructure/instance/legacyRepository'; } from '../../infrastructure/instance/legacyRepository';
import type { LegacySandboxInstanceSchemaType } from '../../infrastructure/instance/legacySchema'; import type { LegacySandboxInstanceSchemaType } from '../../infrastructure/instance/legacySchema';
import { buildSandboxResourceAdapter } from '../../infrastructure/provider/adapter'; import { buildSandboxResourceAdapter } from '../../infrastructure/provider/adapter';
import { deleteSessionVolume } from '../../infrastructure/volume/service'; import {
deleteSessionVolume,
getSessionVolumeClaimName
} from '../../infrastructure/volume/service';
import { getSandboxWorkspaceArchiveForMigration } from '../archive'; import { getSandboxWorkspaceArchiveForMigration } from '../archive';
import { import {
deleteAppSandboxes as deleteCurrentAppSandboxes, deleteAppSandboxes as deleteCurrentAppSandboxes,
...@@ -68,10 +71,14 @@ async function deleteLegacyPhysicalResources(params: { ...@@ -68,10 +71,14 @@ async function deleteLegacyPhysicalResources(params: {
} }
}); });
if (resource.provider === 'opensandbox') { if (resource.provider === 'opensandbox') {
const claimName = getSessionVolumeClaimName(resource.storage);
if (!claimName) {
throw new Error(`OpenSandbox ${resource.sandboxId} has no persisted workspace claimName`);
}
await runLegacyCleanupStep({ await runLegacyCleanupStep({
step: 'delete_volume', step: 'delete_volume',
assertLeaseValid, assertLeaseValid,
fn: () => deleteSessionVolume(resource.sandboxId) fn: () => deleteSessionVolume(claimName)
}); });
} }
} }
......
...@@ -15,7 +15,10 @@ import { ...@@ -15,7 +15,10 @@ import {
type LegacySandboxSourceUpdate type LegacySandboxSourceUpdate
} from '../../infrastructure/instance/legacyRepository'; } from '../../infrastructure/instance/legacyRepository';
import { buildSandboxResourceAdapter } from '../../infrastructure/provider/adapter'; import { buildSandboxResourceAdapter } from '../../infrastructure/provider/adapter';
import { deleteSessionVolume } from '../../infrastructure/volume/service'; import {
deleteSessionVolume,
getSessionVolumeClaimName
} from '../../infrastructure/volume/service';
import { withSandboxLifecycleLease } from '../lease'; import { withSandboxLifecycleLease } from '../lease';
import { cleanupLegacySkillDebugChats } from './debugChatCleanup'; import { cleanupLegacySkillDebugChats } from './debugChatCleanup';
import type { LegacySandboxNormalizationResult } from './types'; import type { LegacySandboxNormalizationResult } from './types';
...@@ -62,7 +65,11 @@ const deleteLegacyOrphanSandbox = async (doc: LegacySandboxNormalizationDoc) => ...@@ -62,7 +65,11 @@ const deleteLegacyOrphanSandbox = async (doc: LegacySandboxNormalizationDoc) =>
await buildSandboxResourceAdapter(doc).delete(); await buildSandboxResourceAdapter(doc).delete();
assertValid(); assertValid();
if (doc.provider === 'opensandbox') { if (doc.provider === 'opensandbox') {
await deleteSessionVolume(doc.sandboxId); const claimName = getSessionVolumeClaimName(doc.storage);
if (!claimName) {
throw new Error(`OpenSandbox ${doc.sandboxId} has no persisted workspace claimName`);
}
await deleteSessionVolume(claimName);
assertValid(); assertValid();
} }
await getS3SandboxSource().deleteLegacyWorkspaceArchiveNow({ sandboxId: doc.sandboxId }); await getS3SandboxSource().deleteLegacyWorkspaceArchiveNow({ sandboxId: doc.sandboxId });
......
...@@ -21,9 +21,16 @@ import { ...@@ -21,9 +21,16 @@ import {
claimSkillSandboxMigrationTarget, claimSkillSandboxMigrationTarget,
completeSandboxOperation, completeSandboxOperation,
findSandboxInstanceBySource, findSandboxInstanceBySource,
markSandboxOperationFailed markSandboxOperationFailed,
type SandboxResourceDoc
} from '../../infrastructure/instance/repository'; } from '../../infrastructure/instance/repository';
import { getConfiguredSandboxProvider } from '../../infrastructure/provider/config'; import { getConfiguredSandboxProvider } from '../../infrastructure/provider/config';
import {
buildVolumeConfig,
createLegacySessionVolumeClaimName,
createSessionVolumeClaimName,
getSessionVolumeClaimName
} from '../../infrastructure/volume/service';
import { SandboxInstanceStatusEnum } from '../../type'; import { SandboxInstanceStatusEnum } from '../../type';
import { SANDBOX_STALE_ARCHIVING_MINUTES, restoreArchivedSandboxBeforeUse } from '../archive'; import { SANDBOX_STALE_ARCHIVING_MINUTES, restoreArchivedSandboxBeforeUse } from '../archive';
import { import {
...@@ -59,6 +66,60 @@ const SKILL_SANDBOX_MIGRATION_CONCURRENCY = 20; ...@@ -59,6 +66,60 @@ const SKILL_SANDBOX_MIGRATION_CONCURRENCY = 20;
type UserSandboxMigrationTrackData = Parameters<typeof pushTrack.userSandboxMigration>[0]; type UserSandboxMigrationTrackData = Parameters<typeof pushTrack.userSandboxMigration>[0];
/**
* OpenSandbox migration target 必须先提交 volume generation,再创建远端资源。
* 旧版本停在 targetEnsured 但缺少 storage 时,补记其已经挂载的旧确定性 volume。
*/
const assignMigrationTargetVolume = async (params: {
target: SandboxResourceDoc;
operationId: string;
operationPhase: string;
}) => {
let storage = params.target.storage;
if (params.target.provider !== 'opensandbox') {
return { operationPhase: params.operationPhase, storage };
}
if (params.operationPhase === 'targetEnsured' && !getSessionVolumeClaimName(storage)) {
const claimName = createLegacySessionVolumeClaimName(params.target.sandboxId);
storage = buildVolumeConfig(claimName).storage;
const repaired = await advanceSandboxOperation({
resource: params.target,
operationId: params.operationId,
status: SandboxInstanceStatusEnum.legacyMigrating,
phase: 'targetEnsured',
set: { storage }
});
if (!repaired) {
throw new Error('Sandbox migration lost ownership while repairing Legacy volume storage');
}
return { operationPhase: 'targetEnsured', storage };
}
if (params.operationPhase !== 'claimed') {
return { operationPhase: params.operationPhase, storage };
}
const claimName =
getSessionVolumeClaimName(storage) ??
createSessionVolumeClaimName({
sandboxId: params.target.sandboxId,
generationId: '0'
});
storage = buildVolumeConfig(claimName).storage;
const assigned = await advanceSandboxOperation({
resource: params.target,
operationId: params.operationId,
status: SandboxInstanceStatusEnum.legacyMigrating,
phase: 'volumeAssigned',
set: { storage }
});
if (!assigned) {
throw new Error('Sandbox migration lost ownership after volume assignment');
}
return { operationPhase: 'volumeAssigned', storage };
};
const recordMigrationTrack = async (data: UserSandboxMigrationTrackData) => { const recordMigrationTrack = async (data: UserSandboxMigrationTrackData) => {
await Promise.resolve(pushTrack.userSandboxMigration(data)).catch(() => undefined); await Promise.resolve(pushTrack.userSandboxMigration(data)).catch(() => undefined);
}; };
...@@ -246,14 +307,25 @@ const migrateLegacySkill = async (item: ResolvedLegacySkill) => { ...@@ -246,14 +307,25 @@ const migrateLegacySkill = async (item: ResolvedLegacySkill) => {
let operationPhase = targetDoc.operation.phase; let operationPhase = targetDoc.operation.phase;
try { try {
assertLeasesValid(); assertLeasesValid();
const volumeAssignment = await assignMigrationTargetVolume({
target: targetDoc,
operationId,
operationPhase
});
operationPhase = volumeAssignment.operationPhase;
const claimName =
targetDoc.provider === 'opensandbox'
? getSessionVolumeClaimName(volumeAssignment.storage)
: undefined;
const target = await createMigrationTarget({ const target = await createMigrationTarget({
provider: targetDoc.provider, provider: targetDoc.provider,
sandboxId: targetDoc.sandboxId, sandboxId: targetDoc.sandboxId,
sourceType: ChatSourceTypeEnum.skillEdit, sourceType: ChatSourceTypeEnum.skillEdit,
limit: item.doc.limit limit: item.doc.limit,
claimName
}); });
assertLeasesValid(); assertLeasesValid();
if (operationPhase === 'claimed') { if (operationPhase === 'claimed' || operationPhase === 'volumeAssigned') {
const ensured = await advanceSandboxOperation({ const ensured = await advanceSandboxOperation({
resource: targetDoc, resource: targetDoc,
operationId, operationId,
...@@ -445,15 +517,26 @@ const migrateAppGroup = async (params: { ...@@ -445,15 +517,26 @@ const migrateAppGroup = async (params: {
try { try {
assertLeasesValid(); assertLeasesValid();
const volumeAssignment = await assignMigrationTargetVolume({
target: targetDoc,
operationId,
operationPhase
});
operationPhase = volumeAssignment.operationPhase;
const claimName =
targetDoc.provider === 'opensandbox'
? getSessionVolumeClaimName(volumeAssignment.storage)
: undefined;
const target = await createMigrationTarget({ const target = await createMigrationTarget({
provider: targetDoc.provider, provider: targetDoc.provider,
sandboxId: targetDoc.sandboxId, sandboxId: targetDoc.sandboxId,
sourceType: ChatSourceTypeEnum.app, sourceType: ChatSourceTypeEnum.app,
chatId: first.chatId, chatId: first.chatId,
limit: first.doc.limit limit: first.doc.limit,
claimName
}); });
assertLeasesValid(); assertLeasesValid();
if (operationPhase === 'claimed') { if (operationPhase === 'claimed' || operationPhase === 'volumeAssigned') {
const ensured = await advanceSandboxOperation({ const ensured = await advanceSandboxOperation({
resource: targetDoc, resource: targetDoc,
operationId, operationId,
......
...@@ -30,6 +30,7 @@ export const toLegacyResource = (doc: LegacySandboxInstanceSchemaType) => ({ ...@@ -30,6 +30,7 @@ export const toLegacyResource = (doc: LegacySandboxInstanceSchemaType) => ({
sandboxId: doc.sandboxId, sandboxId: doc.sandboxId,
status: doc.status ?? SandboxStatusEnum.stopped, status: doc.status ?? SandboxStatusEnum.stopped,
lastActiveAt: doc.lastActiveAt ?? new Date(0), lastActiveAt: doc.lastActiveAt ?? new Date(0),
storage: doc.storage,
metadata: doc.metadata metadata: doc.metadata
}); });
......
...@@ -37,9 +37,15 @@ export const createMigrationTarget = async (params: { ...@@ -37,9 +37,15 @@ export const createMigrationTarget = async (params: {
sourceType: ChatSourceTypeEnum.app | ChatSourceTypeEnum.skillEdit; sourceType: ChatSourceTypeEnum.app | ChatSourceTypeEnum.skillEdit;
chatId?: string; chatId?: string;
limit?: LegacySandboxInstanceSchemaType['limit']; limit?: LegacySandboxInstanceSchemaType['limit'];
claimName?: string;
}): Promise<LegacyMigrationTarget> => { }): Promise<LegacyMigrationTarget> => {
const vmConfig = const vmConfig = await (async () => {
params.provider === 'opensandbox' ? await getSessionVolumeConfig(params.sandboxId) : undefined; if (params.provider !== 'opensandbox') return;
if (!params.claimName) {
throw new Error(`OpenSandbox ${params.sandboxId} has no assigned workspace claimName`);
}
return getSessionVolumeConfig(params.claimName);
})();
const provider = buildRuntimeSandboxAdapter(params.provider, params.sandboxId, { const provider = buildRuntimeSandboxAdapter(params.provider, params.sandboxId, {
vmConfig, vmConfig,
resourceLimits: params.limit resourceLimits: params.limit
......
...@@ -32,6 +32,10 @@ export type SandboxLifecycleStep = { ...@@ -32,6 +32,10 @@ export type SandboxLifecycleStep = {
fromPhase: string; fromPhase: string;
toPhase: string; toPhase: string;
run: (context: SandboxLifecycleStepContext) => Promise<void>; run: (context: SandboxLifecycleStepContext) => Promise<void>;
/** 与 phase checkpoint 原子提交的 Mongo 字段,供后续远端副作用复用。 */
set?:
| Record<string, unknown>
| ((context: SandboxLifecycleStepContext) => Record<string, unknown>);
}; };
type SandboxLifecycleFinish = type SandboxLifecycleFinish =
...@@ -151,11 +155,13 @@ export async function runSandboxLifecycleOperation( ...@@ -151,11 +155,13 @@ export async function runSandboxLifecycleOperation(
context.assertValid(); context.assertValid();
await step.run(context); await step.run(context);
context.assertValid(); context.assertValid();
const set = typeof step.set === 'function' ? step.set(context) : step.set;
const advanced = await advanceSandboxOperation({ const advanced = await advanceSandboxOperation({
resource: claimed, resource: claimed,
operationId, operationId,
status: definition.status, status: definition.status,
phase: step.toPhase phase: step.toPhase,
set
}); });
if (!advanced) { if (!advanced) {
throw new Error( throw new Error(
......
...@@ -14,7 +14,11 @@ import { ...@@ -14,7 +14,11 @@ import {
type SandboxProviderType type SandboxProviderType
} from '../type'; } from '../type';
import { buildSandboxResourceAdapter } from '../infrastructure/provider/adapter'; import { buildSandboxResourceAdapter } from '../infrastructure/provider/adapter';
import { deleteSessionVolume } from '../infrastructure/volume/service'; import {
createLegacySessionVolumeClaimName,
deleteSessionVolume,
getSessionVolumeClaimName
} from '../infrastructure/volume/service';
import { import {
archiveSandboxResourceWithinLease, archiveSandboxResourceWithinLease,
SANDBOX_STALE_ARCHIVING_MINUTES, SANDBOX_STALE_ARCHIVING_MINUTES,
...@@ -86,7 +90,17 @@ export async function migrateSandboxProviderBeforeUse(params: { ...@@ -86,7 +90,17 @@ export async function migrateSandboxProviderBeforeUse(params: {
} }
const rollbackFromPhase = const rollbackFromPhase =
resource.operation?.phase === 'archiveInstalled' ? 'archiveInstalled' : 'claimed'; resource.operation?.phase === 'archiveInstalled' ||
resource.operation?.phase === 'volumeAssigned' ||
resource.operation?.phase === 'previousProviderDeleted' ||
resource.operation?.phase === 'previousVolumeDeleted'
? resource.operation.phase
: 'claimed';
const allowLegacyClaimNameFallback = [
'claimed',
'previousProviderDeleted',
'previousVolumeDeleted'
].includes(rollbackFromPhase);
const definition: SandboxLifecycleDefinition = { const definition: SandboxLifecycleDefinition = {
operationType: SandboxOperationTypeEnum.restore, operationType: SandboxOperationTypeEnum.restore,
status: SandboxInstanceStatusEnum.restoring, status: SandboxInstanceStatusEnum.restoring,
...@@ -97,7 +111,17 @@ export async function migrateSandboxProviderBeforeUse(params: { ...@@ -97,7 +111,17 @@ export async function migrateSandboxProviderBeforeUse(params: {
run: async ({ resource: claimed }) => { run: async ({ resource: claimed }) => {
await buildSandboxResourceAdapter(claimed).delete(); await buildSandboxResourceAdapter(claimed).delete();
if (claimed.provider === 'opensandbox') { if (claimed.provider === 'opensandbox') {
await deleteSessionVolume(claimed.sandboxId); const claimName =
getSessionVolumeClaimName(claimed.storage) ??
(allowLegacyClaimNameFallback
? createLegacySessionVolumeClaimName(claimed.sandboxId)
: undefined);
if (!claimName) {
throw new Error(
`OpenSandbox ${claimed.sandboxId} has no persisted workspace claimName`
);
}
await deleteSessionVolume(claimName);
} }
} }
} }
......
...@@ -21,7 +21,7 @@ import { ...@@ -21,7 +21,7 @@ import {
} from '../infrastructure/instance/repository'; } from '../infrastructure/instance/repository';
import { getSandboxProviderConfig } from '../infrastructure/provider/config'; import { getSandboxProviderConfig } from '../infrastructure/provider/config';
import { buildSandboxResourceAdapter } from '../infrastructure/provider/adapter'; import { buildSandboxResourceAdapter } from '../infrastructure/provider/adapter';
import { deleteSessionVolume } from '../infrastructure/volume/service'; import { deleteSessionVolume, getSessionVolumeClaimName } from '../infrastructure/volume/service';
import { import {
SandboxInstanceStatusEnum, SandboxInstanceStatusEnum,
SandboxOperationTypeEnum, SandboxOperationTypeEnum,
...@@ -147,7 +147,13 @@ export async function deleteSandboxResource(resource: SandboxResourceRef): Promi ...@@ -147,7 +147,13 @@ export async function deleteSandboxResource(resource: SandboxResourceRef): Promi
toPhase: 'volumeDeleted', toPhase: 'volumeDeleted',
run: async ({ resource: claimed }) => { run: async ({ resource: claimed }) => {
if (claimed.provider === 'opensandbox') { if (claimed.provider === 'opensandbox') {
await deleteSessionVolume(claimed.sandboxId); const claimName = getSessionVolumeClaimName(claimed.storage);
if (!claimName) {
throw new Error(
`OpenSandbox ${claimed.sandboxId} has no persisted workspace claimName`
);
}
await deleteSessionVolume(claimName);
} }
} }
}, },
......
...@@ -16,7 +16,10 @@ import { ...@@ -16,7 +16,10 @@ import {
} from '@fastgpt-sdk/sandbox-adapter'; } from '@fastgpt-sdk/sandbox-adapter';
import { isRedisLeaseError, type RedisLeaseContext } from '@fastgpt/dal/redis/caches'; import { isRedisLeaseError, type RedisLeaseContext } from '@fastgpt/dal/redis/caches';
import { import {
buildVolumeConfig,
createSessionVolumeClaimName,
getSessionVolumeConfig, getSessionVolumeConfig,
getSessionVolumeClaimName,
type VolumeManagerResult type VolumeManagerResult
} from '../../infrastructure/volume/service'; } from '../../infrastructure/volume/service';
import { buildRuntimeSandboxAdapter } from '../../infrastructure/provider/adapter'; import { buildRuntimeSandboxAdapter } from '../../infrastructure/provider/adapter';
...@@ -95,7 +98,8 @@ export class SandboxClient { ...@@ -95,7 +98,8 @@ export class SandboxClient {
private sandboxId: string; private sandboxId: string;
private providerName: SandboxProviderType; private providerName: SandboxProviderType;
private runtimePaths: SandboxRuntimePaths; private runtimePaths: SandboxRuntimePaths;
readonly provider: ISandbox; private workspaceClaimName?: string;
private runtimeProvider: ISandbox;
constructor( constructor(
private readonly props: SandboxClientProps, private readonly props: SandboxClientProps,
...@@ -108,12 +112,17 @@ export class SandboxClient { ...@@ -108,12 +112,17 @@ export class SandboxClient {
this.chatId = props.chatId; this.chatId = props.chatId;
this.providerName = opts.providerName ?? getConfiguredSandboxProvider(); this.providerName = opts.providerName ?? getConfiguredSandboxProvider();
this.workspaceClaimName = getSessionVolumeClaimName(opts.vmConfig?.storage);
this.runtimePaths = getSandboxRuntimePaths({ this.runtimePaths = getSandboxRuntimePaths({
sourceType: this.sourceType, sourceType: this.sourceType,
workDirectory: getSandboxRuntimeProfile(this.providerName).workDirectory, workDirectory: getSandboxRuntimeProfile(this.providerName).workDirectory,
chatId: this.chatId chatId: this.chatId
}); });
this.provider = buildRuntimeSandboxAdapter(this.providerName, this.sandboxId, opts); this.runtimeProvider = buildRuntimeSandboxAdapter(this.providerName, this.sandboxId, opts);
}
get provider(): ISandbox {
return this.runtimeProvider;
} }
/** /**
...@@ -130,22 +139,35 @@ export class SandboxClient { ...@@ -130,22 +139,35 @@ export class SandboxClient {
sandboxId: this.sandboxId, sandboxId: this.sandboxId,
createConfig: this.opts.createConfig createConfig: this.opts.createConfig
}); });
const instanceParams = { const instanceIdentity = {
provider: this.providerName, provider: this.providerName,
sandboxId: this.sandboxId, sandboxId: this.sandboxId,
sourceType: this.sourceType, sourceType: this.sourceType,
sourceId: this.sourceId, sourceId: this.sourceId,
userId: this.userId, userId: this.userId
storage: this.opts?.vmConfig?.storage, };
...(this.opts?.resourceLimits && { const instanceLimit = this.opts.resourceLimits
limit: { ? {
cpuCount: this.opts.resourceLimits.cpuCount, cpuCount: this.opts.resourceLimits.cpuCount,
memoryMiB: this.opts.resourceLimits.memoryMiB, memoryMiB: this.opts.resourceLimits.memoryMiB,
storageSize: this.opts.resourceLimits.storageSize storageSize: this.opts.resourceLimits.storageSize
} }
}) : undefined;
const instanceParams = {
...instanceIdentity,
storage: this.opts.vmConfig?.storage,
...(instanceLimit ? { limit: instanceLimit } : {})
};
const touchRunning = async (expectedWorkspaceClaimName = this.workspaceClaimName) => {
if (this.providerName === 'opensandbox' && !expectedWorkspaceClaimName) return null;
return touchRunningSandboxInstance({
...instanceIdentity,
...(this.providerName === 'opensandbox' ? { expectedWorkspaceClaimName } : {}),
...(instanceLimit ? { limit: instanceLimit } : {})
});
}; };
const touched = await touchRunningSandboxInstance(instanceParams); const touched = await touchRunning();
let repairMissingProvider = false; let repairMissingProvider = false;
if (touched) { if (touched) {
try { try {
...@@ -189,8 +211,27 @@ export class SandboxClient { ...@@ -189,8 +211,27 @@ export class SandboxClient {
if (current.provider !== this.providerName) { if (current.provider !== this.providerName) {
throw new Error(`Sandbox belongs to provider ${current.provider}`); throw new Error(`Sandbox belongs to provider ${current.provider}`);
} }
let workspaceClaimName: string | undefined;
if (this.providerName === 'opensandbox') {
workspaceClaimName = getSessionVolumeClaimName(current.storage);
if (!workspaceClaimName) {
throw new Error(`OpenSandbox ${this.sandboxId} has no persisted workspace claimName`);
}
if (workspaceClaimName !== this.workspaceClaimName) {
// lease 内数据库记录是 generation 真值;旧 client 必须切换 adapter 后才能继续自愈。
const vmConfig = buildVolumeConfig(workspaceClaimName);
this.runtimeProvider = buildRuntimeSandboxAdapter(this.providerName, this.sandboxId, {
...this.opts,
vmConfig
});
this.workspaceClaimName = workspaceClaimName;
}
}
if (current.status === SandboxInstanceStatusEnum.running) { if (current.status === SandboxInstanceStatusEnum.running) {
await touchRunningSandboxInstance(instanceParams); const runningTouched = await touchRunning(workspaceClaimName);
if (!runningTouched) {
throw new Error(`Sandbox ${this.sandboxId} storage changed during lifecycle operation`);
}
lease.assertValid(); lease.assertValid();
await ensureConnectedSandboxRunning(this.provider); await ensureConnectedSandboxRunning(this.provider);
lease.assertValid(); lease.assertValid();
...@@ -219,6 +260,7 @@ export class SandboxClient { ...@@ -219,6 +260,7 @@ export class SandboxClient {
toPhase: 'providerEnsured', toPhase: 'providerEnsured',
run: async () => { run: async () => {
await sourceGuard({ sourceType: this.sourceType, sourceId: this.sourceId }); await sourceGuard({ sourceType: this.sourceType, sourceId: this.sourceId });
if (workspaceClaimName) await getSessionVolumeConfig(workspaceClaimName);
await ensureConnectedSandboxRunning(this.provider); await ensureConnectedSandboxRunning(this.provider);
} }
} }
...@@ -455,9 +497,7 @@ export const getSandboxClient = async ( ...@@ -455,9 +497,7 @@ export const getSandboxClient = async (
sourceId: sandboxClientProps.sourceId, sourceId: sandboxClientProps.sourceId,
userId userId
}); });
vmConfig = vmConfig = await restoreArchivedSandboxBeforeUse({
providerName === 'opensandbox' ? await getSessionVolumeConfig(sandboxId) : undefined;
await restoreArchivedSandboxBeforeUse({
provider: providerName, provider: providerName,
sandboxId, sandboxId,
sourceType: sandboxClientProps.sourceType, sourceType: sandboxClientProps.sourceType,
...@@ -470,13 +510,23 @@ export const getSandboxClient = async ( ...@@ -470,13 +510,23 @@ export const getSandboxClient = async (
storageSize: opts.resourceLimits.storageSize storageSize: opts.resourceLimits.storageSize
} }
: undefined, : undefined,
vmConfig: vmConfig ?? null,
storage: vmConfig?.storage,
createConfig: opts.createConfig createConfig: opts.createConfig
}); });
} }
vmConfig ??= if (!vmConfig && providerName === 'opensandbox') {
providerName === 'opensandbox' ? await getSessionVolumeConfig(sandboxId) : undefined; const instance = await findSandboxInstanceBySource({
sourceType: sandboxClientProps.sourceType,
sourceId: sandboxClientProps.sourceId,
userId
});
const persistedClaimName = getSessionVolumeClaimName(instance?.storage);
if (instance && !persistedClaimName) {
throw new Error(`OpenSandbox ${sandboxId} has no persisted workspace claimName`);
}
const claimName =
persistedClaimName ?? createSessionVolumeClaimName({ sandboxId, generationId: '0' });
vmConfig = buildVolumeConfig(claimName);
}
const sandbox = new SandboxClient(sandboxClientProps, { const sandbox = new SandboxClient(sandboxClientProps, {
...opts, ...opts,
providerName, providerName,
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
*/ */
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
import { SANDBOX_WORKSPACE_VOLUME_NAME } from '@fastgpt/global/core/ai/sandbox/volume';
import { MongoSandboxInstance } from './schema'; import { MongoSandboxInstance } from './schema';
import { import {
SandboxInstanceStatusEnum, SandboxInstanceStatusEnum,
...@@ -54,6 +55,7 @@ export type SandboxResourceRef = Partial< ...@@ -54,6 +55,7 @@ export type SandboxResourceRef = Partial<
| 'teamId' | 'teamId'
| 'image' | 'image'
| 'versionId' | 'versionId'
| 'storage'
| 'operation' | 'operation'
> >
> & { > & {
...@@ -181,6 +183,7 @@ export async function advanceSandboxOperation(params: { ...@@ -181,6 +183,7 @@ export async function advanceSandboxOperation(params: {
operationId: string; operationId: string;
status: Exclude<SandboxInstanceStatusType, SandboxStableStatusType>; status: Exclude<SandboxInstanceStatusType, SandboxStableStatusType>;
phase: string; phase: string;
set?: Record<string, unknown>;
}) { }) {
return MongoSandboxInstance.findOneAndUpdate( return MongoSandboxInstance.findOneAndUpdate(
{ {
...@@ -190,6 +193,7 @@ export async function advanceSandboxOperation(params: { ...@@ -190,6 +193,7 @@ export async function advanceSandboxOperation(params: {
}, },
{ {
$set: { $set: {
...(params.set ?? {}),
'operation.phase': params.phase, 'operation.phase': params.phase,
'operation.heartbeatAt': new Date() 'operation.heartbeatAt': new Date()
}, },
...@@ -316,14 +320,19 @@ export async function createSandboxProvisioningInstance( ...@@ -316,14 +320,19 @@ export async function createSandboxProvisioningInstance(
} }
} }
/** 仅刷新已经发布的 running 记录;不存在或处于过渡态时绝不 upsert。 */ /**
* 仅刷新已经发布的 running 记录;不存在或处于过渡态时绝不 upsert。
*
* OpenSandbox 可传入预期 workspace claimName 做 CAS,防止 lease 外构造的旧 client 在 restore
* 提交新 generation 后继续命中快路径。storage 只能由 lifecycle checkpoint 写入,touch 不回写。
*/
export async function touchRunningSandboxInstance(params: { export async function touchRunningSandboxInstance(params: {
provider: SandboxProviderType; provider: SandboxProviderType;
sandboxId: string; sandboxId: string;
sourceType: ChatSourceTypeEnum; sourceType: ChatSourceTypeEnum;
sourceId: string; sourceId: string;
userId: string; userId: string;
storage?: SandboxInstanceSchemaType['storage']; expectedWorkspaceClaimName?: string;
limit?: Partial<NonNullable<SandboxInstanceSchemaType['limit']>>; limit?: Partial<NonNullable<SandboxInstanceSchemaType['limit']>>;
}) { }) {
return MongoSandboxInstance.findOneAndUpdate( return MongoSandboxInstance.findOneAndUpdate(
...@@ -334,12 +343,21 @@ export async function touchRunningSandboxInstance(params: { ...@@ -334,12 +343,21 @@ export async function touchRunningSandboxInstance(params: {
sourceId: params.sourceId, sourceId: params.sourceId,
userId: params.userId, userId: params.userId,
status: SandboxInstanceStatusEnum.running, status: SandboxInstanceStatusEnum.running,
operation: { $exists: false } operation: { $exists: false },
...(params.expectedWorkspaceClaimName
? {
'storage.volumes': {
$elemMatch: {
name: SANDBOX_WORKSPACE_VOLUME_NAME,
claimName: params.expectedWorkspaceClaimName
}
}
}
: {})
}, },
{ {
$set: { $set: {
lastActiveAt: new Date(), lastActiveAt: new Date(),
...(params.storage !== undefined ? { storage: params.storage } : {}),
...(params.limit ? { limit: params.limit } : {}) ...(params.limit ? { limit: params.limit } : {})
} }
}, },
......
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
import { serviceEnv } from '../../../../../../env'; import { serviceEnv } from '../../../../../../env';
import type { SandboxRuntimeProfile } from './types'; import type { SandboxRuntimeProfile } from './types';
import { getSandboxSkillsRootPath, mergeStringRecord, normalizeEntrypoint } from './utils'; import { getSandboxSkillsRootPath, mergeStringRecord, normalizeEntrypoint } from './utils';
import { OPEN_SANDBOX_DEFAULT_ROOT_PATH } from '@fastgpt-sdk/sandbox-adapter'; import { OPEN_SANDBOX_DEFAULT_ROOT_PATH, parseImageSpec } from '@fastgpt-sdk/sandbox-adapter';
const OPEN_SANDBOX_ENTRYPOINT = '/home/sandbox/entrypoint.sh'; const OPEN_SANDBOX_ENTRYPOINT = '/home/sandbox/entrypoint.sh';
const OPEN_SANDBOX_DOCKER_LOCAL_NETWORK_POLICY = { const OPEN_SANDBOX_DOCKER_LOCAL_NETWORK_POLICY = {
...@@ -30,10 +30,17 @@ const OPEN_SANDBOX_DOCKER_LOCAL_NETWORK_POLICY = { ...@@ -30,10 +30,17 @@ const OPEN_SANDBOX_DOCKER_LOCAL_NETWORK_POLICY = {
*/ */
export function buildOpenSandboxRuntimeProfile(): SandboxRuntimeProfile { export function buildOpenSandboxRuntimeProfile(): SandboxRuntimeProfile {
const workDirectory = OPEN_SANDBOX_DEFAULT_ROOT_PATH; const workDirectory = OPEN_SANDBOX_DEFAULT_ROOT_PATH;
const defaultImage = { const defaultImage = (() => {
repository: serviceEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO, const image = serviceEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE?.trim();
tag: serviceEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG if (image) return parseImageSpec(image);
};
const repository = serviceEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO?.trim() ?? '';
if (!repository) return { repository };
return {
repository,
tag: serviceEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG?.trim() || 'latest'
};
})();
return { return {
provider: 'opensandbox', provider: 'opensandbox',
...@@ -46,9 +53,7 @@ export function buildOpenSandboxRuntimeProfile(): SandboxRuntimeProfile { ...@@ -46,9 +53,7 @@ export function buildOpenSandboxRuntimeProfile(): SandboxRuntimeProfile {
const createConfig = input.createConfig ?? {}; const createConfig = input.createConfig ?? {};
const image = input.image ?? createConfig.image ?? defaultImage; const image = input.image ?? createConfig.image ?? defaultImage;
if (!image?.repository) { if (!image?.repository) {
throw new Error( throw new Error('AGENT_SANDBOX_OPENSANDBOX_IMAGE is required for opensandbox provider');
'AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO is required for opensandbox provider'
);
} }
const entrypoint = createConfig.entrypoint ?? normalizeEntrypoint(input.entrypoint); const entrypoint = createConfig.entrypoint ?? normalizeEntrypoint(input.entrypoint);
...@@ -88,6 +93,7 @@ export function buildOpenSandboxRuntimeProfile(): SandboxRuntimeProfile { ...@@ -88,6 +93,7 @@ export function buildOpenSandboxRuntimeProfile(): SandboxRuntimeProfile {
...createConfig, ...createConfig,
image, image,
resourceLimits, resourceLimits,
readyTimeoutSeconds: createConfig.readyTimeoutSeconds ?? 120,
...(entrypoint ? { entrypoint } : {}), ...(entrypoint ? { entrypoint } : {}),
...(env ? { env } : {}), ...(env ? { env } : {}),
...(metadata ? { metadata } : {}), ...(metadata ? { metadata } : {}),
......
...@@ -9,6 +9,7 @@ export type VolumeManagerConfig = { ...@@ -9,6 +9,7 @@ export type VolumeManagerConfig = {
enable: boolean; enable: boolean;
url: string; url: string;
token?: string; token?: string;
volumeNamePrefix: string;
storageSize: string; storageSize: string;
}; };
...@@ -22,6 +23,7 @@ export function getVolumeManagerEnvConfig(): VolumeManagerConfig { ...@@ -22,6 +23,7 @@ export function getVolumeManagerEnvConfig(): VolumeManagerConfig {
enable: true, enable: true,
url: serviceEnv.AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL!, url: serviceEnv.AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL!,
token: serviceEnv.AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN, token: serviceEnv.AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN,
volumeNamePrefix: serviceEnv.AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX,
storageSize: `${serviceEnv.AGENT_SANDBOX_STORAGE_SIZE_GI}Gi` storageSize: `${serviceEnv.AGENT_SANDBOX_STORAGE_SIZE_GI}Gi`
}; };
} }
...@@ -3,13 +3,21 @@ ...@@ -3,13 +3,21 @@
* *
* 只负责 volume API 调用和 provider 卷配置转换,不管理 sandbox 生命周期。 * 只负责 volume API 调用和 provider 卷配置转换,不管理 sandbox 生命周期。
*/ */
import { randomUUID } from 'node:crypto';
import { import {
OPEN_SANDBOX_DEFAULT_ROOT_PATH, OPEN_SANDBOX_DEFAULT_ROOT_PATH,
type OpenSandboxConfigType type OpenSandboxConfigType
} from '@fastgpt-sdk/sandbox-adapter'; } from '@fastgpt-sdk/sandbox-adapter';
import {
SANDBOX_WORKSPACE_VOLUME_NAME,
SandboxVolumeEnsureResponseSchema,
SandboxVolumeNameSchema
} from '@fastgpt/global/core/ai/sandbox/volume';
import type { SandboxStorageType } from '../../type'; import type { SandboxStorageType } from '../../type';
import { getVolumeManagerEnvConfig } from './config'; import { getVolumeManagerEnvConfig } from './config';
const SESSION_VOLUME_GENERATION_LENGTH = 8;
export type VolumeManagerResult = { export type VolumeManagerResult = {
volumes: OpenSandboxConfigType['volumes']; volumes: OpenSandboxConfigType['volumes'];
storage: SandboxStorageType; storage: SandboxStorageType;
...@@ -23,20 +31,70 @@ export type VolumeManagerResult = { ...@@ -23,20 +31,70 @@ export type VolumeManagerResult = {
*/ */
export const buildVolumeConfig = (claimName: string): VolumeManagerResult => { export const buildVolumeConfig = (claimName: string): VolumeManagerResult => {
return { return {
volumes: [{ name: 'workspace', pvc: { claimName }, mountPath: OPEN_SANDBOX_DEFAULT_ROOT_PATH }], volumes: [
{
name: SANDBOX_WORKSPACE_VOLUME_NAME,
pvc: {
claimName,
createIfNotExists: false,
deleteOnSandboxTermination: false
},
mountPath: OPEN_SANDBOX_DEFAULT_ROOT_PATH
}
],
storage: { storage: {
volumes: [{ name: 'workspace', claimName, mountPath: OPEN_SANDBOX_DEFAULT_ROOT_PATH }], volumes: [
{
name: SANDBOX_WORKSPACE_VOLUME_NAME,
claimName,
mountPath: OPEN_SANDBOX_DEFAULT_ROOT_PATH
}
],
mountPath: OPEN_SANDBOX_DEFAULT_ROOT_PATH mountPath: OPEN_SANDBOX_DEFAULT_ROOT_PATH
} }
}; };
}; };
/** /**
* 读取 Mongo storage 中当前已提交的 workspace claimName。
*
* stopped/running 只能复用这个名称;archived restore 会先生成并持久化下一代名称。
*/
export const getSessionVolumeClaimName = (storage?: SandboxStorageType | null) =>
storage?.volumes?.find((volume) => volume.name === SANDBOX_WORKSPACE_VOLUME_NAME)?.claimName;
/**
* 为一次新的 workspace generation 生成唯一 claimName。
*/
export const createSessionVolumeClaimName = (params: {
sandboxId: string;
generationId?: string;
}) => {
const { volumeNamePrefix } = getVolumeManagerEnvConfig();
const generationId =
params.generationId ??
randomUUID().replaceAll('-', '').slice(0, SESSION_VOLUME_GENERATION_LENGTH);
return SandboxVolumeNameSchema.parse(
`${volumeNamePrefix}-${params.sandboxId}-${generationId}`.toLowerCase()
);
};
/**
* 恢复 generation 命名上线前已经创建的确定性 volume 名称。
*
* 只供旧 lifecycle checkpoint 修复使用;新 volume 必须通过 createSessionVolumeClaimName 分配。
*/
export const createLegacySessionVolumeClaimName = (sandboxId: string) => {
const { volumeNamePrefix } = getVolumeManagerEnvConfig();
return SandboxVolumeNameSchema.parse(`${volumeNamePrefix}-${sandboxId}`.toLowerCase());
};
/**
* 确保指定 sandbox 会话拥有可挂载的持久卷。 * 确保指定 sandbox 会话拥有可挂载的持久卷。
* *
* 返回值是 volume-manager 分配的 PVC 名称,调用方再转换成 provider 的 volumes 配置 * claimName 必须先由 FastGPT 生成并持久化,volume-manager 不理解 sandboxId
*/ */
export const ensureSessionVolume = async (sessionId: string): Promise<string> => { export const ensureSessionVolume = async (claimName: string): Promise<string> => {
const vmConfig = getVolumeManagerEnvConfig(); const vmConfig = getVolumeManagerEnvConfig();
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (vmConfig.token) headers['Authorization'] = `Bearer ${vmConfig.token}`; if (vmConfig.token) headers['Authorization'] = `Bearer ${vmConfig.token}`;
...@@ -44,27 +102,37 @@ export const ensureSessionVolume = async (sessionId: string): Promise<string> => ...@@ -44,27 +102,37 @@ export const ensureSessionVolume = async (sessionId: string): Promise<string> =>
const res = await fetch(`${vmConfig.url}/v1/volumes/ensure`, { const res = await fetch(`${vmConfig.url}/v1/volumes/ensure`, {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify({ sessionId, storageSize: vmConfig.storageSize }) body: JSON.stringify({
claimName,
storageSize: vmConfig.storageSize
})
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(`volume-manager error: ${res.status} ${await res.text()}`); throw new Error(`volume-manager error: ${res.status} ${await res.text()}`);
} }
const { claimName } = (await res.json()) as { claimName: string }; const result = SandboxVolumeEnsureResponseSchema.parse(await res.json());
return claimName; if (result.claimName !== claimName) {
throw new Error(
`volume-manager returned unexpected claimName: expected ${claimName}, received ${result.claimName}`
);
}
return result.claimName;
}; };
/** /**
* 删除指定 sandbox 会话关联的持久卷。 * 删除指定 sandbox 会话关联的持久卷。
* *
* 未启用 volume-manager 时直接跳过;404 视为已清理,避免资源删除流程被重复清理中断。 * 未启用 volume-manager 时直接跳过;删除目标始终使用 Mongo 中持久化的完整 claimName。
* 404 视为已清理,volume-manager 的成功响应表示 driver 已完成对应 runtime 的删除语义
* (Kubernetes 会等待目标 PVC generation 结束)。
*/ */
export const deleteSessionVolume = async (sessionId: string): Promise<void> => { export const deleteSessionVolume = async (claimName: string): Promise<void> => {
const vmConfig = getVolumeManagerEnvConfig(); const vmConfig = getVolumeManagerEnvConfig();
if (!vmConfig.enable) return; if (!vmConfig.enable) return;
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (vmConfig.token) headers['Authorization'] = `Bearer ${vmConfig.token}`; if (vmConfig.token) headers['Authorization'] = `Bearer ${vmConfig.token}`;
const res = await fetch(`${vmConfig.url}/v1/volumes/${encodeURIComponent(sessionId)}`, { const res = await fetch(`${vmConfig.url}/v1/volumes/${encodeURIComponent(claimName)}`, {
method: 'DELETE', method: 'DELETE',
headers headers
}); });
...@@ -79,15 +147,15 @@ export const deleteSessionVolume = async (sessionId: string): Promise<void> => { ...@@ -79,15 +147,15 @@ export const deleteSessionVolume = async (sessionId: string): Promise<void> => {
* volume-manager 未开启时返回 undefined,调用方可直接透传给 provider 配置构造。 * volume-manager 未开启时返回 undefined,调用方可直接透传给 provider 配置构造。
*/ */
export const getSessionVolumeConfig = async ( export const getSessionVolumeConfig = async (
sandboxId: string claimName: string
): Promise<VolumeManagerResult | undefined> => { ): Promise<VolumeManagerResult | undefined> => {
const vmConfig = getVolumeManagerEnvConfig(); const vmConfig = getVolumeManagerEnvConfig();
if (!vmConfig.enable) return undefined; if (!vmConfig.enable) return undefined;
if (!vmConfig.url) { if (!vmConfig.url) {
throw new Error('AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL is required'); throw new Error('AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL is required');
} }
const claimName = await ensureSessionVolume(sandboxId); const ensuredClaimName = await ensureSessionVolume(claimName);
const volumeResult = buildVolumeConfig(claimName); const volumeResult = buildVolumeConfig(ensuredClaimName);
return volumeResult; return volumeResult;
}; };
...@@ -4,6 +4,7 @@ import { isPhaseProductionBuild } from '@fastgpt/global/common/system/constants' ...@@ -4,6 +4,7 @@ import { isPhaseProductionBuild } from '@fastgpt/global/common/system/constants'
import { DEFAULT_MAX_FOLDER_DEPTH } from '@fastgpt/global/common/parentFolder/depth'; import { DEFAULT_MAX_FOLDER_DEPTH } from '@fastgpt/global/common/parentFolder/depth';
import { BoolSchema, IntSchema, NumSchema, UrlSchema } from '@fastgpt/global/common/zod'; import { BoolSchema, IntSchema, NumSchema, UrlSchema } from '@fastgpt/global/common/zod';
import { agentSandboxProviderList } from '@fastgpt/global/core/ai/sandbox/constants'; import { agentSandboxProviderList } from '@fastgpt/global/core/ai/sandbox/constants';
import { SandboxVolumeNameSchema } from '@fastgpt/global/core/ai/sandbox/volume';
import { import {
AgentSandboxPreviewProxyUrlSchema, AgentSandboxPreviewProxyUrlSchema,
AgentSandboxProxyUrlSchema, AgentSandboxProxyUrlSchema,
...@@ -110,11 +111,23 @@ export const serviceEnv = createEnv({ ...@@ -110,11 +111,23 @@ export const serviceEnv = createEnv({
AGENT_SANDBOX_OPENSANDBOX_BASEURL: UrlSchema.optional(), AGENT_SANDBOX_OPENSANDBOX_BASEURL: UrlSchema.optional(),
AGENT_SANDBOX_OPENSANDBOX_API_KEY: z.string().optional(), AGENT_SANDBOX_OPENSANDBOX_API_KEY: z.string().optional(),
AGENT_SANDBOX_OPENSANDBOX_RUNTIME: z.enum(['docker', 'kubernetes']).default('docker'), AGENT_SANDBOX_OPENSANDBOX_RUNTIME: z.enum(['docker', 'kubernetes']).default('docker'),
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: z.string().default('fastgpt-agent-sandbox'), AGENT_SANDBOX_OPENSANDBOX_IMAGE: z.string().optional().meta({
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: z.string().default('latest'), description: 'OpenSandbox 使用的运行态镜像;启用 opensandbox 时必填'
}),
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: z.string().optional().meta({
description: 'Deprecated OpenSandbox image repository fallback',
deprecated: true
}),
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: z.string().optional().meta({
description: 'Deprecated OpenSandbox image tag fallback',
deprecated: true
}),
AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY: BoolSchema.default(true), AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY: BoolSchema.default(true),
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL: UrlSchema.optional(), AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL: UrlSchema.optional(),
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN: z.string().optional(), AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN: z.string().optional(),
AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX: SandboxVolumeNameSchema.default(
'fastgpt-session'
).meta({ description: 'OpenSandbox persistent volume claimName prefix' }),
AGENT_SANDBOX_OPENSANDBOX_DISABLE_NETWORK_POLICY: BoolSchema.default(false).meta({ AGENT_SANDBOX_OPENSANDBOX_DISABLE_NETWORK_POLICY: BoolSchema.default(false).meta({
description: description:
'Disable the default outbound network policy for OpenSandbox Docker runtime. ' + 'Disable the default outbound network policy for OpenSandbox Docker runtime. ' +
......
...@@ -86,6 +86,7 @@ const agentSandboxProviderRequiredEnvKeys = { ...@@ -86,6 +86,7 @@ const agentSandboxProviderRequiredEnvKeys = {
opensandbox: [ opensandbox: [
'AGENT_SANDBOX_OPENSANDBOX_BASEURL', 'AGENT_SANDBOX_OPENSANDBOX_BASEURL',
'AGENT_SANDBOX_OPENSANDBOX_API_KEY', 'AGENT_SANDBOX_OPENSANDBOX_API_KEY',
'AGENT_SANDBOX_OPENSANDBOX_IMAGE',
'AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL', 'AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL',
'AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN' 'AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN'
] ]
...@@ -106,7 +107,15 @@ export const getAgentSandboxMissingRequiredEnvKeys = (env: NodeJS.ProcessEnv): s ...@@ -106,7 +107,15 @@ export const getAgentSandboxMissingRequiredEnvKeys = (env: NodeJS.ProcessEnv): s
return []; return [];
} }
return agentSandboxProviderRequiredEnvKeys[provider].filter((key) => !env[key]); return agentSandboxProviderRequiredEnvKeys[provider].filter((key) => {
if (key !== 'AGENT_SANDBOX_OPENSANDBOX_IMAGE') return !env[key];
// 升级窗口内允许旧的 repo/tag 配置提供默认镜像;新 IMAGE 始终优先。
return (
!env.AGENT_SANDBOX_OPENSANDBOX_IMAGE?.trim() &&
!env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO?.trim()
);
});
}; };
/* ===== Sandbox proxy ===== */ /* ===== Sandbox proxy ===== */
......
...@@ -28,7 +28,11 @@ const mocks = vi.hoisted(() => ({ ...@@ -28,7 +28,11 @@ const mocks = vi.hoisted(() => ({
buildSandboxResourceAdapter: vi.fn(), buildSandboxResourceAdapter: vi.fn(),
ensureConnectedSandboxRunning: vi.fn(), ensureConnectedSandboxRunning: vi.fn(),
resolveSandboxHome: vi.fn(), resolveSandboxHome: vi.fn(),
buildVolumeConfig: vi.fn(),
createLegacySessionVolumeClaimName: vi.fn(),
createSessionVolumeClaimName: vi.fn(),
deleteSessionVolume: vi.fn(), deleteSessionVolume: vi.fn(),
getSessionVolumeClaimName: vi.fn(),
getSessionVolumeConfig: vi.fn(), getSessionVolumeConfig: vi.fn(),
downloadLegacyWorkspaceArchive: vi.fn(), downloadLegacyWorkspaceArchive: vi.fn(),
deleteLegacyWorkspaceArchiveNow: vi.fn(), deleteLegacyWorkspaceArchiveNow: vi.fn(),
...@@ -96,7 +100,11 @@ vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/provider/runtimeProfile ...@@ -96,7 +100,11 @@ vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/provider/runtimeProfile
})); }));
vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/volume/service', () => ({ vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/volume/service', () => ({
buildVolumeConfig: mocks.buildVolumeConfig,
createLegacySessionVolumeClaimName: mocks.createLegacySessionVolumeClaimName,
createSessionVolumeClaimName: mocks.createSessionVolumeClaimName,
deleteSessionVolume: mocks.deleteSessionVolume, deleteSessionVolume: mocks.deleteSessionVolume,
getSessionVolumeClaimName: mocks.getSessionVolumeClaimName,
getSessionVolumeConfig: mocks.getSessionVolumeConfig getSessionVolumeConfig: mocks.getSessionVolumeConfig
})); }));
...@@ -149,6 +157,17 @@ const createWorkspaceTarget = () => ({ ...@@ -149,6 +157,17 @@ const createWorkspaceTarget = () => ({
}) })
}); });
const createStorage = (sandboxId: string) => ({
volumes: [
{
name: 'workspace',
claimName: `fastgpt-session-${sandboxId}-current`,
mountPath: '/workspace'
}
],
mountPath: '/workspace'
});
const createMigrationTargetDoc = (sandboxId = 'target-sandbox') => const createMigrationTargetDoc = (sandboxId = 'target-sandbox') =>
({ ({
_id: `id-${sandboxId}`, _id: `id-${sandboxId}`,
...@@ -159,6 +178,7 @@ const createMigrationTargetDoc = (sandboxId = 'target-sandbox') => ...@@ -159,6 +178,7 @@ const createMigrationTargetDoc = (sandboxId = 'target-sandbox') =>
userId: 'user-1', userId: 'user-1',
status: 'legacyMigrating', status: 'legacyMigrating',
lastActiveAt: new Date(), lastActiveAt: new Date(),
storage: createStorage(sandboxId),
operation: { operation: {
id: `operation-${sandboxId}`, id: `operation-${sandboxId}`,
type: 'legacyMigration', type: 'legacyMigration',
...@@ -189,6 +209,7 @@ const insertLegacyApp = async (params: { ...@@ -189,6 +209,7 @@ const insertLegacyApp = async (params: {
chatId: params.chatId ?? 'chat-1', chatId: params.chatId ?? 'chat-1',
status: params.status ?? SandboxStatusEnum.stopped, status: params.status ?? SandboxStatusEnum.stopped,
lastActiveAt: params.lastActiveAt ?? new Date(), lastActiveAt: params.lastActiveAt ?? new Date(),
storage: createStorage(params.sandboxId),
...(params.phase ...(params.phase
? { ? {
metadata: { metadata: {
...@@ -217,6 +238,7 @@ const insertLegacySkill = async (params: { ...@@ -217,6 +238,7 @@ const insertLegacySkill = async (params: {
sourceId, sourceId,
status: SandboxStatusEnum.stopped, status: SandboxStatusEnum.stopped,
lastActiveAt: new Date(), lastActiveAt: new Date(),
storage: createStorage(params.sandboxId),
metadata: params.metadata ?? {} metadata: params.metadata ?? {}
}); });
}; };
...@@ -237,6 +259,7 @@ const insertPreBeta6Sandbox = async (params: { ...@@ -237,6 +259,7 @@ const insertPreBeta6Sandbox = async (params: {
status: SandboxStatusEnum.stopped, status: SandboxStatusEnum.stopped,
lastActiveAt: new Date(), lastActiveAt: new Date(),
createdAt: new Date(), createdAt: new Date(),
storage: createStorage(params.sandboxId),
...(params.skillId ? { metadata: { skillId: params.skillId } } : {}) ...(params.skillId ? { metadata: { skillId: params.skillId } } : {})
}); });
...@@ -272,6 +295,34 @@ describe('legacy sandbox migration', () => { ...@@ -272,6 +295,34 @@ describe('legacy sandbox migration', () => {
mocks.markSandboxOperationFailed.mockResolvedValue(undefined); mocks.markSandboxOperationFailed.mockResolvedValue(undefined);
mocks.ensureConnectedSandboxRunning.mockResolvedValue(undefined); mocks.ensureConnectedSandboxRunning.mockResolvedValue(undefined);
mocks.resolveSandboxHome.mockResolvedValue('/home/sandbox'); mocks.resolveSandboxHome.mockResolvedValue('/home/sandbox');
mocks.buildVolumeConfig.mockImplementation((claimName: string) => ({
volumes: [
{
name: 'workspace',
pvc: {
claimName,
createIfNotExists: false,
deleteOnSandboxTermination: false
},
mountPath: '/workspace'
}
],
storage: {
volumes: [{ name: 'workspace', claimName, mountPath: '/workspace' }],
mountPath: '/workspace'
}
}));
mocks.createSessionVolumeClaimName.mockImplementation(
({ sandboxId, generationId }: { sandboxId: string; generationId?: string }) =>
`fastgpt-session-${sandboxId}-${generationId ?? 'new'}`
);
mocks.createLegacySessionVolumeClaimName.mockImplementation(
(sandboxId: string) => `fastgpt-session-${sandboxId}`
);
mocks.getSessionVolumeClaimName.mockImplementation(
(storage: any) =>
storage?.volumes?.find((volume: any) => volume.name === 'workspace')?.claimName
);
mocks.getSessionVolumeConfig.mockResolvedValue(undefined); mocks.getSessionVolumeConfig.mockResolvedValue(undefined);
mocks.deleteSessionVolume.mockResolvedValue(undefined); mocks.deleteSessionVolume.mockResolvedValue(undefined);
mocks.downloadLegacyWorkspaceArchive.mockResolvedValue(Buffer.from('zip')); mocks.downloadLegacyWorkspaceArchive.mockResolvedValue(Buffer.from('zip'));
...@@ -650,6 +701,38 @@ describe('legacy sandbox migration', () => { ...@@ -650,6 +701,38 @@ describe('legacy sandbox migration', () => {
expect(mocks.deleteLegacyWorkspaceArchiveNow).not.toHaveBeenCalled(); expect(mocks.deleteLegacyWorkspaceArchiveNow).not.toHaveBeenCalled();
}); });
it('repairs a pre-generation targetEnsured checkpoint before resuming migration', async () => {
const targetSandboxId = generateSandboxId({
sourceType: ChatSourceTypeEnum.app,
sourceId: 'app-1',
userId: 'user-1'
});
await insertLegacyApp({ sandboxId: 'migration-test-target-ensured', chatId: 'chat-1' });
const migratingTarget = createMigrationTargetDoc(targetSandboxId);
migratingTarget.storage = undefined;
migratingTarget.operation.phase = 'targetEnsured';
mocks.claimAppSandboxMigrationTarget.mockResolvedValue(migratingTarget);
const result = await migrateLegacySandboxesToUserLevel({ dryRun: false });
const legacyClaimName = `fastgpt-session-${targetSandboxId}`;
expect(result).toMatchObject({ migratedAppCount: 1, failedCount: 0 });
expect(mocks.createLegacySessionVolumeClaimName).toHaveBeenCalledWith(targetSandboxId);
expect(mocks.advanceSandboxOperation).toHaveBeenCalledWith(
expect.objectContaining({
operationId: migratingTarget.operation.id,
phase: 'targetEnsured',
set: {
storage: expect.objectContaining({
volumes: [expect.objectContaining({ claimName: legacyClaimName })]
})
}
})
);
expect(mocks.getSessionVolumeConfig).toHaveBeenCalledWith(legacyClaimName);
expect(mocks.createSessionVolumeClaimName).not.toHaveBeenCalled();
});
it('stops an already published target before completing installed Legacy records', async () => { it('stops an already published target before completing installed Legacy records', async () => {
const sourceId = 'installed-app'; const sourceId = 'installed-app';
const userId = 'installed-user'; const userId = 'installed-user';
......
...@@ -75,7 +75,12 @@ describe('sandbox lifecycle runner', () => { ...@@ -75,7 +75,12 @@ describe('sandbox lifecycle runner', () => {
operationType: 'stop', operationType: 'stop',
status: 'stopping', status: 'stopping',
steps: [ steps: [
{ fromPhase: 'claimed', toPhase: 'providerStopped', run: firstStep }, {
fromPhase: 'claimed',
toPhase: 'providerStopped',
run: firstStep,
set: { storage: { volumes: [] } }
},
{ fromPhase: 'providerStopped', toPhase: 'volumeStopped', run: secondStep } { fromPhase: 'providerStopped', toPhase: 'volumeStopped', run: secondStep }
], ],
finish: { type: 'complete', status: 'stopped' } finish: { type: 'complete', status: 'stopped' }
...@@ -88,6 +93,13 @@ describe('sandbox lifecycle runner', () => { ...@@ -88,6 +93,13 @@ describe('sandbox lifecycle runner', () => {
'providerStopped', 'providerStopped',
'volumeStopped' 'volumeStopped'
]); ]);
expect(mocks.advanceSandboxOperation).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
phase: 'providerStopped',
set: { storage: { volumes: [] } }
})
);
expect(mocks.completeSandboxOperation).toHaveBeenCalledWith( expect(mocks.completeSandboxOperation).toHaveBeenCalledWith(
expect.objectContaining({ fromStatus: 'stopping', status: 'stopped' }) expect.objectContaining({ fromStatus: 'stopping', status: 'stopped' })
); );
......
...@@ -12,7 +12,9 @@ const mocks = vi.hoisted(() => ({ ...@@ -12,7 +12,9 @@ const mocks = vi.hoisted(() => ({
markSandboxOperationFailed: vi.fn(), markSandboxOperationFailed: vi.fn(),
archiveSandboxResourceWithinLease: vi.fn(), archiveSandboxResourceWithinLease: vi.fn(),
buildSandboxResourceAdapter: vi.fn(), buildSandboxResourceAdapter: vi.fn(),
createLegacySessionVolumeClaimName: vi.fn(),
deleteSessionVolume: vi.fn(), deleteSessionVolume: vi.fn(),
getSessionVolumeClaimName: vi.fn(),
resolveSandboxRuntimeImage: vi.fn() resolveSandboxRuntimeImage: vi.fn()
})); }));
...@@ -46,7 +48,9 @@ vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/provider/adapter', () = ...@@ -46,7 +48,9 @@ vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/provider/adapter', () =
})); }));
vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/volume/service', () => ({ vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/volume/service', () => ({
deleteSessionVolume: mocks.deleteSessionVolume createLegacySessionVolumeClaimName: mocks.createLegacySessionVolumeClaimName,
deleteSessionVolume: mocks.deleteSessionVolume,
getSessionVolumeClaimName: mocks.getSessionVolumeClaimName
})); }));
vi.mock('@fastgpt/service/core/ai/sandbox/application/archive', () => { vi.mock('@fastgpt/service/core/ai/sandbox/application/archive', () => {
...@@ -77,6 +81,13 @@ const params = { ...@@ -77,6 +81,13 @@ const params = {
userId: 'user-1' userId: 'user-1'
}; };
const staleRestorePhases = [
'previousProviderDeleted',
'previousVolumeDeleted',
'volumeAssigned',
'archiveInstalled'
];
const createInstance = (overrides: Record<string, unknown> = {}) => const createInstance = (overrides: Record<string, unknown> = {}) =>
({ ({
provider: 'opensandbox', provider: 'opensandbox',
...@@ -86,9 +97,45 @@ const createInstance = (overrides: Record<string, unknown> = {}) => ...@@ -86,9 +97,45 @@ const createInstance = (overrides: Record<string, unknown> = {}) =>
userId: params.userId, userId: params.userId,
status: 'running', status: 'running',
lastActiveAt: new Date('2026-07-01T00:00:00.000Z'), lastActiveAt: new Date('2026-07-01T00:00:00.000Z'),
storage: {
volumes: [
{
name: 'workspace',
claimName: 'fastgpt-session-app-sandbox-current',
mountPath: '/workspace'
}
],
mountPath: '/workspace'
},
...overrides ...overrides
}) as any; }) as any;
const createStaleRestore = (phase: string, overrides: Record<string, unknown> = {}) =>
createInstance({
status: 'restoring',
operation: {
id: 'old-restore',
type: 'restore',
phase,
previousStatus: 'archived',
startedAt: new Date(0),
heartbeatAt: new Date(0),
error: 'worker stopped'
},
...overrides
});
const mockRestoreRollback = (resource: ReturnType<typeof createInstance>) => {
const archived = createInstance({ status: 'archived' });
mocks.findSandboxInstanceBySource.mockResolvedValueOnce(resource).mockResolvedValueOnce(resource);
mocks.claimSandboxOperation.mockResolvedValueOnce({
...resource,
operation: { ...resource.operation, id: 'rollback-restore' }
});
mocks.completeSandboxOperation.mockResolvedValueOnce(archived);
return archived;
};
describe('sandbox provider migration lifecycle', () => { describe('sandbox provider migration lifecycle', () => {
const lease = { const lease = {
signal: new AbortController().signal, signal: new AbortController().signal,
...@@ -110,7 +157,12 @@ describe('sandbox provider migration lifecycle', () => { ...@@ -110,7 +157,12 @@ describe('sandbox provider migration lifecycle', () => {
); );
mocks.markSandboxOperationFailed.mockResolvedValue(undefined); mocks.markSandboxOperationFailed.mockResolvedValue(undefined);
mocks.buildSandboxResourceAdapter.mockReturnValue({ delete: vi.fn(async () => undefined) }); mocks.buildSandboxResourceAdapter.mockReturnValue({ delete: vi.fn(async () => undefined) });
mocks.createLegacySessionVolumeClaimName.mockReturnValue('fastgpt-session-app-sandbox');
mocks.deleteSessionVolume.mockResolvedValue(undefined); mocks.deleteSessionVolume.mockResolvedValue(undefined);
mocks.getSessionVolumeClaimName.mockImplementation(
(storage: any) =>
storage?.volumes?.find((volume: any) => volume.name === 'workspace')?.claimName
);
mocks.resolveSandboxRuntimeImage.mockReturnValue({ mocks.resolveSandboxRuntimeImage.mockReturnValue({
repository: 'registry.example.com/sandbox', repository: 'registry.example.com/sandbox',
tag: 'v2' tag: 'v2'
...@@ -174,45 +226,38 @@ describe('sandbox provider migration lifecycle', () => { ...@@ -174,45 +226,38 @@ describe('sandbox provider migration lifecycle', () => {
}); });
}); });
it('rolls an archiveInstalled old-provider restore back to archived without deleting S3', async () => { it.each(staleRestorePhases)(
const restoring = createInstance({ 'rolls a %s old-provider restore back to archived without deleting S3',
status: 'restoring', async (phase) => {
operation: { const archived = mockRestoreRollback(createStaleRestore(phase));
id: 'old-restore',
type: 'restore', await migrateSandboxProviderBeforeUse(params);
phase: 'archiveInstalled',
previousStatus: 'archived', const rollbackAdapter = mocks.buildSandboxResourceAdapter.mock.results[1].value;
startedAt: new Date(0), expect(rollbackAdapter.delete).toHaveBeenCalledTimes(1);
heartbeatAt: new Date(0), expect(mocks.deleteSessionVolume).toHaveBeenCalledWith('fastgpt-session-app-sandbox-current');
error: 'worker stopped' expect(mocks.completeSandboxOperation).toHaveBeenCalledWith(
} expect.objectContaining({ fromStatus: 'restoring', status: 'archived' })
}); );
const archived = createInstance({ status: 'archived' }); expect(mocks.switchArchivedSandboxProvider).toHaveBeenCalledWith({
const restoreClaim = { resource: archived,
...restoring, provider: 'sealosdevbox',
operation: { image: { repository: 'registry.example.com/sandbox', tag: 'v2' }
...restoring.operation, });
id: 'rollback-restore' }
} );
};
mocks.findSandboxInstanceBySource it('uses the deterministic legacy volume when rolling back a cleanup phase without storage', async () => {
.mockResolvedValueOnce(restoring) const archived = mockRestoreRollback(
.mockResolvedValueOnce(restoring); createStaleRestore('previousProviderDeleted', { storage: undefined })
mocks.claimSandboxOperation.mockResolvedValueOnce(restoreClaim); );
mocks.completeSandboxOperation.mockResolvedValueOnce(archived);
await migrateSandboxProviderBeforeUse(params); await migrateSandboxProviderBeforeUse(params);
const rollbackAdapter = mocks.buildSandboxResourceAdapter.mock.results[1].value; expect(mocks.createLegacySessionVolumeClaimName).toHaveBeenCalledWith('app-sandbox');
expect(rollbackAdapter.delete).toHaveBeenCalledTimes(1); expect(mocks.deleteSessionVolume).toHaveBeenCalledWith('fastgpt-session-app-sandbox');
expect(mocks.deleteSessionVolume).toHaveBeenCalledWith('app-sandbox'); expect(mocks.switchArchivedSandboxProvider).toHaveBeenCalledWith(
expect(mocks.completeSandboxOperation).toHaveBeenCalledWith( expect.objectContaining({ resource: archived, provider: 'sealosdevbox' })
expect.objectContaining({ fromStatus: 'restoring', status: 'archived' })
); );
expect(mocks.switchArchivedSandboxProvider).toHaveBeenCalledWith({
resource: archived,
provider: 'sealosdevbox',
image: { repository: 'registry.example.com/sandbox', tag: 'v2' }
});
}); });
}); });
...@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({ ...@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
buildSandboxResourceAdapter: vi.fn(), buildSandboxResourceAdapter: vi.fn(),
deleteSessionVolume: vi.fn(), deleteSessionVolume: vi.fn(),
getSessionVolumeClaimName: vi.fn(),
deleteWorkspaceArchiveNow: vi.fn(), deleteWorkspaceArchiveNow: vi.fn(),
withSandboxLifecycleLease: vi.fn(), withSandboxLifecycleLease: vi.fn(),
findSandboxInstanceBySandboxId: vi.fn(), findSandboxInstanceBySandboxId: vi.fn(),
...@@ -32,7 +33,8 @@ vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/provider/adapter', () = ...@@ -32,7 +33,8 @@ vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/provider/adapter', () =
})); }));
vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/volume/service', () => ({ vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/volume/service', () => ({
deleteSessionVolume: mocks.deleteSessionVolume deleteSessionVolume: mocks.deleteSessionVolume,
getSessionVolumeClaimName: mocks.getSessionVolumeClaimName
})); }));
vi.mock('@fastgpt/service/common/s3/sources/sandbox', () => ({ vi.mock('@fastgpt/service/common/s3/sources/sandbox', () => ({
...@@ -70,6 +72,16 @@ const createResource = (overrides: Record<string, unknown> = {}) => ...@@ -70,6 +72,16 @@ const createResource = (overrides: Record<string, unknown> = {}) =>
userId: 'user-1', userId: 'user-1',
status: 'running', status: 'running',
lastActiveAt: new Date('2026-07-01T00:00:00.000Z'), lastActiveAt: new Date('2026-07-01T00:00:00.000Z'),
storage: {
volumes: [
{
name: 'workspace',
claimName: 'fastgpt-session-sandbox-1-current',
mountPath: '/workspace'
}
],
mountPath: '/workspace'
},
...overrides ...overrides
}) as any; }) as any;
...@@ -102,6 +114,10 @@ describe('sandbox resource lifecycle', () => { ...@@ -102,6 +114,10 @@ describe('sandbox resource lifecycle', () => {
mocks.deleteClaimedSandboxRecord.mockResolvedValue({ deletedCount: 1 }); mocks.deleteClaimedSandboxRecord.mockResolvedValue({ deletedCount: 1 });
mocks.markSandboxOperationFailed.mockResolvedValue(undefined); mocks.markSandboxOperationFailed.mockResolvedValue(undefined);
mocks.deleteSessionVolume.mockResolvedValue(undefined); mocks.deleteSessionVolume.mockResolvedValue(undefined);
mocks.getSessionVolumeClaimName.mockImplementation(
(storage: any) =>
storage?.volumes?.find((volume: any) => volume.name === 'workspace')?.claimName
);
mocks.deleteWorkspaceArchiveNow.mockResolvedValue(undefined); mocks.deleteWorkspaceArchiveNow.mockResolvedValue(undefined);
mocks.findSandboxResourcesBySource.mockResolvedValue([]); mocks.findSandboxResourcesBySource.mockResolvedValue([]);
mocks.findSkillRelatedSandboxResources.mockResolvedValue([]); mocks.findSkillRelatedSandboxResources.mockResolvedValue([]);
...@@ -196,7 +212,7 @@ describe('sandbox resource lifecycle', () => { ...@@ -196,7 +212,7 @@ describe('sandbox resource lifecycle', () => {
const adapter = mocks.buildSandboxResourceAdapter.mock.results[0].value; const adapter = mocks.buildSandboxResourceAdapter.mock.results[0].value;
expect(adapter.delete).toHaveBeenCalledTimes(1); expect(adapter.delete).toHaveBeenCalledTimes(1);
expect(mocks.deleteSessionVolume).toHaveBeenCalledWith('sandbox-1'); expect(mocks.deleteSessionVolume).toHaveBeenCalledWith('fastgpt-session-sandbox-1-current');
expect(mocks.deleteWorkspaceArchiveNow).toHaveBeenCalledWith({ sandboxId: 'sandbox-1' }); expect(mocks.deleteWorkspaceArchiveNow).toHaveBeenCalledWith({ sandboxId: 'sandbox-1' });
expect(mocks.advanceSandboxOperation.mock.calls.map((call) => call[0].phase)).toEqual([ expect(mocks.advanceSandboxOperation.mock.calls.map((call) => call[0].phase)).toEqual([
'providerDeleted', 'providerDeleted',
......
...@@ -55,8 +55,7 @@ vi.mock('@fastgpt/service/env', () => ({ ...@@ -55,8 +55,7 @@ vi.mock('@fastgpt/service/env', () => ({
AGENT_SANDBOX_OPENSANDBOX_BASEURL: process.env.AGENT_SANDBOX_OPENSANDBOX_BASEURL, AGENT_SANDBOX_OPENSANDBOX_BASEURL: process.env.AGENT_SANDBOX_OPENSANDBOX_BASEURL,
AGENT_SANDBOX_OPENSANDBOX_API_KEY: process.env.AGENT_SANDBOX_OPENSANDBOX_API_KEY, AGENT_SANDBOX_OPENSANDBOX_API_KEY: process.env.AGENT_SANDBOX_OPENSANDBOX_API_KEY,
AGENT_SANDBOX_OPENSANDBOX_RUNTIME: process.env.AGENT_SANDBOX_OPENSANDBOX_RUNTIME, AGENT_SANDBOX_OPENSANDBOX_RUNTIME: process.env.AGENT_SANDBOX_OPENSANDBOX_RUNTIME,
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO, AGENT_SANDBOX_OPENSANDBOX_IMAGE: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE,
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG,
AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY: envBool( AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY: envBool(
process.env.AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY process.env.AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY
), ),
......
...@@ -12,6 +12,9 @@ const mocks = vi.hoisted(() => ({ ...@@ -12,6 +12,9 @@ const mocks = vi.hoisted(() => ({
ensureConnectedSandboxRunning: vi.fn(), ensureConnectedSandboxRunning: vi.fn(),
deleteSandboxResource: vi.fn(), deleteSandboxResource: vi.fn(),
stopSandboxResource: vi.fn(), stopSandboxResource: vi.fn(),
buildVolumeConfig: vi.fn(),
createSessionVolumeClaimName: vi.fn(),
getSessionVolumeClaimName: vi.fn(),
getSessionVolumeConfig: vi.fn(), getSessionVolumeConfig: vi.fn(),
existsSandboxInstanceBySandboxId: vi.fn(), existsSandboxInstanceBySandboxId: vi.fn(),
touchRunningSandboxInstance: vi.fn(), touchRunningSandboxInstance: vi.fn(),
...@@ -51,6 +54,9 @@ vi.mock('@fastgpt/service/core/ai/sandbox/application/resource', () => ({ ...@@ -51,6 +54,9 @@ vi.mock('@fastgpt/service/core/ai/sandbox/application/resource', () => ({
})); }));
vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/volume/service', () => ({ vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/volume/service', () => ({
buildVolumeConfig: mocks.buildVolumeConfig,
createSessionVolumeClaimName: mocks.createSessionVolumeClaimName,
getSessionVolumeClaimName: mocks.getSessionVolumeClaimName,
getSessionVolumeConfig: mocks.getSessionVolumeConfig getSessionVolumeConfig: mocks.getSessionVolumeConfig
})); }));
...@@ -122,15 +128,34 @@ const createProvider = () => ({ ...@@ -122,15 +128,34 @@ const createProvider = () => ({
execute: vi.fn(async () => ({ stdout: 'ok', stderr: '', exitCode: 0 })) execute: vi.fn(async () => ({ stdout: 'ok', stderr: '', exitCode: 0 }))
}); });
const createInstance = (status: string, operationId?: string) => const createInstance = (
status: string,
operationId?: string,
provider: 'sealosdevbox' | 'opensandbox' = 'sealosdevbox',
overrides: Record<string, unknown> = {}
) =>
({ ({
provider: 'sealosdevbox', provider,
sandboxId: query.sandboxId, sandboxId: query.sandboxId,
sourceType: query.sourceType, sourceType: query.sourceType,
sourceId: query.sourceId, sourceId: query.sourceId,
userId: query.userId, userId: query.userId,
status, status,
lastActiveAt: new Date('2026-07-01T00:00:00.000Z'), lastActiveAt: new Date('2026-07-01T00:00:00.000Z'),
...(provider === 'opensandbox'
? {
storage: {
volumes: [
{
name: 'workspace',
claimName: 'fastgpt-session-sandbox-1-current',
mountPath: '/workspace'
}
],
mountPath: '/workspace'
}
}
: {}),
operation: operationId operation: operationId
? { ? {
id: operationId, id: operationId,
...@@ -139,7 +164,8 @@ const createInstance = (status: string, operationId?: string) => ...@@ -139,7 +164,8 @@ const createInstance = (status: string, operationId?: string) =>
startedAt: new Date(), startedAt: new Date(),
heartbeatAt: new Date() heartbeatAt: new Date()
} }
: undefined : undefined,
...overrides
}) as any; }) as any;
describe('sandbox runtime client lifecycle', () => { describe('sandbox runtime client lifecycle', () => {
...@@ -149,6 +175,31 @@ describe('sandbox runtime client lifecycle', () => { ...@@ -149,6 +175,31 @@ describe('sandbox runtime client lifecycle', () => {
vi.clearAllMocks(); vi.clearAllMocks();
mocks.buildRuntimeSandboxAdapter.mockReturnValue(createProvider()); mocks.buildRuntimeSandboxAdapter.mockReturnValue(createProvider());
mocks.assertSandboxSourceActive.mockResolvedValue(undefined); mocks.assertSandboxSourceActive.mockResolvedValue(undefined);
mocks.buildVolumeConfig.mockImplementation((claimName: string) => ({
volumes: [
{
name: 'workspace',
pvc: {
claimName,
createIfNotExists: false,
deleteOnSandboxTermination: false
},
mountPath: '/workspace'
}
],
storage: {
volumes: [{ name: 'workspace', claimName, mountPath: '/workspace' }],
mountPath: '/workspace'
}
}));
mocks.createSessionVolumeClaimName.mockImplementation(
({ sandboxId, generationId }: { sandboxId: string; generationId?: string }) =>
`fastgpt-session-${sandboxId}-${generationId ?? 'new'}`
);
mocks.getSessionVolumeClaimName.mockImplementation(
(storage: any) =>
storage?.volumes?.find((volume: any) => volume.name === 'workspace')?.claimName
);
mocks.getSessionVolumeConfig.mockResolvedValue(undefined); mocks.getSessionVolumeConfig.mockResolvedValue(undefined);
mocks.touchRunningSandboxInstance.mockResolvedValue(createInstance('running')); mocks.touchRunningSandboxInstance.mockResolvedValue(createInstance('running'));
mocks.findSandboxInstanceBySource.mockResolvedValue(null); mocks.findSandboxInstanceBySource.mockResolvedValue(null);
...@@ -251,18 +302,135 @@ describe('sandbox runtime client lifecycle', () => { ...@@ -251,18 +302,135 @@ describe('sandbox runtime client lifecycle', () => {
); );
}); });
it('claims stopped -> provisioning before resuming the provider', async () => { it('reuses the restore-assigned claimName and only ensures it inside provisioning', async () => {
const stopped = createInstance('stopped'); const vmConfig = {
const claimed = createInstance('provisioning', 'resume-1'); volumes: [{ name: 'workspace', pvc: { claimName: 'fastgpt-session-sandbox-1' } }],
storage: {
volumes: [
{
name: 'workspace',
claimName: 'fastgpt-session-sandbox-1',
mountPath: '/workspace'
}
],
mountPath: '/workspace'
}
};
mocks.touchRunningSandboxInstance.mockResolvedValue(null);
const provisioning = createInstance('provisioning', 'provision-1', 'opensandbox', {
storage: vmConfig.storage
});
mocks.createSandboxProvisioningInstance.mockResolvedValueOnce({
instance: provisioning,
created: true
});
mocks.restoreArchivedSandboxBeforeUse.mockResolvedValueOnce(vmConfig);
await getSandboxClient(query, { providerName: 'opensandbox' });
expect(mocks.getSessionVolumeConfig).toHaveBeenCalledWith('fastgpt-session-sandbox-1');
expect(mocks.withSandboxLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan(
mocks.getSessionVolumeConfig.mock.invocationCallOrder[0]
);
expect(mocks.restoreArchivedSandboxBeforeUse).toHaveBeenCalledWith(
expect.not.objectContaining({ vmConfig: expect.anything(), storage: expect.anything() })
);
expect(mocks.buildRuntimeSandboxAdapter).toHaveBeenCalledWith(
'opensandbox',
query.sandboxId,
expect.objectContaining({ vmConfig })
);
});
it('rebuilds the provider from the current claim after a stale storage CAS misses', async () => {
const staleVmConfig = mocks.buildVolumeConfig('fastgpt-session-sandbox-1-stale');
const current = createInstance('running', undefined, 'opensandbox', {
storage: mocks.buildVolumeConfig('fastgpt-session-sandbox-1-current').storage
});
const staleProvider = createProvider();
const currentProvider = createProvider();
mocks.buildRuntimeSandboxAdapter
.mockReturnValueOnce(staleProvider)
.mockReturnValueOnce(currentProvider);
mocks.restoreArchivedSandboxBeforeUse.mockResolvedValueOnce(staleVmConfig);
mocks.touchRunningSandboxInstance.mockResolvedValueOnce(null).mockResolvedValueOnce(current);
mocks.findSandboxInstanceBySource.mockResolvedValue(current);
await getSandboxClient(query, { providerName: 'opensandbox' });
expect(mocks.touchRunningSandboxInstance).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
expectedWorkspaceClaimName: 'fastgpt-session-sandbox-1-stale'
})
);
expect(mocks.buildRuntimeSandboxAdapter).toHaveBeenLastCalledWith(
'opensandbox',
query.sandboxId,
expect.objectContaining({
vmConfig: expect.objectContaining({ storage: current.storage })
})
);
expect(mocks.ensureConnectedSandboxRunning).toHaveBeenCalledWith(currentProvider);
expect(mocks.ensureConnectedSandboxRunning).not.toHaveBeenCalledWith(staleProvider);
});
it('persists the initial claimName before ensuring a new OpenSandbox provider', async () => {
const provisioning = createInstance('provisioning', 'provision-1', 'opensandbox', {
storage: {
volumes: [
{
name: 'workspace',
claimName: 'fastgpt-session-sandbox-1-0',
mountPath: '/workspace'
}
],
mountPath: '/workspace'
}
});
mocks.touchRunningSandboxInstance.mockResolvedValue(null);
mocks.findSandboxInstanceBySource.mockResolvedValue(null);
mocks.createSandboxProvisioningInstance.mockResolvedValueOnce({
instance: provisioning,
created: true
});
await getSandboxClient(query, { providerName: 'opensandbox' });
expect(mocks.createSandboxProvisioningInstance).toHaveBeenCalledWith(
expect.objectContaining({
storage: {
volumes: [
{
name: 'workspace',
claimName: 'fastgpt-session-sandbox-1-0',
mountPath: '/workspace'
}
],
mountPath: '/workspace'
}
})
);
expect(mocks.createSandboxProvisioningInstance.mock.invocationCallOrder[0]).toBeLessThan(
mocks.getSessionVolumeConfig.mock.invocationCallOrder[0]
);
expect(mocks.getSessionVolumeConfig).toHaveBeenCalledWith('fastgpt-session-sandbox-1-0');
});
it('claims stopped -> provisioning and reuses its persisted OpenSandbox claimName', async () => {
const stopped = createInstance('stopped', undefined, 'opensandbox');
const claimed = createInstance('provisioning', 'resume-1', 'opensandbox');
mocks.touchRunningSandboxInstance.mockResolvedValue(null); mocks.touchRunningSandboxInstance.mockResolvedValue(null);
mocks.findSandboxInstanceBySource.mockResolvedValue(stopped); mocks.findSandboxInstanceBySource.mockResolvedValue(stopped);
mocks.claimSandboxOperation.mockResolvedValue(claimed); mocks.claimSandboxOperation.mockResolvedValue(claimed);
await getSandboxClient(query); await getSandboxClient(query, { providerName: 'opensandbox' });
expect(mocks.claimSandboxOperation).toHaveBeenCalledWith( expect(mocks.claimSandboxOperation).toHaveBeenCalledWith(
expect.objectContaining({ status: 'provisioning', type: 'provision' }) expect.objectContaining({ status: 'provisioning', type: 'provision' })
); );
expect(mocks.createSessionVolumeClaimName).not.toHaveBeenCalled();
expect(mocks.getSessionVolumeConfig).toHaveBeenCalledWith('fastgpt-session-sandbox-1-current');
expect(mocks.completeSandboxOperation).toHaveBeenCalledWith( expect(mocks.completeSandboxOperation).toHaveBeenCalledWith(
expect.objectContaining({ operationId: 'resume-1', status: 'running' }) expect.objectContaining({ operationId: 'resume-1', status: 'running' })
); );
......
...@@ -153,6 +153,49 @@ describe('sandbox instance lifecycle repository', () => { ...@@ -153,6 +153,49 @@ describe('sandbox instance lifecycle repository', () => {
).resolves.toBeNull(); ).resolves.toBeNull();
}); });
it('uses workspace claimName as CAS without overwriting storage', async () => {
const identity = createAppIdentity();
const storage = {
volumes: [
{
name: 'workspace',
claimName: 'fastgpt-session-current-generation',
mountPath: '/workspace'
}
],
mountPath: '/workspace'
};
const running = await MongoSandboxInstance.create({
provider: 'opensandbox',
sourceType: ChatSourceTypeEnum.app,
...identity,
status: SandboxInstanceStatusEnum.running,
lastActiveAt: oldDate,
createdAt: oldDate,
storage
});
await expect(
touchRunningSandboxInstance({
provider: 'opensandbox',
sourceType: ChatSourceTypeEnum.app,
...identity,
expectedWorkspaceClaimName: 'fastgpt-session-stale-generation'
})
).resolves.toBeNull();
await expect(
touchRunningSandboxInstance({
provider: 'opensandbox',
sourceType: ChatSourceTypeEnum.app,
...identity,
expectedWorkspaceClaimName: 'fastgpt-session-current-generation'
})
).resolves.toMatchObject({ storage });
await expect(MongoSandboxInstance.findById(running._id).lean()).resolves.toMatchObject({
storage
});
});
it('advances, fails and completes a stop operation with CAS fencing', async () => { it('advances, fails and completes a stop operation with CAS fencing', async () => {
const identity = createAppIdentity(); const identity = createAppIdentity();
const running = await MongoSandboxInstance.create({ const running = await MongoSandboxInstance.create({
......
...@@ -13,15 +13,15 @@ vi.mock('@fastgpt/service/env', () => ({ ...@@ -13,15 +13,15 @@ vi.mock('@fastgpt/service/env', () => ({
AGENT_SANDBOX_OPENSANDBOX_API_KEY: 'mock-opensandbox-api-key', AGENT_SANDBOX_OPENSANDBOX_API_KEY: 'mock-opensandbox-api-key',
AGENT_SANDBOX_OPENSANDBOX_RUNTIME: 'docker', AGENT_SANDBOX_OPENSANDBOX_RUNTIME: 'docker',
AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY: false, AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY: false,
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: 'runtime-image', AGENT_SANDBOX_OPENSANDBOX_IMAGE: 'runtime-image:test',
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: 'test',
AGENT_SANDBOX_SEALOS_BASEURL: 'http://mock-sealos.local', AGENT_SANDBOX_SEALOS_BASEURL: 'http://mock-sealos.local',
AGENT_SANDBOX_SEALOS_TOKEN: 'mock-sealos-token', AGENT_SANDBOX_SEALOS_TOKEN: 'mock-sealos-token',
AGENT_SANDBOX_STORAGE_SIZE_GI: 1 AGENT_SANDBOX_STORAGE_SIZE_GI: 1
} }
})); }));
vi.mock('@fastgpt-sdk/sandbox-adapter', () => ({ vi.mock('@fastgpt-sdk/sandbox-adapter', async (importOriginal) => ({
...(await importOriginal<typeof import('@fastgpt-sdk/sandbox-adapter')>()),
OPEN_SANDBOX_DEFAULT_ROOT_PATH: '/workspace', OPEN_SANDBOX_DEFAULT_ROOT_PATH: '/workspace',
createSandbox: mocks.createSandbox createSandbox: mocks.createSandbox
})); }));
......
...@@ -9,8 +9,7 @@ const originalEnv = { ...@@ -9,8 +9,7 @@ const originalEnv = {
AGENT_SANDBOX_OPENSANDBOX_BASEURL: process.env.AGENT_SANDBOX_OPENSANDBOX_BASEURL, AGENT_SANDBOX_OPENSANDBOX_BASEURL: process.env.AGENT_SANDBOX_OPENSANDBOX_BASEURL,
AGENT_SANDBOX_OPENSANDBOX_API_KEY: process.env.AGENT_SANDBOX_OPENSANDBOX_API_KEY, AGENT_SANDBOX_OPENSANDBOX_API_KEY: process.env.AGENT_SANDBOX_OPENSANDBOX_API_KEY,
AGENT_SANDBOX_OPENSANDBOX_RUNTIME: process.env.AGENT_SANDBOX_OPENSANDBOX_RUNTIME, AGENT_SANDBOX_OPENSANDBOX_RUNTIME: process.env.AGENT_SANDBOX_OPENSANDBOX_RUNTIME,
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO, AGENT_SANDBOX_OPENSANDBOX_IMAGE: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE,
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG,
AGENT_SANDBOX_CPU_COUNT: process.env.AGENT_SANDBOX_CPU_COUNT, AGENT_SANDBOX_CPU_COUNT: process.env.AGENT_SANDBOX_CPU_COUNT,
AGENT_SANDBOX_MEMORY_MIB: process.env.AGENT_SANDBOX_MEMORY_MIB, AGENT_SANDBOX_MEMORY_MIB: process.env.AGENT_SANDBOX_MEMORY_MIB,
AGENT_SANDBOX_STORAGE_SIZE_GI: process.env.AGENT_SANDBOX_STORAGE_SIZE_GI, AGENT_SANDBOX_STORAGE_SIZE_GI: process.env.AGENT_SANDBOX_STORAGE_SIZE_GI,
...@@ -62,14 +61,7 @@ describe('sandbox provider config', () => { ...@@ -62,14 +61,7 @@ describe('sandbox provider config', () => {
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', originalEnv.AGENT_SANDBOX_OPENSANDBOX_BASEURL); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', originalEnv.AGENT_SANDBOX_OPENSANDBOX_BASEURL);
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_API_KEY', originalEnv.AGENT_SANDBOX_OPENSANDBOX_API_KEY); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_API_KEY', originalEnv.AGENT_SANDBOX_OPENSANDBOX_API_KEY);
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_RUNTIME', originalEnv.AGENT_SANDBOX_OPENSANDBOX_RUNTIME); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_RUNTIME', originalEnv.AGENT_SANDBOX_OPENSANDBOX_RUNTIME);
vi.stubEnv( vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE', originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE);
'AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO',
originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO
);
vi.stubEnv(
'AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG',
originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG
);
vi.stubEnv('AGENT_SANDBOX_CPU_COUNT', originalEnv.AGENT_SANDBOX_CPU_COUNT); vi.stubEnv('AGENT_SANDBOX_CPU_COUNT', originalEnv.AGENT_SANDBOX_CPU_COUNT);
vi.stubEnv('AGENT_SANDBOX_MEMORY_MIB', originalEnv.AGENT_SANDBOX_MEMORY_MIB); vi.stubEnv('AGENT_SANDBOX_MEMORY_MIB', originalEnv.AGENT_SANDBOX_MEMORY_MIB);
vi.stubEnv('AGENT_SANDBOX_STORAGE_SIZE_GI', originalEnv.AGENT_SANDBOX_STORAGE_SIZE_GI); vi.stubEnv('AGENT_SANDBOX_STORAGE_SIZE_GI', originalEnv.AGENT_SANDBOX_STORAGE_SIZE_GI);
...@@ -210,8 +202,7 @@ describe('sandbox provider config', () => { ...@@ -210,8 +202,7 @@ describe('sandbox provider config', () => {
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', 'http://opensandbox.local'); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', 'http://opensandbox.local');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_API_KEY', 'opensandbox-key'); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_API_KEY', 'opensandbox-key');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_RUNTIME', 'docker'); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_RUNTIME', 'docker');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO', 'fastgpt-agent-sandbox'); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE', 'fastgpt-agent-sandbox:test');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG', 'test');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL', 'http://volume-manager.local'); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL', 'http://volume-manager.local');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN', 'volume-token'); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN', 'volume-token');
...@@ -256,6 +247,7 @@ describe('sandbox provider config', () => { ...@@ -256,6 +247,7 @@ describe('sandbox provider config', () => {
cpuCount: 1, cpuCount: 1,
memoryMiB: 512 memoryMiB: 512
}, },
readyTimeoutSeconds: 120,
entrypoint: ['sh', '-c', 'echo ok'], entrypoint: ['sh', '-c', 'echo ok'],
env: { A: 'B' }, env: { A: 'B' },
metadata: { teamId: 'team-1' }, metadata: { teamId: 'team-1' },
...@@ -273,15 +265,15 @@ describe('sandbox provider config', () => { ...@@ -273,15 +265,15 @@ describe('sandbox provider config', () => {
it('builds opensandbox runtime create config from profile env image', async () => { it('builds opensandbox runtime create config from profile env image', async () => {
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_RUNTIME', 'docker'); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_RUNTIME', 'docker');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO', 'default-opensandbox-image'); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE', 'default-opensandbox-image:stable');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG', 'stable');
vi.stubEnv('AGENT_SANDBOX_CPU_COUNT', '2'); vi.stubEnv('AGENT_SANDBOX_CPU_COUNT', '2');
vi.stubEnv('AGENT_SANDBOX_MEMORY_MIB', '4096'); vi.stubEnv('AGENT_SANDBOX_MEMORY_MIB', '4096');
vi.resetModules(); vi.resetModules();
const { getSandboxRuntimeProfile } = const { getSandboxRuntimeProfile } =
await import('@fastgpt/service/core/ai/sandbox/infrastructure/provider/runtimeProfile'); await import('@fastgpt/service/core/ai/sandbox/infrastructure/provider/runtimeProfile');
const profile = getSandboxRuntimeProfile('opensandbox'); const profile = getSandboxRuntimeProfile('opensandbox');
expect(profile.buildConfig()).toEqual({ const defaultConfig = profile.buildConfig();
expect(defaultConfig).toEqual({
image: { image: {
repository: 'default-opensandbox-image', repository: 'default-opensandbox-image',
tag: 'stable' tag: 'stable'
...@@ -290,6 +282,7 @@ describe('sandbox provider config', () => { ...@@ -290,6 +282,7 @@ describe('sandbox provider config', () => {
cpuCount: 2, cpuCount: 2,
memoryMiB: 4096 memoryMiB: 4096
}, },
readyTimeoutSeconds: 120,
networkPolicy: defaultOpenSandboxDockerNetworkPolicy networkPolicy: defaultOpenSandboxDockerNetworkPolicy
}); });
...@@ -298,16 +291,8 @@ describe('sandbox provider config', () => { ...@@ -298,16 +291,8 @@ describe('sandbox provider config', () => {
entrypoint: profile.entrypoint entrypoint: profile.entrypoint
}) })
).toEqual({ ).toEqual({
image: { ...defaultConfig,
repository: 'default-opensandbox-image', entrypoint: ['/home/sandbox/entrypoint.sh']
tag: 'stable'
},
resourceLimits: {
cpuCount: 2,
memoryMiB: 4096
},
entrypoint: ['/home/sandbox/entrypoint.sh'],
networkPolicy: defaultOpenSandboxDockerNetworkPolicy
}); });
expect( expect(
...@@ -380,8 +365,7 @@ describe('sandbox provider config', () => { ...@@ -380,8 +365,7 @@ describe('sandbox provider config', () => {
AGENT_SANDBOX_PROVIDER: 'opensandbox', AGENT_SANDBOX_PROVIDER: 'opensandbox',
AGENT_SANDBOX_OPENSANDBOX_BASEURL: 'http://opensandbox.local', AGENT_SANDBOX_OPENSANDBOX_BASEURL: 'http://opensandbox.local',
AGENT_SANDBOX_OPENSANDBOX_RUNTIME: 'docker', AGENT_SANDBOX_OPENSANDBOX_RUNTIME: 'docker',
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: '', AGENT_SANDBOX_OPENSANDBOX_IMAGE: '',
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: undefined,
AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY: true, AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY: true,
AGENT_SANDBOX_CPU_COUNT: 1, AGENT_SANDBOX_CPU_COUNT: 1,
AGENT_SANDBOX_MEMORY_MIB: 2048, AGENT_SANDBOX_MEMORY_MIB: 2048,
...@@ -395,7 +379,7 @@ describe('sandbox provider config', () => { ...@@ -395,7 +379,7 @@ describe('sandbox provider config', () => {
const runtimeProfile = getSandboxRuntimeProfile('opensandbox'); const runtimeProfile = getSandboxRuntimeProfile('opensandbox');
expect(() => runtimeProfile.buildConfig()).toThrow( expect(() => runtimeProfile.buildConfig()).toThrow(
'AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO is required' 'AGENT_SANDBOX_OPENSANDBOX_IMAGE is required'
); );
expect( expect(
runtimeProfile.buildConfig({ runtimeProfile.buildConfig({
......
...@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; ...@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
const originalEnv = { const originalEnv = {
AGENT_SANDBOX_PROVIDER: process.env.AGENT_SANDBOX_PROVIDER, AGENT_SANDBOX_PROVIDER: process.env.AGENT_SANDBOX_PROVIDER,
AGENT_SANDBOX_OPENSANDBOX_IMAGE: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE,
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO, AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO,
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG, AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG,
AGENT_SANDBOX_SEALOS_WORK_DIRECTORY: process.env.AGENT_SANDBOX_SEALOS_WORK_DIRECTORY, AGENT_SANDBOX_SEALOS_WORK_DIRECTORY: process.env.AGENT_SANDBOX_SEALOS_WORK_DIRECTORY,
...@@ -17,6 +18,7 @@ const loadSandboxRuntimeProfileModule = async () => { ...@@ -17,6 +18,7 @@ const loadSandboxRuntimeProfileModule = async () => {
describe('sandbox runtime profile', () => { describe('sandbox runtime profile', () => {
afterEach(() => { afterEach(() => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', originalEnv.AGENT_SANDBOX_PROVIDER); vi.stubEnv('AGENT_SANDBOX_PROVIDER', originalEnv.AGENT_SANDBOX_PROVIDER);
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE', originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE);
vi.stubEnv( vi.stubEnv(
'AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO', 'AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO',
originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO
...@@ -35,8 +37,9 @@ describe('sandbox runtime profile', () => { ...@@ -35,8 +37,9 @@ describe('sandbox runtime profile', () => {
it('uses fixed /workspace as opensandbox work directory', async () => { it('uses fixed /workspace as opensandbox work directory', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', ''); vi.stubEnv('AGENT_SANDBOX_PROVIDER', '');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO', 'runtime-image'); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE', 'registry.local:5000/runtime-image:stable');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG', 'stable'); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO', 'legacy/image');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG', 'legacy-tag');
const { getSandboxRuntimeProfile } = await loadSandboxRuntimeProfileModule(); const { getSandboxRuntimeProfile } = await loadSandboxRuntimeProfileModule();
const runtimeProfile = getSandboxRuntimeProfile('opensandbox'); const runtimeProfile = getSandboxRuntimeProfile('opensandbox');
...@@ -44,7 +47,7 @@ describe('sandbox runtime profile', () => { ...@@ -44,7 +47,7 @@ describe('sandbox runtime profile', () => {
expect(runtimeProfile).toMatchObject({ expect(runtimeProfile).toMatchObject({
provider: 'opensandbox', provider: 'opensandbox',
defaultImage: { defaultImage: {
repository: 'runtime-image', repository: 'registry.local:5000/runtime-image',
tag: 'stable' tag: 'stable'
}, },
workDirectory: '/workspace', workDirectory: '/workspace',
...@@ -53,6 +56,49 @@ describe('sandbox runtime profile', () => { ...@@ -53,6 +56,49 @@ describe('sandbox runtime profile', () => {
expect(runtimeProfile.skillsRootPath).toBe('/workspace/skills'); expect(runtimeProfile.skillsRootPath).toBe('/workspace/skills');
}); });
it('falls back to the legacy opensandbox repo and tag variables', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', '');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE', '');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO', 'legacy/runtime-image');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG', 'legacy-stable');
const { getSandboxRuntimeProfile } = await loadSandboxRuntimeProfileModule();
expect(getSandboxRuntimeProfile('opensandbox').defaultImage).toEqual({
repository: 'legacy/runtime-image',
tag: 'legacy-stable'
});
});
it('defaults the legacy opensandbox image tag to latest', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', '');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE', '');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO', 'legacy/runtime-image');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG', '');
const { getSandboxRuntimeProfile } = await loadSandboxRuntimeProfileModule();
expect(getSandboxRuntimeProfile('opensandbox').defaultImage).toEqual({
repository: 'legacy/runtime-image',
tag: 'latest'
});
});
it('preserves an explicit opensandbox ready timeout', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', '');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE', 'runtime/fastgpt:stable');
const { getSandboxRuntimeProfile } = await loadSandboxRuntimeProfileModule();
const runtimeProfile = getSandboxRuntimeProfile('opensandbox');
expect(
runtimeProfile.buildConfig({
createConfig: { readyTimeoutSeconds: 45 }
})
).toMatchObject({ readyTimeoutSeconds: 45 });
expect(runtimeProfile.buildConfig()).toMatchObject({ readyTimeoutSeconds: 120 });
});
it('uses devbox defaults for sealosdevbox provider', async () => { it('uses devbox defaults for sealosdevbox provider', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', ''); vi.stubEnv('AGENT_SANDBOX_PROVIDER', '');
vi.stubEnv('AGENT_SANDBOX_SEALOS_IMAGE', 'runtime/fastgpt:stable'); vi.stubEnv('AGENT_SANDBOX_SEALOS_IMAGE', 'runtime/fastgpt:stable');
......
...@@ -16,6 +16,7 @@ describe('sandbox volume config', () => { ...@@ -16,6 +16,7 @@ describe('sandbox volume config', () => {
serviceEnv: { serviceEnv: {
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL: 'http://volume-manager.local', AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL: 'http://volume-manager.local',
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN: 'volume-token', AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN: 'volume-token',
AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX: 'custom-volume',
AGENT_SANDBOX_STORAGE_SIZE_GI: 5 AGENT_SANDBOX_STORAGE_SIZE_GI: 5
} }
})); }));
...@@ -26,6 +27,7 @@ describe('sandbox volume config', () => { ...@@ -26,6 +27,7 @@ describe('sandbox volume config', () => {
enable: true, enable: true,
url: 'http://volume-manager.local', url: 'http://volume-manager.local',
token: 'volume-token', token: 'volume-token',
volumeNamePrefix: 'custom-volume',
storageSize: '5Gi' storageSize: '5Gi'
}); });
}); });
......
...@@ -5,6 +5,7 @@ const volumeConfigMock = vi.hoisted(() => ({ ...@@ -5,6 +5,7 @@ const volumeConfigMock = vi.hoisted(() => ({
enable: true, enable: true,
url: 'http://volume-manager.local', url: 'http://volume-manager.local',
token: 'volume-token', token: 'volume-token',
volumeNamePrefix: 'fastgpt-session',
storageSize: '1Gi' storageSize: '1Gi'
} }
})); }));
...@@ -15,8 +16,11 @@ vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/volume/config', () => ( ...@@ -15,8 +16,11 @@ vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/volume/config', () => (
import { import {
buildVolumeConfig, buildVolumeConfig,
createLegacySessionVolumeClaimName,
createSessionVolumeClaimName,
deleteSessionVolume, deleteSessionVolume,
ensureSessionVolume, ensureSessionVolume,
getSessionVolumeClaimName,
getSessionVolumeConfig getSessionVolumeConfig
} from '@fastgpt/service/core/ai/sandbox/infrastructure/volume/service'; } from '@fastgpt/service/core/ai/sandbox/infrastructure/volume/service';
...@@ -27,13 +31,24 @@ describe('sandbox volume service', () => { ...@@ -27,13 +31,24 @@ describe('sandbox volume service', () => {
enable: true, enable: true,
url: 'http://volume-manager.local', url: 'http://volume-manager.local',
token: 'volume-token', token: 'volume-token',
volumeNamePrefix: 'fastgpt-session',
storageSize: '1Gi' storageSize: '1Gi'
}; };
}); });
it('builds provider volume config and persisted storage metadata', () => { it('builds provider volume config and persisted storage metadata', () => {
expect(buildVolumeConfig('claim-1')).toEqual({ expect(buildVolumeConfig('claim-1')).toEqual({
volumes: [{ name: 'workspace', pvc: { claimName: 'claim-1' }, mountPath: '/workspace' }], volumes: [
{
name: 'workspace',
pvc: {
claimName: 'claim-1',
createIfNotExists: false,
deleteOnSandboxTermination: false
},
mountPath: '/workspace'
}
],
storage: { storage: {
volumes: [{ name: 'workspace', claimName: 'claim-1', mountPath: '/workspace' }], volumes: [{ name: 'workspace', claimName: 'claim-1', mountPath: '/workspace' }],
mountPath: '/workspace' mountPath: '/workspace'
...@@ -41,21 +56,51 @@ describe('sandbox volume service', () => { ...@@ -41,21 +56,51 @@ describe('sandbox volume service', () => {
}); });
}); });
it('ensures session volume with auth header', async () => { it('generates a claimName and reads it from storage', () => {
const claimName = createSessionVolumeClaimName({
sandboxId: 'ABC123',
generationId: 'generation1'
});
expect(claimName).toBe('fastgpt-session-abc123-generation1');
expect(getSessionVolumeClaimName(buildVolumeConfig(claimName).storage)).toBe(claimName);
});
it('generates a short prefixed claimName when no generation is provided', () => {
const claimName = createSessionVolumeClaimName({ sandboxId: 'ABC123' });
expect(claimName).toMatch(/^fastgpt-session-abc123-[a-f0-9]{8}$/);
});
it('uses the app-configured volume name prefix', () => {
volumeConfigMock.config.volumeNamePrefix = 'custom-volume';
expect(createSessionVolumeClaimName({ sandboxId: 'ABC123', generationId: 'generation1' })).toBe(
'custom-volume-abc123-generation1'
);
});
it('reconstructs the pre-generation deterministic volume name', () => {
volumeConfigMock.config.volumeNamePrefix = 'legacy-prefix';
expect(createLegacySessionVolumeClaimName('ABC123')).toBe('legacy-prefix-abc123');
});
it('ensures an exact claimName with auth header', async () => {
const fetchMock = vi.fn(async () => ({ const fetchMock = vi.fn(async () => ({
ok: true, ok: true,
json: async () => ({ claimName: 'claim-session-1' }) json: async () => ({ claimName: 'claim-1', created: true })
})); }));
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
await expect(ensureSessionVolume('session-1')).resolves.toBe('claim-session-1'); await expect(ensureSessionVolume('claim-1')).resolves.toBe('claim-1');
expect(fetchMock).toHaveBeenCalledWith('http://volume-manager.local/v1/volumes/ensure', { expect(fetchMock).toHaveBeenCalledWith('http://volume-manager.local/v1/volumes/ensure', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: 'Bearer volume-token' Authorization: 'Bearer volume-token'
}, },
body: JSON.stringify({ sessionId: 'session-1', storageSize: '1Gi' }) body: JSON.stringify({ claimName: 'claim-1', storageSize: '1Gi' })
}); });
}); });
...@@ -64,13 +109,13 @@ describe('sandbox volume service', () => { ...@@ -64,13 +109,13 @@ describe('sandbox volume service', () => {
const fetchMock = vi.fn(async () => ({ const fetchMock = vi.fn(async () => ({
ok: true, ok: true,
status: 204, status: 204,
json: async () => ({ claimName: 'claim-session-1' }), json: async () => ({ claimName: 'claim-1', created: true }),
text: async () => '' text: async () => ''
})); }));
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
await expect(ensureSessionVolume('session-1')).resolves.toBe('claim-session-1'); await expect(ensureSessionVolume('claim-1')).resolves.toBe('claim-1');
await expect(deleteSessionVolume('session-1')).resolves.toBeUndefined(); await expect(deleteSessionVolume('fastgpt-session-claim-1')).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenNthCalledWith( expect(fetchMock).toHaveBeenNthCalledWith(
1, 1,
...@@ -79,12 +124,12 @@ describe('sandbox volume service', () => { ...@@ -79,12 +124,12 @@ describe('sandbox volume service', () => {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ sessionId: 'session-1', storageSize: '1Gi' }) body: JSON.stringify({ claimName: 'claim-1', storageSize: '1Gi' })
}) })
); );
expect(fetchMock).toHaveBeenNthCalledWith( expect(fetchMock).toHaveBeenNthCalledWith(
2, 2,
'http://volume-manager.local/v1/volumes/session-1', 'http://volume-manager.local/v1/volumes/fastgpt-session-claim-1',
expect.objectContaining({ expect.objectContaining({
headers: {} headers: {}
}) })
...@@ -101,23 +146,35 @@ describe('sandbox volume service', () => { ...@@ -101,23 +146,35 @@ describe('sandbox volume service', () => {
})) }))
); );
await expect(ensureSessionVolume('session-1')).rejects.toThrow( await expect(ensureSessionVolume('claim-1')).rejects.toThrow('volume-manager error: 500 boom');
'volume-manager error: 500 boom'
);
}); });
it('deletes session volume and treats disabled or 404 as success', async () => { it('deletes session volume and treats disabled or 404 as success', async () => {
const fetchMock = vi.fn(async () => ({ ok: true, status: 204, text: async () => '' })); const fetchMock = vi.fn(async () => ({ ok: true, status: 204, text: async () => '' }));
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
await expect(deleteSessionVolume('session/a')).resolves.toBeUndefined(); await expect(deleteSessionVolume('fastgpt-session-a')).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('http://volume-manager.local/v1/volumes/session%2Fa', { expect(fetchMock).toHaveBeenCalledWith(
method: 'DELETE', 'http://volume-manager.local/v1/volumes/fastgpt-session-a',
headers: { Authorization: 'Bearer volume-token' } {
}); method: 'DELETE',
headers: { Authorization: 'Bearer volume-token' }
}
);
fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' }); fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'not found' });
await expect(deleteSessionVolume('missing')).resolves.toBeUndefined(); await expect(deleteSessionVolume('fastgpt-session-missing')).resolves.toBeUndefined();
volumeConfigMock.config.volumeNamePrefix = 'new-prefix';
fetchMock.mockResolvedValueOnce({ ok: true, status: 204, text: async () => '' });
await expect(deleteSessionVolume('legacy-prefix-a')).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenLastCalledWith(
'http://volume-manager.local/v1/volumes/legacy-prefix-a',
{
method: 'DELETE',
headers: { Authorization: 'Bearer volume-token' }
}
);
volumeConfigMock.config.enable = false; volumeConfigMock.config.enable = false;
await expect(deleteSessionVolume('disabled')).resolves.toBeUndefined(); await expect(deleteSessionVolume('disabled')).resolves.toBeUndefined();
...@@ -133,7 +190,7 @@ describe('sandbox volume service', () => { ...@@ -133,7 +190,7 @@ describe('sandbox volume service', () => {
})) }))
); );
await expect(deleteSessionVolume('session-1')).rejects.toThrow( await expect(deleteSessionVolume('fastgpt-session-claim-1')).rejects.toThrow(
'volume-manager error: 503 unavailable' 'volume-manager error: 503 unavailable'
); );
}); });
...@@ -141,13 +198,13 @@ describe('sandbox volume service', () => { ...@@ -141,13 +198,13 @@ describe('sandbox volume service', () => {
it('returns undefined when session volume is disabled', async () => { it('returns undefined when session volume is disabled', async () => {
volumeConfigMock.config.enable = false; volumeConfigMock.config.enable = false;
await expect(getSessionVolumeConfig('session-1')).resolves.toBeUndefined(); await expect(getSessionVolumeConfig('claim-1')).resolves.toBeUndefined();
}); });
it('requires volume-manager url when enabled', async () => { it('requires volume-manager url when enabled', async () => {
volumeConfigMock.config.url = ''; volumeConfigMock.config.url = '';
await expect(getSessionVolumeConfig('session-1')).rejects.toThrow( await expect(getSessionVolumeConfig('claim-1')).rejects.toThrow(
'AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL is required' 'AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL is required'
); );
}); });
...@@ -157,12 +214,24 @@ describe('sandbox volume service', () => { ...@@ -157,12 +214,24 @@ describe('sandbox volume service', () => {
'fetch', 'fetch',
vi.fn(async () => ({ vi.fn(async () => ({
ok: true, ok: true,
json: async () => ({ claimName: 'claim-session-1' }) json: async () => ({ claimName: 'claim-1', created: false })
}))
);
await expect(getSessionVolumeConfig('claim-1')).resolves.toEqual(buildVolumeConfig('claim-1'));
});
it('rejects a different claimName returned by volume-manager', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: true,
json: async () => ({ claimName: 'other-claim', created: false })
})) }))
); );
await expect(getSessionVolumeConfig('session-1')).resolves.toEqual( await expect(ensureSessionVolume('claim-1')).rejects.toThrow(
buildVolumeConfig('claim-session-1') 'expected claim-1, received other-claim'
); );
}); });
}); });
...@@ -48,8 +48,7 @@ const { ...@@ -48,8 +48,7 @@ const {
AGENT_ENGINE: 'fastAgent', AGENT_ENGINE: 'fastAgent',
AGENT_SANDBOX_PROVIDER: 'opensandbox', AGENT_SANDBOX_PROVIDER: 'opensandbox',
AGENT_SANDBOX_OPENSANDBOX_RUNTIME: 'docker', AGENT_SANDBOX_OPENSANDBOX_RUNTIME: 'docker',
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: 'fastgpt-agent-sandbox', AGENT_SANDBOX_OPENSANDBOX_IMAGE: 'fastgpt-agent-sandbox:latest',
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: 'latest',
AGENT_SANDBOX_MAX_EDIT_DEBUG: 100, AGENT_SANDBOX_MAX_EDIT_DEBUG: 100,
AGENT_SANDBOX_MAX_SESSION_RUNTIME: 300, AGENT_SANDBOX_MAX_SESSION_RUNTIME: 300,
AGENT_SANDBOX_SEALOS_WORK_DIRECTORY: '/home/devbox/workspace', AGENT_SANDBOX_SEALOS_WORK_DIRECTORY: '/home/devbox/workspace',
......
...@@ -27,7 +27,12 @@ const originalEnv = { ...@@ -27,7 +27,12 @@ const originalEnv = {
AGENT_SANDBOX_SEALOS_TOKEN: process.env.AGENT_SANDBOX_SEALOS_TOKEN, AGENT_SANDBOX_SEALOS_TOKEN: process.env.AGENT_SANDBOX_SEALOS_TOKEN,
AGENT_SANDBOX_SEALOS_IMAGE: process.env.AGENT_SANDBOX_SEALOS_IMAGE, AGENT_SANDBOX_SEALOS_IMAGE: process.env.AGENT_SANDBOX_SEALOS_IMAGE,
AGENT_SANDBOX_OPENSANDBOX_BASEURL: process.env.AGENT_SANDBOX_OPENSANDBOX_BASEURL, AGENT_SANDBOX_OPENSANDBOX_BASEURL: process.env.AGENT_SANDBOX_OPENSANDBOX_BASEURL,
AGENT_SANDBOX_OPENSANDBOX_API_KEY: process.env.AGENT_SANDBOX_OPENSANDBOX_API_KEY AGENT_SANDBOX_OPENSANDBOX_API_KEY: process.env.AGENT_SANDBOX_OPENSANDBOX_API_KEY,
AGENT_SANDBOX_OPENSANDBOX_IMAGE: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE,
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO,
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG,
AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX:
process.env.AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX
}; };
const importServiceEnv = async () => { const importServiceEnv = async () => {
...@@ -66,6 +71,19 @@ describe('serviceEnv', () => { ...@@ -66,6 +71,19 @@ describe('serviceEnv', () => {
vi.stubEnv('AGENT_SANDBOX_SEALOS_IMAGE', originalEnv.AGENT_SANDBOX_SEALOS_IMAGE); vi.stubEnv('AGENT_SANDBOX_SEALOS_IMAGE', originalEnv.AGENT_SANDBOX_SEALOS_IMAGE);
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', originalEnv.AGENT_SANDBOX_OPENSANDBOX_BASEURL); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', originalEnv.AGENT_SANDBOX_OPENSANDBOX_BASEURL);
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_API_KEY', originalEnv.AGENT_SANDBOX_OPENSANDBOX_API_KEY); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_API_KEY', originalEnv.AGENT_SANDBOX_OPENSANDBOX_API_KEY);
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE', originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE);
vi.stubEnv(
'AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO',
originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO
);
vi.stubEnv(
'AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG',
originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG
);
vi.stubEnv(
'AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX',
originalEnv.AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX
);
}); });
it('enables MongoDB index synchronization by default and supports disabling it', async () => { it('enables MongoDB index synchronization by default and supports disabling it', async () => {
...@@ -279,18 +297,35 @@ describe('serviceEnv', () => { ...@@ -279,18 +297,35 @@ describe('serviceEnv', () => {
vi.stubEnv('AGENT_SANDBOX_CPU_COUNT', undefined); vi.stubEnv('AGENT_SANDBOX_CPU_COUNT', undefined);
vi.stubEnv('AGENT_SANDBOX_MEMORY_MIB', undefined); vi.stubEnv('AGENT_SANDBOX_MEMORY_MIB', undefined);
vi.stubEnv('AGENT_SANDBOX_STORAGE_SIZE_GI', undefined); vi.stubEnv('AGENT_SANDBOX_STORAGE_SIZE_GI', undefined);
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX', undefined);
const defaultEnv = await importServiceEnv(); const defaultEnv = await importServiceEnv();
expect(defaultEnv.serviceEnv.AGENT_SANDBOX_CPU_COUNT).toBe(1); expect(defaultEnv.serviceEnv.AGENT_SANDBOX_CPU_COUNT).toBe(1);
expect(defaultEnv.serviceEnv.AGENT_SANDBOX_MEMORY_MIB).toBe(2048); expect(defaultEnv.serviceEnv.AGENT_SANDBOX_MEMORY_MIB).toBe(2048);
expect(defaultEnv.serviceEnv.AGENT_SANDBOX_STORAGE_SIZE_GI).toBe(1); expect(defaultEnv.serviceEnv.AGENT_SANDBOX_STORAGE_SIZE_GI).toBe(1);
expect(defaultEnv.serviceEnv.AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX).toBe(
'fastgpt-session'
);
vi.stubEnv('AGENT_SANDBOX_CPU_COUNT', '2.5'); vi.stubEnv('AGENT_SANDBOX_CPU_COUNT', '2.5');
vi.stubEnv('AGENT_SANDBOX_MEMORY_MIB', '4096'); vi.stubEnv('AGENT_SANDBOX_MEMORY_MIB', '4096');
vi.stubEnv('AGENT_SANDBOX_STORAGE_SIZE_GI', '5'); vi.stubEnv('AGENT_SANDBOX_STORAGE_SIZE_GI', '5');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX', 'custom-volume');
const customEnv = await importServiceEnv(); const customEnv = await importServiceEnv();
expect(customEnv.serviceEnv.AGENT_SANDBOX_CPU_COUNT).toBe(2.5); expect(customEnv.serviceEnv.AGENT_SANDBOX_CPU_COUNT).toBe(2.5);
expect(customEnv.serviceEnv.AGENT_SANDBOX_MEMORY_MIB).toBe(4096); expect(customEnv.serviceEnv.AGENT_SANDBOX_MEMORY_MIB).toBe(4096);
expect(customEnv.serviceEnv.AGENT_SANDBOX_STORAGE_SIZE_GI).toBe(5); expect(customEnv.serviceEnv.AGENT_SANDBOX_STORAGE_SIZE_GI).toBe(5);
expect(customEnv.serviceEnv.AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX).toBe('custom-volume');
});
it('rejects an invalid OpenSandbox volume name prefix', async () => {
vi.stubEnv('FILE_TOKEN_KEY', 'filetokenkey');
vi.stubEnv('AES256_SECRET_KEY', 'fastgptsecret');
vi.stubEnv('INVOKE_TOKEN_SECRET', validInvokeTokenSecret);
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX', 'invalid_prefix');
await expect(importServiceEnv()).rejects.toThrow(
'Invalid environment variables. Please check: AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX'
);
}); });
it('validates Agent Sandbox lifecycle thresholds during service env init', async () => { it('validates Agent Sandbox lifecycle thresholds during service env init', async () => {
...@@ -336,6 +371,7 @@ describe('serviceEnv', () => { ...@@ -336,6 +371,7 @@ describe('serviceEnv', () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', 'opensandbox'); vi.stubEnv('AGENT_SANDBOX_PROVIDER', 'opensandbox');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', 'http://mock-opensandbox.local'); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', 'http://mock-opensandbox.local');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_API_KEY', 'mock-opensandbox-api-key'); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_API_KEY', 'mock-opensandbox-api-key');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE', 'fastgpt-agent-sandbox:latest');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL', 'http://mock-volume-manager.local'); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL', 'http://mock-volume-manager.local');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN', 'mock-volume-manager-token'); vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN', 'mock-volume-manager-token');
vi.stubEnv('AGENT_SANDBOX_PROXY_SECRET', ''); vi.stubEnv('AGENT_SANDBOX_PROXY_SECRET', '');
...@@ -343,4 +379,20 @@ describe('serviceEnv', () => { ...@@ -343,4 +379,20 @@ describe('serviceEnv', () => {
await expect(importServiceEnv()).resolves.toBeDefined(); await expect(importServiceEnv()).resolves.toBeDefined();
}); });
it('保留 OpenSandbox 旧镜像环境变量供升级兼容', async () => {
vi.stubEnv('FILE_TOKEN_KEY', 'filetokenkey');
vi.stubEnv('AES256_SECRET_KEY', 'fastgptsecret');
vi.stubEnv('INVOKE_TOKEN_SECRET', validInvokeTokenSecret);
vi.stubEnv('AGENT_SANDBOX_PROVIDER', '');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO', 'legacy/runtime-image');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG', 'legacy-stable');
await expect(importServiceEnv()).resolves.toMatchObject({
serviceEnv: {
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: 'legacy/runtime-image',
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: 'legacy-stable'
}
});
});
}); });
...@@ -114,7 +114,7 @@ describe('validateS3Env', () => { ...@@ -114,7 +114,7 @@ describe('validateS3Env', () => {
}); });
describe('env util', () => { describe('env util', () => {
it('requires opensandbox volume manager env when opensandbox provider is enabled', () => { it('requires opensandbox image and volume manager env when opensandbox provider is enabled', () => {
expect( expect(
getAgentSandboxMissingRequiredEnvKeys({ getAgentSandboxMissingRequiredEnvKeys({
AGENT_SANDBOX_PROVIDER: 'opensandbox', AGENT_SANDBOX_PROVIDER: 'opensandbox',
...@@ -122,11 +122,25 @@ describe('env util', () => { ...@@ -122,11 +122,25 @@ describe('env util', () => {
AGENT_SANDBOX_OPENSANDBOX_API_KEY: 'opensandbox-key' AGENT_SANDBOX_OPENSANDBOX_API_KEY: 'opensandbox-key'
} as NodeJS.ProcessEnv) } as NodeJS.ProcessEnv)
).toEqual([ ).toEqual([
'AGENT_SANDBOX_OPENSANDBOX_IMAGE',
'AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL', 'AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL',
'AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN' 'AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN'
]); ]);
}); });
it('accepts the legacy opensandbox image repository as the image fallback', () => {
expect(
getAgentSandboxMissingRequiredEnvKeys({
AGENT_SANDBOX_PROVIDER: 'opensandbox',
AGENT_SANDBOX_OPENSANDBOX_BASEURL: 'http://opensandbox.local',
AGENT_SANDBOX_OPENSANDBOX_API_KEY: 'opensandbox-key',
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: 'legacy/runtime',
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL: 'http://volume-manager.local',
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN: 'volume-token'
} as NodeJS.ProcessEnv)
).toEqual([]);
});
it('does not require sandbox env for an unsupported provider', () => { it('does not require sandbox env for an unsupported provider', () => {
expect( expect(
getAgentSandboxMissingRequiredEnvKeys({ getAgentSandboxMissingRequiredEnvKeys({
......
...@@ -1831,6 +1831,9 @@ importers: ...@@ -1831,6 +1831,9 @@ importers:
projects/volume-manager: projects/volume-manager:
dependencies: dependencies:
'@fastgpt/global':
specifier: workspace:*
version: link:../../packages/global
'@hono/node-server': '@hono/node-server':
specifier: 'catalog:' specifier: 'catalog:'
version: 2.0.12(hono@4.12.27) version: 2.0.12(hono@4.12.27)
Subproject commit 52a34d749dbea06819819f69a881d1eb6af61df8 Subproject commit c307c54657d4c29beec71742fe278aa995cd2711
...@@ -60,11 +60,11 @@ AGENT_SANDBOX_STORAGE_SIZE_GI=1 ...@@ -60,11 +60,11 @@ AGENT_SANDBOX_STORAGE_SIZE_GI=1
AGENT_SANDBOX_OPENSANDBOX_BASEURL=http://localhost:8090 AGENT_SANDBOX_OPENSANDBOX_BASEURL=http://localhost:8090
AGENT_SANDBOX_OPENSANDBOX_API_KEY=my_secure_sandbox_key_123 AGENT_SANDBOX_OPENSANDBOX_API_KEY=my_secure_sandbox_key_123
AGENT_SANDBOX_OPENSANDBOX_RUNTIME=docker AGENT_SANDBOX_OPENSANDBOX_RUNTIME=docker
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO=registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt-agent-sandbox AGENT_SANDBOX_OPENSANDBOX_IMAGE=registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt-agent-sandbox:v0.1
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG=v0.1
AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY=true AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY=true
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL=http://localhost:3005 AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL=http://localhost:3005
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN=vmtoken AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN=vmtoken
AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX=fastgpt-session
# 活跃编辑/调试沙箱数量上限 # 活跃编辑/调试沙箱数量上限
AGENT_SANDBOX_MAX_EDIT_DEBUG=100 AGENT_SANDBOX_MAX_EDIT_DEBUG=100
......
...@@ -57,8 +57,7 @@ vi.mock('@fastgpt/service/env', async (importOriginal) => { ...@@ -57,8 +57,7 @@ vi.mock('@fastgpt/service/env', async (importOriginal) => {
AGENT_SANDBOX_OPENSANDBOX_BASEURL: 'http://mock-opensandbox.local', AGENT_SANDBOX_OPENSANDBOX_BASEURL: 'http://mock-opensandbox.local',
AGENT_SANDBOX_OPENSANDBOX_API_KEY: 'mock-opensandbox-api-key', AGENT_SANDBOX_OPENSANDBOX_API_KEY: 'mock-opensandbox-api-key',
AGENT_SANDBOX_OPENSANDBOX_RUNTIME: 'docker', AGENT_SANDBOX_OPENSANDBOX_RUNTIME: 'docker',
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: 'runtime-image', AGENT_SANDBOX_OPENSANDBOX_IMAGE: 'runtime-image:test',
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: 'test',
AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY: false AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY: false
} }
}; };
......
...@@ -17,8 +17,5 @@ VM_K8S_NAMESPACE=opensandbox ...@@ -17,8 +17,5 @@ VM_K8S_NAMESPACE=opensandbox
# k8s StorageClass 名称(仅 kubernetes 模式) # k8s StorageClass 名称(仅 kubernetes 模式)
VM_K8S_PVC_STORAGE_CLASS=standard VM_K8S_PVC_STORAGE_CLASS=standard
# volume 名称前缀,最终 volume 名为 {prefix}-{sessionId hash}
VM_VOLUME_NAME_PREFIX=fastgpt-session
# 日志级别:debug | info | none # 日志级别:debug | info | none
VM_LOG_LEVEL=info VM_LOG_LEVEL=info
...@@ -10,6 +10,8 @@ ARG proxy ...@@ -10,6 +10,8 @@ ARG proxy
RUN npm install -g pnpm@10.33.2 RUN npm install -g pnpm@10.33.2
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./
COPY packages/global/ ./packages/global/
COPY sdk/sandbox-adapter/ ./sdk/sandbox-adapter/
COPY projects/volume-manager/ ./projects/volume-manager/ COPY projects/volume-manager/ ./projects/volume-manager/
RUN if [ -z "$proxy" ]; then \ RUN if [ -z "$proxy" ]; then \
......
# volume-manager # volume-manager
FastGPT Agent 沙箱存储卷管理服务。负责为每个 Agent 会话创建和销毁持久化存储卷,支持 Kubernetes PVC 和 Docker Volume 两种运行时。 FastGPT Agent 沙箱存储卷管理服务。负责按 FastGPT 分配的精确 `claimName` 创建和销毁持久化存储卷,支持 Kubernetes PVC 和 Docker Volume 两种运行时。
## 技术栈 ## 技术栈
...@@ -43,10 +43,11 @@ GET /health ...@@ -43,10 +43,11 @@ GET /health
POST /v1/volumes/ensure POST /v1/volumes/ensure
Content-Type: application/json Content-Type: application/json
{ "sessionId": "<24位十六进制字符串>", "storageSize": "1Gi" } { "claimName": "fastgpt-session-<sandboxId>-<generation>", "storageSize": "1Gi" }
``` ```
`storageSize` 可选,仅 k8s 模式下创建新 PVC 时有效;旧客户端未传入时兼容使用 `1Gi` `claimName` 由 FastGPT 生成并先持久化;volume-manager 不再根据会话 ID 推导名称。
`storageSize` 可选,仅 k8s 模式下创建新 PVC 时有效,未传入时使用 `1Gi`
- 卷已存在:返回 `200``{ "claimName": "...", "created": false }` - 卷已存在:返回 `200``{ "claimName": "...", "created": false }`
- 卷新建:返回 `201``{ "claimName": "...", "created": true }` - 卷新建:返回 `201``{ "claimName": "...", "created": true }`
...@@ -54,11 +55,16 @@ Content-Type: application/json ...@@ -54,11 +55,16 @@ Content-Type: application/json
### 删除存储卷 ### 删除存储卷
``` ```
DELETE /v1/volumes/:sessionId DELETE /v1/volumes/:claimName
``` ```
响应:`204 No Content`(幂等,卷不存在时同样返回 204) 响应:`204 No Content`(幂等,卷不存在时同样返回 204)
Kubernetes 模式下,`204` 表示目标 PVC generation 已完成删除(PVC 对象已不存在或已被新的
UID generation 替换),不是仅表示 API Server 接受了 DELETE 请求。Terminating PVC 会被轮询到
删除完成;等待超时或 Kubernetes 返回其他错误时接口返回失败。Docker 模式保持 Docker API
原有的同步删除语义。
## 环境变量 ## 环境变量
| 变量 | 必填 | 默认值 | 说明 | | 变量 | 必填 | 默认值 | 说明 |
...@@ -67,7 +73,6 @@ DELETE /v1/volumes/:sessionId ...@@ -67,7 +73,6 @@ DELETE /v1/volumes/:sessionId
| `VM_RUNTIME` | | `kubernetes` | 运行时:`kubernetes``docker` | | `VM_RUNTIME` | | `kubernetes` | 运行时:`kubernetes``docker` |
| `VM_PORT` | | `3001` | 监听端口 | | `VM_PORT` | | `3001` | 监听端口 |
| `VM_LOG_LEVEL` | | `info` | 日志级别:`debug` / `info` / `none` | | `VM_LOG_LEVEL` | | `info` | 日志级别:`debug` / `info` / `none` |
| `VM_VOLUME_NAME_PREFIX` | | `fastgpt-session` | 卷名前缀 |
| `VM_DOCKER_SOCKET` | | `/var/run/docker.sock` | Docker socket 路径(docker 模式) | | `VM_DOCKER_SOCKET` | | `/var/run/docker.sock` | Docker socket 路径(docker 模式) |
| `VM_K8S_NAMESPACE` | | `opensandbox` | PVC 所在命名空间(k8s 模式) | | `VM_K8S_NAMESPACE` | | `opensandbox` | PVC 所在命名空间(k8s 模式) |
| `VM_K8S_PVC_STORAGE_CLASS` | | `''` | PVC StorageClass(k8s 模式) | | `VM_K8S_PVC_STORAGE_CLASS` | | `''` | PVC StorageClass(k8s 模式) |
...@@ -132,7 +137,6 @@ src/ ...@@ -132,7 +137,6 @@ src/
│ ├── DockerVolumeDriver.ts │ ├── DockerVolumeDriver.ts
│ └── K8sVolumeDriver.ts │ └── K8sVolumeDriver.ts
└── utils/ └── utils/
├── naming.ts # 卷名生成(sessionId → volume name)
└── logger.ts # 日志工具 └── logger.ts # 日志工具
``` ```
......
...@@ -14,6 +14,7 @@ ...@@ -14,6 +14,7 @@
"pnpm": "10.x" "pnpm": "10.x"
}, },
"dependencies": { "dependencies": {
"@fastgpt/global": "workspace:*",
"@hono/node-server": "catalog:", "@hono/node-server": "catalog:",
"hono": "catalog:", "hono": "catalog:",
"undici": "catalog:", "undici": "catalog:",
......
import { Agent } from 'undici'; import { Agent } from 'undici';
import type { IVolumeDriver, EnsureResult } from './IVolumeDriver'; import {
import { toVolumeName } from '../utils/naming'; SandboxVolumeNameSchema,
type SandboxVolumeEnsureRequest,
type SandboxVolumeEnsureResponse
} from '@fastgpt/global/core/ai/sandbox/volume';
import type { IVolumeDriver } from './IVolumeDriver';
import { env } from '../env'; import { env } from '../env';
import { logDebug } from '../utils/logger'; import { logDebug } from '../utils/logger';
export class DockerVolumeDriver implements IVolumeDriver { export class DockerVolumeDriver implements IVolumeDriver {
private readonly socketPath: string; private readonly socketPath: string;
private readonly prefix: string;
private readonly dispatcher: Agent; private readonly dispatcher: Agent;
constructor(socketPath = env.VM_DOCKER_SOCKET, prefix = env.VM_VOLUME_NAME_PREFIX) { constructor(socketPath = env.VM_DOCKER_SOCKET) {
this.socketPath = socketPath; this.socketPath = socketPath;
this.prefix = prefix;
this.dispatcher = new Agent({ connect: { socketPath } }); this.dispatcher = new Agent({ connect: { socketPath } });
} }
...@@ -23,8 +25,8 @@ export class DockerVolumeDriver implements IVolumeDriver { ...@@ -23,8 +25,8 @@ export class DockerVolumeDriver implements IVolumeDriver {
} as RequestInit & { dispatcher: Agent }); } as RequestInit & { dispatcher: Agent });
} }
async ensure(sessionId: string): Promise<EnsureResult> { async ensure(params: SandboxVolumeEnsureRequest): Promise<SandboxVolumeEnsureResponse> {
const name = toVolumeName(this.prefix, sessionId); const name = SandboxVolumeNameSchema.parse(params.claimName);
// Check if volume already exists // Check if volume already exists
logDebug(`Docker inspect volume name=${name}`); logDebug(`Docker inspect volume name=${name}`);
...@@ -57,8 +59,8 @@ export class DockerVolumeDriver implements IVolumeDriver { ...@@ -57,8 +59,8 @@ export class DockerVolumeDriver implements IVolumeDriver {
return { claimName: name, created: true }; return { claimName: name, created: true };
} }
async remove(sessionId: string): Promise<void> { async remove(claimName: string): Promise<void> {
const name = toVolumeName(this.prefix, sessionId); const name = SandboxVolumeNameSchema.parse(claimName);
logDebug(`Docker remove volume name=${name}`); logDebug(`Docker remove volume name=${name}`);
const res = await this.dockerFetch(`/volumes/${name}`, { method: 'DELETE' }); const res = await this.dockerFetch(`/volumes/${name}`, { method: 'DELETE' });
logDebug(`Docker remove volume status=${res.status}`); logDebug(`Docker remove volume status=${res.status}`);
......
export type EnsureResult = { import type {
claimName: string; SandboxVolumeEnsureRequest,
created: boolean; SandboxVolumeEnsureResponse
}; } from '@fastgpt/global/core/ai/sandbox/volume';
export type IVolumeDriver = { export type IVolumeDriver = {
ensure(sessionId: string, storageSize?: string): Promise<EnsureResult>; ensure(params: SandboxVolumeEnsureRequest): Promise<SandboxVolumeEnsureResponse>;
remove(sessionId: string): Promise<void>; remove(claimName: string): Promise<void>;
}; };
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { Agent } from 'undici'; import { Agent } from 'undici';
import type { IVolumeDriver, EnsureResult } from './IVolumeDriver'; import { z } from 'zod';
import { toVolumeName } from '../utils/naming'; import {
SandboxVolumeNameSchema,
type SandboxVolumeEnsureRequest,
type SandboxVolumeEnsureResponse
} from '@fastgpt/global/core/ai/sandbox/volume';
import type { IVolumeDriver } from './IVolumeDriver';
import { env } from '../env'; import { env } from '../env';
import { logDebug } from '../utils/logger'; import { logDebug } from '../utils/logger';
...@@ -9,23 +14,42 @@ const K8S_API = 'https://kubernetes.default.svc'; ...@@ -9,23 +14,42 @@ const K8S_API = 'https://kubernetes.default.svc';
const TOKEN_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/token'; const TOKEN_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/token';
const CA_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt'; const CA_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt';
const DEFAULT_PVC_STORAGE_SIZE = '1Gi'; const DEFAULT_PVC_STORAGE_SIZE = '1Gi';
const DEFAULT_PVC_WAIT_TIMEOUT_MS = 5 * 60 * 1000;
const DEFAULT_PVC_POLL_INTERVAL_MS = 500;
const K8sPvcSchema = z.object({
metadata: z.object({
uid: z.string().min(1),
deletionTimestamp: z.string().nullable().optional()
})
});
type K8sPvcState =
| { state: 'absent' }
| { state: 'active'; uid: string }
| { state: 'deleting'; uid: string };
export type K8sVolumeDriverOptions = {
namespace?: string;
waitTimeoutMs?: number;
pollIntervalMs?: number;
};
function readToken(): string { function readToken(): string {
return readFileSync(TOKEN_PATH, 'utf-8').trim(); return readFileSync(TOKEN_PATH, 'utf-8').trim();
} }
function pvcBody(name: string, sessionId: string, storageSize: string): object { function pvcBody(params: { name: string; storageSize: string; namespace: string }): object {
return { return {
apiVersion: 'v1', apiVersion: 'v1',
kind: 'PersistentVolumeClaim', kind: 'PersistentVolumeClaim',
metadata: { metadata: {
name, name: params.name,
namespace: env.VM_K8S_NAMESPACE, namespace: params.namespace
labels: { 'fastgpt/session-id': sessionId }
}, },
spec: { spec: {
accessModes: ['ReadWriteOnce'], accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: storageSize } }, resources: { requests: { storage: params.storageSize } },
storageClassName: env.VM_K8S_PVC_STORAGE_CLASS storageClassName: env.VM_K8S_PVC_STORAGE_CLASS
} }
}; };
...@@ -33,12 +57,18 @@ function pvcBody(name: string, sessionId: string, storageSize: string): object { ...@@ -33,12 +57,18 @@ function pvcBody(name: string, sessionId: string, storageSize: string): object {
export class K8sVolumeDriver implements IVolumeDriver { export class K8sVolumeDriver implements IVolumeDriver {
private readonly namespace: string; private readonly namespace: string;
private readonly prefix: string; private readonly waitTimeoutMs: number;
private readonly pollIntervalMs: number;
private dispatcher?: Agent; private dispatcher?: Agent;
constructor(namespace = env.VM_K8S_NAMESPACE, prefix = env.VM_VOLUME_NAME_PREFIX) { constructor(options: K8sVolumeDriverOptions = {}) {
this.namespace = namespace; this.namespace = options.namespace ?? env.VM_K8S_NAMESPACE;
this.prefix = prefix; this.waitTimeoutMs = options.waitTimeoutMs ?? DEFAULT_PVC_WAIT_TIMEOUT_MS;
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_PVC_POLL_INTERVAL_MS;
if (this.waitTimeoutMs <= 0 || this.pollIntervalMs <= 0) {
throw new Error('K8s PVC wait timeout and poll interval must be positive');
}
} }
private fetchOpts(extra: RequestInit = {}): RequestInit & { dispatcher: Agent } { private fetchOpts(extra: RequestInit = {}): RequestInit & { dispatcher: Agent } {
...@@ -60,45 +90,115 @@ export class K8sVolumeDriver implements IVolumeDriver { ...@@ -60,45 +90,115 @@ export class K8sVolumeDriver implements IVolumeDriver {
return name ? `${base}/${name}` : base; return name ? `${base}/${name}` : base;
} }
async ensure(sessionId: string, storageSize = DEFAULT_PVC_STORAGE_SIZE): Promise<EnsureResult> { /** 读取 PVC 的 generation 和删除状态,避免把 Terminating 对象当成可挂载资源。 */
const name = toVolumeName(this.prefix, sessionId); private async readPvc(name: string): Promise<K8sPvcState> {
const getUrl = this.pvcUrl(name); const url = this.pvcUrl(name);
logDebug(`K8s GET PVC url=${url}`);
const res = await fetch(url, this.fetchOpts({ headers: this.headers() }));
logDebug(`K8s GET PVC status=${res.status}`);
logDebug(`K8s GET PVC url=${getUrl}`); if (res.status === 404) return { state: 'absent' };
const getRes = await fetch(getUrl, this.fetchOpts({ headers: this.headers() })); if (!res.ok) {
logDebug(`K8s GET PVC status=${getRes.status}`); const text = await res.text().catch(() => '');
throw new Error(`K8s PVC GET failed (${res.status}): ${text}`);
}
if (getRes.ok) { const body: unknown = await res.json();
return { claimName: name, created: false }; const parsed = K8sPvcSchema.safeParse(body);
if (!parsed.success) {
throw new Error(`K8s PVC GET returned invalid metadata for ${this.namespace}/${name}`);
} }
const { uid, deletionTimestamp } = parsed.data.metadata;
return deletionTimestamp ? { state: 'deleting', uid } : { state: 'active', uid };
}
if (getRes.status !== 404) { /**
const text = await getRes.text().catch(() => ''); * 等待目标 PVC generation 结束。
throw new Error(`K8s PVC GET failed (${getRes.status}): ${text}`); *
* 同名对象 UID 变化表示旧 generation 已完成删除;此时必须停止等待,不能继续操作新 PVC。
*/
private async waitForPvcGenerationEnd(params: {
name: string;
uid: string;
deadline: number;
}): Promise<void> {
while (true) {
const current = await this.readPvc(params.name);
if (current.state === 'absent' || current.uid !== params.uid) return;
await this.waitForPoll(
params.deadline,
`Timed out waiting for K8s PVC ${this.namespace}/${params.name} uid=${params.uid} to be deleted`
);
} }
}
const postUrl = this.pvcUrl(); private async waitForPoll(deadline: number, timeoutMessage: string): Promise<void> {
logDebug(`K8s POST PVC url=${postUrl} name=${name}`); const remainingMs = deadline - Date.now();
const createRes = await fetch( if (remainingMs <= 0) {
postUrl, throw new Error(timeoutMessage);
this.fetchOpts({ }
method: 'POST', await new Promise((resolve) => setTimeout(resolve, Math.min(this.pollIntervalMs, remainingMs)));
headers: this.headers(), }
body: JSON.stringify(pvcBody(name, sessionId, storageSize))
}) async ensure(params: SandboxVolumeEnsureRequest): Promise<SandboxVolumeEnsureResponse> {
); const name = SandboxVolumeNameSchema.parse(params.claimName);
logDebug(`K8s POST PVC status=${createRes.status}`); const storageSize = params.storageSize ?? DEFAULT_PVC_STORAGE_SIZE;
const deadline = Date.now() + this.waitTimeoutMs;
while (true) {
const current = await this.readPvc(name);
if (current.state === 'active') {
return { claimName: name, created: false };
}
if (current.state === 'deleting') {
await this.waitForPvcGenerationEnd({ name, uid: current.uid, deadline });
continue;
}
const postUrl = this.pvcUrl();
logDebug(`K8s POST PVC url=${postUrl} name=${name}`);
const createRes = await fetch(
postUrl,
this.fetchOpts({
method: 'POST',
headers: this.headers(),
body: JSON.stringify(
pvcBody({
name,
storageSize,
namespace: this.namespace
})
)
})
);
logDebug(`K8s POST PVC status=${createRes.status}`);
if (createRes.ok) {
return { claimName: name, created: true };
}
if (createRes.status === 409) {
await this.waitForPoll(deadline, `Timed out ensuring K8s PVC ${this.namespace}/${name}`);
continue;
}
if (!createRes.ok) {
const text = await createRes.text().catch(() => ''); const text = await createRes.text().catch(() => '');
throw new Error(`K8s PVC create failed (${createRes.status}): ${text}`); throw new Error(`K8s PVC create failed (${createRes.status}): ${text}`);
} }
return { claimName: name, created: true };
} }
async remove(sessionId: string): Promise<void> { async remove(claimName: string): Promise<void> {
const name = toVolumeName(this.prefix, sessionId); const name = SandboxVolumeNameSchema.parse(claimName);
const current = await this.readPvc(name);
if (current.state === 'absent') return;
const targetUid = current.uid;
const deadline = Date.now() + this.waitTimeoutMs;
if (current.state === 'deleting') {
await this.waitForPvcGenerationEnd({ name, uid: targetUid, deadline });
return;
}
const delUrl = this.pvcUrl(name); const delUrl = this.pvcUrl(name);
logDebug(`K8s DELETE PVC url=${delUrl}`); logDebug(`K8s DELETE PVC url=${delUrl}`);
...@@ -106,14 +206,28 @@ export class K8sVolumeDriver implements IVolumeDriver { ...@@ -106,14 +206,28 @@ export class K8sVolumeDriver implements IVolumeDriver {
delUrl, delUrl,
this.fetchOpts({ this.fetchOpts({
method: 'DELETE', method: 'DELETE',
headers: this.headers() headers: this.headers(),
body: JSON.stringify({
apiVersion: 'v1',
kind: 'DeleteOptions',
preconditions: { uid: targetUid }
})
}) })
); );
logDebug(`K8s DELETE PVC status=${res.status}`); logDebug(`K8s DELETE PVC status=${res.status}`);
if (!res.ok && res.status !== 404) { if (res.status === 404) return;
if (res.status === 409) {
const latest = await this.readPvc(name);
if (latest.state === 'absent' || latest.uid !== targetUid) return;
const text = await res.text().catch(() => ''); const text = await res.text().catch(() => '');
throw new Error(`K8s PVC delete failed (${res.status}): ${text}`); throw new Error(`K8s PVC delete failed (${res.status}): ${text}`);
} }
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`K8s PVC delete failed (${res.status}): ${text}`);
}
await this.waitForPvcGenerationEnd({ name, uid: targetUid, deadline });
} }
} }
...@@ -8,7 +8,6 @@ const schema = z.object({ ...@@ -8,7 +8,6 @@ const schema = z.object({
VM_DOCKER_API_VERSION: z.string().default('v1.44'), VM_DOCKER_API_VERSION: z.string().default('v1.44'),
VM_K8S_NAMESPACE: z.string().default('opensandbox'), VM_K8S_NAMESPACE: z.string().default('opensandbox'),
VM_K8S_PVC_STORAGE_CLASS: z.string().default(''), VM_K8S_PVC_STORAGE_CLASS: z.string().default(''),
VM_VOLUME_NAME_PREFIX: z.string().default('fastgpt-session'),
VM_LOG_LEVEL: z.enum(['debug', 'info', 'none']).default('info') VM_LOG_LEVEL: z.enum(['debug', 'info', 'none']).default('info')
}); });
......
import { Hono } from 'hono'; import { Hono } from 'hono';
import { z } from 'zod'; import { SandboxVolumeEnsureRequestSchema } from '@fastgpt/global/core/ai/sandbox/volume';
import type { VolumeService } from '../services/VolumeService'; import type { VolumeService } from '../services/VolumeService';
import { logInfo } from '../utils/logger'; import { logInfo } from '../utils/logger';
export const EnsureVolumeBodySchema = z.object({
sessionId: z.string(),
storageSize: z.string().trim().min(1).max(64).optional()
});
export type EnsureVolumeBody = z.infer<typeof EnsureVolumeBodySchema>;
export function volumeRoutes(service: VolumeService): Hono { export function volumeRoutes(service: VolumeService): Hono {
const app = new Hono(); const app = new Hono();
// POST /v1/volumes/ensure // POST /v1/volumes/ensure
app.post('/ensure', async (c) => { app.post('/ensure', async (c) => {
const body = await c.req.json().catch(() => null); const body = await c.req.json().catch(() => null);
const parsed = EnsureVolumeBodySchema.safeParse(body); const parsed = SandboxVolumeEnsureRequestSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return c.json({ error: 'Invalid request body', details: parsed.error.issues }, 400); return c.json({ error: 'Invalid request body', details: parsed.error.issues }, 400);
} }
const { sessionId, storageSize } = parsed.data; const { claimName, storageSize } = parsed.data;
logInfo(`POST /v1/volumes/ensure sessionId=${sessionId}`); logInfo(`POST /v1/volumes/ensure claimName=${claimName}`);
const result = await service.ensure(sessionId, storageSize); const result = await service.ensure({ claimName, storageSize });
const status = result.created ? 201 : 200; const status = result.created ? 201 : 200;
logInfo(`ensure done claimName=${result.claimName} created=${result.created} status=${status}`); logInfo(`ensure done claimName=${result.claimName} created=${result.created} status=${status}`);
return c.json(result, status); return c.json(result, status);
}); });
// DELETE /v1/volumes/:sessionId // DELETE /v1/volumes/:claimName
app.delete('/:sessionId', async (c) => { app.delete('/:claimName', async (c) => {
const sessionId = c.req.param('sessionId'); const claimName = c.req.param('claimName');
logInfo(`DELETE /v1/volumes/${sessionId}`); logInfo(`DELETE /v1/volumes/${claimName}`);
await service.remove(sessionId); await service.remove(claimName);
logInfo(`remove done sessionId=${sessionId}`); logInfo(`remove done claimName=${claimName}`);
return c.body(null, 204); return c.body(null, 204);
}); });
......
import type { IVolumeDriver, EnsureResult } from '../drivers/IVolumeDriver'; import type {
SandboxVolumeEnsureRequest,
SandboxVolumeEnsureResponse
} from '@fastgpt/global/core/ai/sandbox/volume';
import type { IVolumeDriver } from '../drivers/IVolumeDriver';
import { logDebug } from '../utils/logger'; import { logDebug } from '../utils/logger';
export class VolumeService { export class VolumeService {
constructor(private readonly driver: IVolumeDriver) {} constructor(private readonly driver: IVolumeDriver) {}
async ensure(sessionId: string, storageSize?: string): Promise<EnsureResult> { async ensure(params: SandboxVolumeEnsureRequest): Promise<SandboxVolumeEnsureResponse> {
logDebug(`VolumeService.ensure sessionId=${sessionId}`); logDebug(`VolumeService.ensure claimName=${params.claimName}`);
const result = await this.driver.ensure(sessionId, storageSize); const result = await this.driver.ensure(params);
logDebug(`VolumeService.ensure done claimName=${result.claimName} created=${result.created}`); logDebug(`VolumeService.ensure done claimName=${result.claimName} created=${result.created}`);
return result; return result;
} }
async remove(sessionId: string): Promise<void> { async remove(claimName: string): Promise<void> {
logDebug(`VolumeService.remove sessionId=${sessionId}`); logDebug(`VolumeService.remove claimName=${claimName}`);
await this.driver.remove(sessionId); await this.driver.remove(claimName);
logDebug(`VolumeService.remove done sessionId=${sessionId}`); logDebug(`VolumeService.remove done claimName=${claimName}`);
} }
} }
// sessionId: lowercase alphanumeric and hyphens, no leading/trailing hyphen, 1-253 chars
const SESSION_ID_RE = /^[a-z0-9]([a-z0-9-]{0,251}[a-z0-9])?$/;
export function toVolumeName(prefix: string, sessionId: string): string {
const normalized = sessionId.toLowerCase();
if (!SESSION_ID_RE.test(normalized)) {
throw new Error(
`Invalid sessionId: must be lowercase alphanumeric/hyphens, got "${sessionId}"`
);
}
return `${prefix}-${normalized}`;
}
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const VALID_ID = 'a1b2c3d4e5f6a1b2c3d4e5f6'; const VOLUME_NAME = 'fastgpt-session-a1b2c3d4e5f6a1b2c3d4e5f6-generation';
const VOLUME_NAME = `fastgpt-session-${VALID_ID}`;
// Mock env before importing driver // Mock env before importing driver
vi.mock('../../src/env', () => ({ vi.mock('../../src/env', () => ({
env: { env: {
VM_DOCKER_SOCKET: '/var/run/docker.sock', VM_DOCKER_SOCKET: '/var/run/docker.sock'
VM_VOLUME_NAME_PREFIX: 'fastgpt-session'
} }
})); }));
...@@ -27,7 +25,7 @@ describe('DockerVolumeDriver', () => { ...@@ -27,7 +25,7 @@ describe('DockerVolumeDriver', () => {
fetchMock.mockResolvedValueOnce({ ok: true, status: 200 }); fetchMock.mockResolvedValueOnce({ ok: true, status: 200 });
const { DockerVolumeDriver } = await import('../../src/drivers/DockerVolumeDriver'); const { DockerVolumeDriver } = await import('../../src/drivers/DockerVolumeDriver');
const driver = new DockerVolumeDriver(); const driver = new DockerVolumeDriver();
const result = await driver.ensure(VALID_ID); const result = await driver.ensure({ claimName: VOLUME_NAME });
expect(result).toEqual({ claimName: VOLUME_NAME, created: false }); expect(result).toEqual({ claimName: VOLUME_NAME, created: false });
const [, opts] = fetchMock.mock.calls[0]; const [, opts] = fetchMock.mock.calls[0];
expect((opts as any).dispatcher).toBeTruthy(); expect((opts as any).dispatcher).toBeTruthy();
...@@ -39,7 +37,7 @@ describe('DockerVolumeDriver', () => { ...@@ -39,7 +37,7 @@ describe('DockerVolumeDriver', () => {
.mockResolvedValueOnce({ ok: true, status: 201 }); .mockResolvedValueOnce({ ok: true, status: 201 });
const { DockerVolumeDriver } = await import('../../src/drivers/DockerVolumeDriver'); const { DockerVolumeDriver } = await import('../../src/drivers/DockerVolumeDriver');
const driver = new DockerVolumeDriver(); const driver = new DockerVolumeDriver();
const result = await driver.ensure(VALID_ID); const result = await driver.ensure({ claimName: VOLUME_NAME });
expect(result).toEqual({ claimName: VOLUME_NAME, created: true }); expect(result).toEqual({ claimName: VOLUME_NAME, created: true });
expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2);
}); });
...@@ -48,13 +46,13 @@ describe('DockerVolumeDriver', () => { ...@@ -48,13 +46,13 @@ describe('DockerVolumeDriver', () => {
fetchMock.mockResolvedValueOnce({ ok: false, status: 500, text: async () => 'server error' }); fetchMock.mockResolvedValueOnce({ ok: false, status: 500, text: async () => 'server error' });
const { DockerVolumeDriver } = await import('../../src/drivers/DockerVolumeDriver'); const { DockerVolumeDriver } = await import('../../src/drivers/DockerVolumeDriver');
const driver = new DockerVolumeDriver(); const driver = new DockerVolumeDriver();
await expect(driver.ensure(VALID_ID)).rejects.toThrow('500'); await expect(driver.ensure({ claimName: VOLUME_NAME })).rejects.toThrow('500');
}); });
it('remove treats 404 as success', async () => { it('remove treats 404 as success', async () => {
fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '' }); fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '' });
const { DockerVolumeDriver } = await import('../../src/drivers/DockerVolumeDriver'); const { DockerVolumeDriver } = await import('../../src/drivers/DockerVolumeDriver');
const driver = new DockerVolumeDriver(); const driver = new DockerVolumeDriver();
await expect(driver.remove(VALID_ID)).resolves.toBeUndefined(); await expect(driver.remove(VOLUME_NAME)).resolves.toBeUndefined();
}); });
}); });
...@@ -15,7 +15,7 @@ function makeDriver(): IVolumeDriver { ...@@ -15,7 +15,7 @@ function makeDriver(): IVolumeDriver {
}; };
} }
const VALID_ID = 'a1b2c3d4e5f6a1b2c3d4e5f6'; const CLAIM_NAME = 'fastgpt-session-a1b2c3d4e5f6a1b2c3d4e5f6-generation';
describe('VolumeService', () => { describe('VolumeService', () => {
let driver: IVolumeDriver; let driver: IVolumeDriver;
...@@ -28,17 +28,17 @@ describe('VolumeService', () => { ...@@ -28,17 +28,17 @@ describe('VolumeService', () => {
it('delegates ensure to driver', async () => { it('delegates ensure to driver', async () => {
vi.mocked(driver.ensure).mockResolvedValue({ vi.mocked(driver.ensure).mockResolvedValue({
claimName: 'fastgpt-session-' + VALID_ID, claimName: CLAIM_NAME,
created: true created: true
}); });
const result = await service.ensure(VALID_ID, '5Gi'); const result = await service.ensure({ claimName: CLAIM_NAME, storageSize: '5Gi' });
expect(driver.ensure).toHaveBeenCalledWith(VALID_ID, '5Gi'); expect(driver.ensure).toHaveBeenCalledWith({ claimName: CLAIM_NAME, storageSize: '5Gi' });
expect(result.created).toBe(true); expect(result.created).toBe(true);
}); });
it('delegates remove to driver', async () => { it('delegates remove to driver', async () => {
vi.mocked(driver.remove).mockResolvedValue(undefined); vi.mocked(driver.remove).mockResolvedValue(undefined);
await service.remove(VALID_ID); await service.remove(CLAIM_NAME);
expect(driver.remove).toHaveBeenCalledWith(VALID_ID); expect(driver.remove).toHaveBeenCalledWith(CLAIM_NAME);
}); });
}); });
import { describe, it, expect } from 'vitest';
import { toVolumeName } from '../../src/utils/naming';
describe('toVolumeName', () => {
it('returns prefix-sessionId for valid 24-char hex', () => {
const id = 'a'.repeat(24);
expect(toVolumeName('fastgpt-session', id)).toBe(`fastgpt-session-${id}`);
});
it('normalizes uppercase to lowercase', () => {
expect(toVolumeName('pfx', 'ABC123')).toBe('pfx-abc123');
});
it('accepts debug-mode sessionId', () => {
const id = 'debug-69bb6a1aee77d10e6fb58e2d-7BdojPlukIQw';
expect(toVolumeName('fastgpt-session', id)).toBe(`fastgpt-session-${id.toLowerCase()}`);
});
it('accepts single character sessionId', () => {
expect(toVolumeName('pfx', 'a')).toBe('pfx-a');
});
it('throws for sessionId with leading hyphen', () => {
expect(() => toVolumeName('pfx', '-abc123')).toThrow('Invalid sessionId');
});
it('throws for sessionId with trailing hyphen', () => {
expect(() => toVolumeName('pfx', 'abc123-')).toThrow('Invalid sessionId');
});
it('throws for empty sessionId', () => {
expect(() => toVolumeName('pfx', '')).toThrow('Invalid sessionId');
});
it('throws for sessionId with invalid characters', () => {
expect(() => toVolumeName('pfx', 'abc_123')).toThrow('Invalid sessionId');
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment