@icyfenix-dmla/cli 2026.4.19-844 → 2026.4.19-847
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/package.json +9 -1
- package/scripts/build.js +50 -0
- package/src/commands/server.js +204 -21
- package/src/index.js +85 -5
- package/src/server/index.js +117 -0
- package/src/server/routes/sandbox.js +131 -0
- package/src/server/sandbox.js +490 -0
- package/tests/cli.test.js +0 -34
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# @icyfenix-dmla/cli
|
|
2
|
+
|
|
3
|
+
DMLA 沙箱服务命令行工具。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @icyfenix-dmla/cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 使用
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 启动服务
|
|
15
|
+
dmla start # 默认端口 3001
|
|
16
|
+
dmla start --port 8080 # 自定义端口
|
|
17
|
+
dmla start --gpu # GPU 模式
|
|
18
|
+
|
|
19
|
+
# 停止服务
|
|
20
|
+
dmla stop
|
|
21
|
+
|
|
22
|
+
# 查看状态
|
|
23
|
+
dmla status
|
|
24
|
+
|
|
25
|
+
# 安装镜像
|
|
26
|
+
dmla install # 安装所有镜像(默认从 Docker Hub)
|
|
27
|
+
dmla install --cpu # 仅 CPU 版本
|
|
28
|
+
dmla install --gpu # 仅 GPU 版本
|
|
29
|
+
dmla install --registry acr # 从阿里云 ACR 安装(国内加速)
|
|
30
|
+
|
|
31
|
+
# 更新
|
|
32
|
+
dmla update # 更新 npm 包和镜像
|
|
33
|
+
dmla update --registry acr
|
|
34
|
+
|
|
35
|
+
# 环境诊断
|
|
36
|
+
dmla doctor
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 要求
|
|
40
|
+
|
|
41
|
+
- Node.js >= 18.0.0
|
|
42
|
+
- Docker
|
|
43
|
+
|
|
44
|
+
## 许可证
|
|
45
|
+
|
|
46
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@icyfenix-dmla/cli",
|
|
3
|
-
"version": "2026.4.19-
|
|
3
|
+
"version": "2026.4.19-847",
|
|
4
4
|
"description": "DMLA 沙箱服务命令行工具",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -9,8 +9,16 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node src/index.js",
|
|
12
|
+
"build": "node scripts/build.js",
|
|
13
|
+
"prepublishOnly": "npm run build",
|
|
12
14
|
"test": "node --experimental-vm-modules $(npm root)/jest/bin/jest.js"
|
|
13
15
|
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin/",
|
|
18
|
+
"src/",
|
|
19
|
+
"scripts/",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
14
22
|
"dependencies": {
|
|
15
23
|
"commander": "^12.1.0",
|
|
16
24
|
"chalk": "^5.3.0",
|
package/scripts/build.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 构建脚本:将 local-server 代码复制到 CLI 包中
|
|
3
|
+
* 用于 npm 发布时包含完整的服务器代码
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'fs'
|
|
6
|
+
import path from 'path'
|
|
7
|
+
import { fileURLToPath } from 'url'
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
10
|
+
const __dirname = path.dirname(__filename)
|
|
11
|
+
|
|
12
|
+
const rootDir = path.resolve(__dirname, '../../..')
|
|
13
|
+
const localServerSrc = path.resolve(rootDir, 'local-server/src')
|
|
14
|
+
const cliServerDest = path.resolve(__dirname, '../src/server')
|
|
15
|
+
|
|
16
|
+
console.log('📦 构建 CLI 包...')
|
|
17
|
+
console.log(` 源目录: ${localServerSrc}`)
|
|
18
|
+
console.log(` 目标目录: ${cliServerDest}`)
|
|
19
|
+
|
|
20
|
+
// 递归复制目录
|
|
21
|
+
function copyDir(src, dest) {
|
|
22
|
+
if (!fs.existsSync(src)) {
|
|
23
|
+
console.error(`❌ 源目录不存在: ${src}`)
|
|
24
|
+
process.exit(1)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 创建目标目录
|
|
28
|
+
if (!fs.existsSync(dest)) {
|
|
29
|
+
fs.mkdirSync(dest, { recursive: true })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const entries = fs.readdirSync(src, { withFileTypes: true })
|
|
33
|
+
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
const srcPath = path.join(src, entry.name)
|
|
36
|
+
const destPath = path.join(dest, entry.name)
|
|
37
|
+
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
copyDir(srcPath, destPath)
|
|
40
|
+
} else if (entry.isFile() && entry.name.endsWith('.js')) {
|
|
41
|
+
fs.copyFileSync(srcPath, destPath)
|
|
42
|
+
console.log(` ✓ 复制: ${entry.name}`)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 执行复制
|
|
48
|
+
copyDir(localServerSrc, cliServerDest)
|
|
49
|
+
|
|
50
|
+
console.log('✅ 服务器代码已复制到 CLI 包')
|
package/src/commands/server.js
CHANGED
|
@@ -6,7 +6,7 @@ import Docker from 'dockerode'
|
|
|
6
6
|
import { spawn } from 'child_process'
|
|
7
7
|
import http from 'http'
|
|
8
8
|
import path from 'path'
|
|
9
|
-
import { fileURLToPath } from 'url'
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'url'
|
|
10
10
|
import fs from 'fs'
|
|
11
11
|
|
|
12
12
|
const __filename = fileURLToPath(import.meta.url)
|
|
@@ -114,7 +114,106 @@ async function findServiceContainer() {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
/**
|
|
117
|
-
*
|
|
117
|
+
* 查找服务器入口文件
|
|
118
|
+
*/
|
|
119
|
+
function findServerPath() {
|
|
120
|
+
// 开发环境路径:packages/cli/src/commands -> ../../../local-server/src/index.js
|
|
121
|
+
const serverPath = path.resolve(__dirname, '../../../local-server/src/index.js')
|
|
122
|
+
// npm 包路径:packages/cli/src/commands -> ../server/index.js
|
|
123
|
+
const standaloneServerPath = path.resolve(__dirname, '../server/index.js')
|
|
124
|
+
|
|
125
|
+
// 检查 __dirname 是否正确(调试用)
|
|
126
|
+
const cliPackageRoot = path.resolve(__dirname, '../..')
|
|
127
|
+
const expectedServerDir = path.resolve(cliPackageRoot, 'src/server')
|
|
128
|
+
|
|
129
|
+
if (fs.existsSync(serverPath)) {
|
|
130
|
+
return serverPath
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (fs.existsSync(standaloneServerPath)) {
|
|
134
|
+
return standaloneServerPath
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 调试输出:显示路径信息帮助诊断
|
|
138
|
+
console.log(chalk.yellow('⚠️ 服务入口文件查找失败'))
|
|
139
|
+
console.log(chalk.gray(` __dirname: ${__dirname}`))
|
|
140
|
+
console.log(chalk.gray(` 开发路径: ${serverPath} (${fs.existsSync(serverPath) ? '存在' : '不存在'})`))
|
|
141
|
+
console.log(chalk.gray(` npm路径: ${standaloneServerPath} (${fs.existsSync(standaloneServerPath) ? '存在' : '不存在'})`))
|
|
142
|
+
console.log(chalk.gray(` CLI包根目录: ${cliPackageRoot}`))
|
|
143
|
+
|
|
144
|
+
// 检查 CLI 包根目录下的文件结构
|
|
145
|
+
const srcDir = path.resolve(cliPackageRoot, 'src')
|
|
146
|
+
if (fs.existsSync(srcDir)) {
|
|
147
|
+
console.log(chalk.gray(` src目录内容: ${fs.readdirSync(srcDir).join(', ')}`))
|
|
148
|
+
if (fs.existsSync(expectedServerDir)) {
|
|
149
|
+
console.log(chalk.gray(` server目录内容: ${fs.readdirSync(expectedServerDir).join(', ')}`))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 同步启动服务(在当前进程运行,用于调试)
|
|
158
|
+
* @param {number} port - 服务端口
|
|
159
|
+
* @param {boolean} useGpu - 是否使用 GPU
|
|
160
|
+
*/
|
|
161
|
+
export async function startServerSync(port, useGpu = false) {
|
|
162
|
+
// 检查端口
|
|
163
|
+
const portAvailable = await checkPortAvailable(port)
|
|
164
|
+
if (!portAvailable) {
|
|
165
|
+
console.log(chalk.red(`❌ 端口 ${port} 已被占用`))
|
|
166
|
+
console.log(chalk.yellow('💡 提示: 使用 --port 选项指定其他端口'))
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 检查镜像
|
|
171
|
+
const imageType = useGpu ? 'gpu' : 'cpu'
|
|
172
|
+
const imageExists = await checkImageExists(imageType)
|
|
173
|
+
if (!imageExists) {
|
|
174
|
+
console.log(chalk.red(`❌ 镜像 ${useGpu ? CONFIG.imageGpu : CONFIG.imageCpu} 不存在`))
|
|
175
|
+
console.log(chalk.yellow('💡 提示: 运行 dmla install 安装镜像'))
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 检查服务是否已运行
|
|
180
|
+
const alreadyRunning = await checkServiceRunning(port)
|
|
181
|
+
if (alreadyRunning) {
|
|
182
|
+
console.log(chalk.green(`✅ 服务已在端口 ${port} 运行`))
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 查找服务器入口
|
|
187
|
+
const actualServerPath = findServerPath()
|
|
188
|
+
if (!actualServerPath) {
|
|
189
|
+
console.log(chalk.red('❌ 找不到服务入口文件'))
|
|
190
|
+
console.log(chalk.yellow('💡 提示: 确保正确安装了 @icyfenix-dmla/cli'))
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
console.log(chalk.gray(' 同步模式启动...'))
|
|
195
|
+
console.log(chalk.gray(` 服务入口: ${actualServerPath}`))
|
|
196
|
+
console.log()
|
|
197
|
+
|
|
198
|
+
// 设置环境变量
|
|
199
|
+
process.env.PORT = port.toString()
|
|
200
|
+
process.env.USE_GPU = useGpu ? 'true' : 'false'
|
|
201
|
+
process.env.DMLA_SYNC_MODE = 'true' // 标记同步模式,让服务器在 import 时启动
|
|
202
|
+
|
|
203
|
+
// 动态 import 服务器模块并直接运行
|
|
204
|
+
// 服务器模块会在 import 时自动启动(因为入口点检测逻辑)
|
|
205
|
+
// Windows 需要将路径转换为 file:// URL 格式
|
|
206
|
+
try {
|
|
207
|
+
const serverURL = pathToFileURL(actualServerPath).href
|
|
208
|
+
await import(serverURL)
|
|
209
|
+
} catch (error) {
|
|
210
|
+
console.log(chalk.red(`❌ 服务启动失败: ${error.message}`))
|
|
211
|
+
console.log(chalk.gray(error.stack))
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 启动服务(异步模式,spawn 子进程)
|
|
118
217
|
*/
|
|
119
218
|
export async function startServer(port, useGpu = false) {
|
|
120
219
|
// 检查端口
|
|
@@ -145,14 +244,7 @@ export async function startServer(port, useGpu = false) {
|
|
|
145
244
|
console.log(chalk.gray(' 正在启动...'))
|
|
146
245
|
|
|
147
246
|
try {
|
|
148
|
-
|
|
149
|
-
const serverPath = path.resolve(__dirname, '../../../local-server/src/index.js')
|
|
150
|
-
|
|
151
|
-
// 如果 server 文件不存在,说明是独立安装模式,需要启动内置服务
|
|
152
|
-
const standaloneServerPath = path.resolve(__dirname, '../server/index.js')
|
|
153
|
-
|
|
154
|
-
const actualServerPath = fs.existsSync(serverPath) ? serverPath :
|
|
155
|
-
fs.existsSync(standaloneServerPath) ? standaloneServerPath : null
|
|
247
|
+
const actualServerPath = findServerPath()
|
|
156
248
|
|
|
157
249
|
if (!actualServerPath) {
|
|
158
250
|
console.log(chalk.red('❌ 找不到服务入口文件'))
|
|
@@ -160,20 +252,61 @@ export async function startServer(port, useGpu = false) {
|
|
|
160
252
|
return
|
|
161
253
|
}
|
|
162
254
|
|
|
255
|
+
// 日志文件路径
|
|
256
|
+
const logDir = path.resolve(__dirname, '../../logs')
|
|
257
|
+
if (!fs.existsSync(logDir)) {
|
|
258
|
+
fs.mkdirSync(logDir, { recursive: true })
|
|
259
|
+
}
|
|
260
|
+
const logFile = path.join(logDir, 'server.log')
|
|
261
|
+
const errorLogFile = path.join(logDir, 'server-error.log')
|
|
262
|
+
|
|
263
|
+
console.log(chalk.gray(` 日志文件: ${logFile}`))
|
|
264
|
+
|
|
265
|
+
// 创建日志文件流
|
|
266
|
+
const logStream = fs.openSync(logFile, 'a')
|
|
267
|
+
const errorLogStream = fs.openSync(errorLogFile, 'a')
|
|
268
|
+
|
|
163
269
|
const env = {
|
|
164
270
|
...process.env,
|
|
165
271
|
PORT: port.toString(),
|
|
166
|
-
USE_GPU: useGpu ? 'true' : 'false'
|
|
272
|
+
USE_GPU: useGpu ? 'true' : 'false',
|
|
273
|
+
DMLA_LOG_FILE: logFile // 传递日志文件路径给服务端
|
|
167
274
|
}
|
|
168
275
|
|
|
276
|
+
// 写入启动日志
|
|
277
|
+
const timestamp = new Date().toISOString()
|
|
278
|
+
fs.writeSync(logStream, `[${timestamp}] Server starting...\n`)
|
|
279
|
+
fs.writeSync(logStream, `[${timestamp}] Server path: ${actualServerPath}\n`)
|
|
280
|
+
fs.writeSync(logStream, `[${timestamp}] Port: ${port}\n`)
|
|
281
|
+
fs.writeSync(logStream, `[${timestamp}] GPU: ${useGpu}\n`)
|
|
282
|
+
|
|
283
|
+
// 使用 spawn 启动 server 进程
|
|
284
|
+
// 重要:stdio 必须是 'ignore' 或管道,不能是 'inherit'
|
|
285
|
+
// 因为 'inherit' 会让子进程依赖父进程的 stdout,父进程退出后子进程也会退出
|
|
169
286
|
const serverProcess = spawn('node', [actualServerPath], {
|
|
170
287
|
env,
|
|
171
|
-
stdio: '
|
|
172
|
-
detached: true
|
|
288
|
+
stdio: ['ignore', logStream, errorLogStream], // stdin: ignore, stdout: log file, stderr: error log
|
|
289
|
+
detached: true,
|
|
290
|
+
windowsHide: true // Windows 下隐藏窗口
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
// 监听子进程事件(调试用)
|
|
294
|
+
serverProcess.on('error', (err) => {
|
|
295
|
+
fs.writeSync(errorLogStream, `[${new Date().toISOString()}] Spawn error: ${err.message}\n`)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
serverProcess.on('exit', (code, signal) => {
|
|
299
|
+
const msg = `[${new Date().toISOString()}] Process exited: code=${code}, signal=${signal}\n`
|
|
300
|
+
fs.writeSync(logStream, msg)
|
|
301
|
+
fs.writeSync(errorLogStream, msg)
|
|
173
302
|
})
|
|
174
303
|
|
|
175
304
|
serverProcess.unref()
|
|
176
305
|
|
|
306
|
+
// 关闭父进程中的文件描述符(子进程会保留自己的副本)
|
|
307
|
+
fs.closeSync(logStream)
|
|
308
|
+
fs.closeSync(errorLogStream)
|
|
309
|
+
|
|
177
310
|
// 等待服务启动
|
|
178
311
|
console.log(chalk.gray(' 等待服务就绪...'))
|
|
179
312
|
let attempts = 0
|
|
@@ -184,15 +317,19 @@ export async function startServer(port, useGpu = false) {
|
|
|
184
317
|
if (running) {
|
|
185
318
|
console.log(chalk.green(`✅ 服务已启动: http://localhost:${port}`))
|
|
186
319
|
console.log(chalk.gray(` 健康检查: http://localhost:${port}/api/health`))
|
|
320
|
+
console.log(chalk.gray(` 日志查看: ${logFile}`))
|
|
187
321
|
return
|
|
188
322
|
}
|
|
189
323
|
await new Promise(resolve => setTimeout(resolve, 500))
|
|
190
324
|
attempts++
|
|
191
325
|
}
|
|
192
326
|
|
|
193
|
-
console.log(chalk.yellow('⚠️
|
|
327
|
+
console.log(chalk.yellow('⚠️ 服务启动超时'))
|
|
328
|
+
console.log(chalk.gray(` 请查看日志: ${logFile}`))
|
|
329
|
+
console.log(chalk.gray(` 或使用 --sync 模式调试`))
|
|
194
330
|
} catch (error) {
|
|
195
331
|
console.log(chalk.red(`❌ 启动失败: ${error.message}`))
|
|
332
|
+
console.log(chalk.gray(error.stack))
|
|
196
333
|
}
|
|
197
334
|
}
|
|
198
335
|
|
|
@@ -200,7 +337,51 @@ export async function startServer(port, useGpu = false) {
|
|
|
200
337
|
* 停止服务
|
|
201
338
|
*/
|
|
202
339
|
export async function stopServer() {
|
|
203
|
-
//
|
|
340
|
+
// 首先尝试通过 API 停止服务
|
|
341
|
+
const port = CONFIG.defaultPort
|
|
342
|
+
const running = await checkServiceRunning(port)
|
|
343
|
+
|
|
344
|
+
if (running) {
|
|
345
|
+
try {
|
|
346
|
+
// 调用 shutdown API
|
|
347
|
+
await new Promise((resolve, reject) => {
|
|
348
|
+
const req = http.request({
|
|
349
|
+
hostname: 'localhost',
|
|
350
|
+
port: port,
|
|
351
|
+
path: '/api/shutdown',
|
|
352
|
+
method: 'POST',
|
|
353
|
+
timeout: 5000
|
|
354
|
+
}, (res) => {
|
|
355
|
+
if (res.statusCode === 200) {
|
|
356
|
+
console.log(chalk.green('✅ 服务已停止'))
|
|
357
|
+
resolve()
|
|
358
|
+
} else {
|
|
359
|
+
reject(new Error(`HTTP ${res.statusCode}`))
|
|
360
|
+
}
|
|
361
|
+
})
|
|
362
|
+
req.on('error', (e) => reject(e))
|
|
363
|
+
req.on('timeout', () => {
|
|
364
|
+
req.destroy()
|
|
365
|
+
reject(new Error('Timeout'))
|
|
366
|
+
})
|
|
367
|
+
req.end()
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
// 等待服务完全关闭
|
|
371
|
+
let attempts = 0
|
|
372
|
+
while (attempts < 10) {
|
|
373
|
+
const stillRunning = await checkServiceRunning(port)
|
|
374
|
+
if (!stillRunning) break
|
|
375
|
+
await new Promise(r => setTimeout(r, 200))
|
|
376
|
+
attempts++
|
|
377
|
+
}
|
|
378
|
+
return
|
|
379
|
+
} catch (error) {
|
|
380
|
+
console.log(chalk.yellow(`⚠️ 通过 API 停止失败: ${error.message}`))
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// 尝试查找并停止 Docker 容器
|
|
204
385
|
const container = await findServiceContainer()
|
|
205
386
|
|
|
206
387
|
if (container) {
|
|
@@ -208,14 +389,15 @@ export async function stopServer() {
|
|
|
208
389
|
const containerObj = docker.getContainer(container.Id)
|
|
209
390
|
await containerObj.stop()
|
|
210
391
|
await containerObj.remove()
|
|
211
|
-
console.log(chalk.green('✅
|
|
392
|
+
console.log(chalk.green('✅ 服务容器已停止'))
|
|
212
393
|
} catch (error) {
|
|
213
|
-
console.log(chalk.red(`❌
|
|
394
|
+
console.log(chalk.red(`❌ 停止容器失败: ${error.message}`))
|
|
214
395
|
}
|
|
396
|
+
} else if (!running) {
|
|
397
|
+
console.log(chalk.gray(' 服务未运行'))
|
|
215
398
|
} else {
|
|
216
|
-
|
|
217
|
-
console.log(chalk.
|
|
218
|
-
console.log(chalk.gray(' 提示: 服务可能以非容器模式运行'))
|
|
399
|
+
console.log(chalk.yellow('⚠️ 无法停止服务'))
|
|
400
|
+
console.log(chalk.gray(' 提示: 手动终止端口 3001 上的进程'))
|
|
219
401
|
}
|
|
220
402
|
}
|
|
221
403
|
|
|
@@ -228,7 +410,8 @@ export async function getStatus() {
|
|
|
228
410
|
// 检查 npm 包版本
|
|
229
411
|
console.log(chalk.bold('📦 npm 包版本'))
|
|
230
412
|
try {
|
|
231
|
-
|
|
413
|
+
// __dirname 是 src/commands,需要向上两级到包根目录
|
|
414
|
+
const pkgPath = path.resolve(__dirname, '../../package.json')
|
|
232
415
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
233
416
|
console.log(chalk.gray(` @icyfenix-dmla/cli: ${pkg.version}`))
|
|
234
417
|
} catch {
|
package/src/index.js
CHANGED
|
@@ -2,18 +2,87 @@
|
|
|
2
2
|
* DMLA CLI 入口
|
|
3
3
|
* 沙箱服务命令行管理工具
|
|
4
4
|
*/
|
|
5
|
-
import { program } from 'commander'
|
|
5
|
+
import { program, Help } from 'commander'
|
|
6
6
|
import chalk from 'chalk'
|
|
7
|
-
import
|
|
7
|
+
import path from 'path'
|
|
8
|
+
import { fileURLToPath } from 'url'
|
|
9
|
+
import fs from 'fs'
|
|
10
|
+
import { startServer, startServerSync, stopServer, getStatus } from './commands/server.js'
|
|
8
11
|
import { updateAll, runDoctor } from './commands/manage.js'
|
|
9
12
|
import { runInstallTUI } from '@icyfenix-dmla/install'
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
// 从 package.json 读取版本号
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
16
|
+
const __dirname = path.dirname(__filename)
|
|
17
|
+
const pkgPath = path.resolve(__dirname, '../package.json')
|
|
18
|
+
const VERSION = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version
|
|
19
|
+
|
|
20
|
+
// 重写 Help 类的方法以输出中文标题
|
|
21
|
+
Help.prototype.padWidth = function(cmd, helper) {
|
|
22
|
+
return 20
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 重写帮助信息格式化方法
|
|
26
|
+
Help.prototype.formatHelp = function(cmd, helper) {
|
|
27
|
+
const indent = ' '
|
|
28
|
+
const itemIndent = ' '
|
|
29
|
+
|
|
30
|
+
let output = []
|
|
31
|
+
|
|
32
|
+
// 用法(中文)
|
|
33
|
+
output.push('用法:')
|
|
34
|
+
output.push(indent + helper.commandUsage(cmd))
|
|
35
|
+
|
|
36
|
+
// 说明(中文)
|
|
37
|
+
if (cmd.description()) {
|
|
38
|
+
output.push('')
|
|
39
|
+
output.push('说明:')
|
|
40
|
+
output.push(indent + cmd.description())
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 参数(中文)
|
|
44
|
+
const args = helper.visibleArguments(cmd)
|
|
45
|
+
if (args.length > 0) {
|
|
46
|
+
output.push('')
|
|
47
|
+
output.push('参数:')
|
|
48
|
+
args.forEach(arg => {
|
|
49
|
+
output.push(itemIndent + arg.name())
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 选项(中文)
|
|
54
|
+
const options = helper.visibleOptions(cmd)
|
|
55
|
+
if (options.length > 0) {
|
|
56
|
+
output.push('')
|
|
57
|
+
output.push('选项:')
|
|
58
|
+
options.forEach(opt => {
|
|
59
|
+
const term = helper.optionTerm(opt)
|
|
60
|
+
const description = helper.optionDescription(opt)
|
|
61
|
+
output.push(itemIndent + term.padEnd(20) + description)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 命令(中文)
|
|
66
|
+
const commands = helper.visibleCommands(cmd)
|
|
67
|
+
if (commands.length > 0) {
|
|
68
|
+
output.push('')
|
|
69
|
+
output.push('命令:')
|
|
70
|
+
commands.forEach(subcmd => {
|
|
71
|
+
const term = helper.subcommandTerm(subcmd)
|
|
72
|
+
const description = helper.subcommandDescription(subcmd)
|
|
73
|
+
output.push(itemIndent + term.padEnd(20) + description)
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return output.join('\n')
|
|
78
|
+
}
|
|
12
79
|
|
|
13
80
|
program
|
|
14
81
|
.name('dmla')
|
|
15
82
|
.description('DMLA 沙箱服务命令行管理工具')
|
|
16
|
-
.version(VERSION)
|
|
83
|
+
.version(VERSION, '-v, --version', '显示版本号')
|
|
84
|
+
.helpOption('-h, --help', '显示帮助信息')
|
|
85
|
+
.addHelpCommand('help [command]', '显示命令帮助信息')
|
|
17
86
|
|
|
18
87
|
// ─────────────────────────────────────────────────────────────
|
|
19
88
|
// start 命令
|
|
@@ -23,13 +92,24 @@ program
|
|
|
23
92
|
.description('启动沙箱服务')
|
|
24
93
|
.option('-p, --port <number>', '服务端口', '3001')
|
|
25
94
|
.option('--gpu', '使用 GPU 镜像')
|
|
95
|
+
.option('--sync', '同步模式:在当前进程运行,日志直接输出(用于调试)')
|
|
26
96
|
.action(async (options) => {
|
|
27
97
|
const port = parseInt(options.port, 10)
|
|
28
98
|
const useGpu = options.gpu
|
|
99
|
+
const sync = options.sync
|
|
100
|
+
|
|
29
101
|
console.log(chalk.blue('🚀 启动 DMLA 沙箱服务...'))
|
|
30
102
|
console.log(chalk.gray(` 端口: ${port}`))
|
|
31
103
|
console.log(chalk.gray(` 镜像: ${useGpu ? 'GPU' : 'CPU'}`))
|
|
32
|
-
|
|
104
|
+
if (sync) {
|
|
105
|
+
console.log(chalk.yellow(` 模式: 同步(调试模式)`))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (sync) {
|
|
109
|
+
await startServerSync(port, useGpu)
|
|
110
|
+
} else {
|
|
111
|
+
await startServer(port, useGpu)
|
|
112
|
+
}
|
|
33
113
|
})
|
|
34
114
|
|
|
35
115
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design Machine Learning Applications 本地服务
|
|
3
|
+
* 提供 Python 代码沙箱执行 API
|
|
4
|
+
*/
|
|
5
|
+
import express from 'express'
|
|
6
|
+
import cors from 'cors'
|
|
7
|
+
import { fileURLToPath } from 'url'
|
|
8
|
+
import { resolve } from 'path'
|
|
9
|
+
import sandboxRouter from './routes/sandbox.js'
|
|
10
|
+
|
|
11
|
+
export const app = express()
|
|
12
|
+
const PORT = process.env.PORT || 3001
|
|
13
|
+
|
|
14
|
+
// 日志函数
|
|
15
|
+
function log(message) {
|
|
16
|
+
const timestamp = new Date().toISOString()
|
|
17
|
+
console.log(`[${timestamp}] ${message}`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 启动日志
|
|
21
|
+
log('Server initializing...')
|
|
22
|
+
log(`PORT: ${PORT}`)
|
|
23
|
+
log(`NODE_VERSION: ${process.version}`)
|
|
24
|
+
log(`PLATFORM: ${process.platform}`)
|
|
25
|
+
log(`DMLA_SYNC_MODE: ${process.env.DMLA_SYNC_MODE || 'false'}`)
|
|
26
|
+
|
|
27
|
+
// 中间件
|
|
28
|
+
app.use(cors())
|
|
29
|
+
app.use(express.json())
|
|
30
|
+
|
|
31
|
+
// 请求日志中间件
|
|
32
|
+
app.use((req, res, next) => {
|
|
33
|
+
log(`Request: ${req.method} ${req.path}`)
|
|
34
|
+
next()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// 健康检查
|
|
38
|
+
app.get('/api/health', (req, res) => {
|
|
39
|
+
log('Health check request')
|
|
40
|
+
res.json({ status: 'ok', timestamp: new Date().toISOString() })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// 停止服务(用于 CLI stop 命令)
|
|
44
|
+
app.post('/api/shutdown', (req, res) => {
|
|
45
|
+
log('Shutdown request received')
|
|
46
|
+
res.json({ status: 'shutting_down', timestamp: new Date().toISOString() })
|
|
47
|
+
// 延迟关闭,确保响应发送完成
|
|
48
|
+
setTimeout(() => {
|
|
49
|
+
log('Exiting due to shutdown request')
|
|
50
|
+
process.exit(0)
|
|
51
|
+
}, 100)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// 沙箱 API
|
|
55
|
+
app.use('/api/sandbox', sandboxRouter)
|
|
56
|
+
|
|
57
|
+
// 错误处理
|
|
58
|
+
app.use((err, req, res, next) => {
|
|
59
|
+
log(`Error: ${err.message}`)
|
|
60
|
+
log(`Stack: ${err.stack}`)
|
|
61
|
+
res.status(500).json({
|
|
62
|
+
success: false,
|
|
63
|
+
error: err.message || 'Internal Server Error'
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// 捕获未处理的异常
|
|
68
|
+
process.on('uncaughtException', (err) => {
|
|
69
|
+
log(`UNCAUGHT EXCEPTION: ${err.message}`)
|
|
70
|
+
log(`Stack: ${err.stack}`)
|
|
71
|
+
process.exit(1)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// 捕获未处理的 Promise 拒绝
|
|
75
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
76
|
+
log(`UNHANDLED REJECTION: ${reason}`)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// 捕获进程信号
|
|
80
|
+
process.on('SIGTERM', () => {
|
|
81
|
+
log('Received SIGTERM')
|
|
82
|
+
process.exit(0)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
process.on('SIGINT', () => {
|
|
86
|
+
log('Received SIGINT')
|
|
87
|
+
process.exit(0)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// 启动服务器
|
|
91
|
+
// 条件1: 直接运行(入口点匹配)
|
|
92
|
+
// 条件2: 同步模式(DMLA_SYNC_MODE 环境变量)
|
|
93
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
94
|
+
const entryPoint = resolve(process.argv[1] || '')
|
|
95
|
+
const shouldStart = __filename === entryPoint || process.env.DMLA_SYNC_MODE === 'true'
|
|
96
|
+
|
|
97
|
+
log(`Entry point check: __filename=${__filename}, entryPoint=${entryPoint}, match=${__filename === entryPoint}`)
|
|
98
|
+
|
|
99
|
+
if (shouldStart) {
|
|
100
|
+
const server = app.listen(PORT, () => {
|
|
101
|
+
log('Server started successfully')
|
|
102
|
+
log(`API: http://localhost:${PORT}`)
|
|
103
|
+
log(`Health: http://localhost:${PORT}/api/health`)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
server.on('error', (err) => {
|
|
107
|
+
log(`Server error: ${err.message}`)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
server.on('close', () => {
|
|
111
|
+
log('Server closed')
|
|
112
|
+
})
|
|
113
|
+
} else {
|
|
114
|
+
log('Skipping server start (imported as module)')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export default app
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 沙箱 API 路由
|
|
3
|
+
*/
|
|
4
|
+
import { Router } from 'express'
|
|
5
|
+
import sandbox, { runPythonCode, checkImageExists, checkGPUAvailable } from '../sandbox.js'
|
|
6
|
+
|
|
7
|
+
const { SANDBOX_CONFIG } = sandbox
|
|
8
|
+
|
|
9
|
+
const router = Router()
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 健康检查
|
|
13
|
+
*/
|
|
14
|
+
router.get('/health', async (req, res) => {
|
|
15
|
+
try {
|
|
16
|
+
const imageCpuExists = await checkImageExists(false)
|
|
17
|
+
const imageGpuExists = await checkImageExists(true)
|
|
18
|
+
const gpuAvailable = await checkGPUAvailable()
|
|
19
|
+
|
|
20
|
+
res.json({
|
|
21
|
+
status: 'ok',
|
|
22
|
+
images: {
|
|
23
|
+
cpu: imageCpuExists,
|
|
24
|
+
gpu: imageGpuExists
|
|
25
|
+
},
|
|
26
|
+
gpu: gpuAvailable
|
|
27
|
+
})
|
|
28
|
+
} catch (error) {
|
|
29
|
+
res.status(500).json({
|
|
30
|
+
status: 'error',
|
|
31
|
+
error: error.message
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 执行代码
|
|
38
|
+
* POST /api/sandbox/run
|
|
39
|
+
* Body: { code: string, useGpu?: boolean }
|
|
40
|
+
*/
|
|
41
|
+
router.post('/run', async (req, res) => {
|
|
42
|
+
const { code, useGpu = false } = req.body
|
|
43
|
+
|
|
44
|
+
// 验证请求
|
|
45
|
+
if (!code || typeof code !== 'string') {
|
|
46
|
+
return res.status(400).json({
|
|
47
|
+
success: false,
|
|
48
|
+
error: 'Missing or invalid code parameter'
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 代码长度限制 (约 100KB)
|
|
53
|
+
if (code.length > 100000) {
|
|
54
|
+
return res.status(400).json({
|
|
55
|
+
success: false,
|
|
56
|
+
error: 'Code too long (max 100KB)'
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
// 检查镜像是否存在,智能降级
|
|
62
|
+
// GPU镜像包含CPU的全部功能,如果CPU镜像不存在但GPU镜像存在,可以使用GPU镜像执行CPU代码
|
|
63
|
+
let actualUseGpu = useGpu
|
|
64
|
+
let actualImage = null // 指定使用的镜像
|
|
65
|
+
let imageExists = await checkImageExists(useGpu)
|
|
66
|
+
|
|
67
|
+
if (!imageExists && !useGpu) {
|
|
68
|
+
// CPU镜像不存在,检查是否可以用GPU镜像替代
|
|
69
|
+
const gpuImageExists = await checkImageExists(true)
|
|
70
|
+
if (gpuImageExists) {
|
|
71
|
+
imageExists = true
|
|
72
|
+
actualUseGpu = false // 不启用GPU设备
|
|
73
|
+
actualImage = SANDBOX_CONFIG.imageGpu // 使用GPU镜像
|
|
74
|
+
console.log('[Sandbox] CPU镜像不存在,使用GPU镜像执行(不启用GPU设备)')
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!imageExists) {
|
|
79
|
+
return res.status(503).json({
|
|
80
|
+
success: false,
|
|
81
|
+
error: useGpu
|
|
82
|
+
? 'GPU 镜像未安装。请运行以下命令安装:\n\nnpm run build:sandbox:gpu\n\n或使用 dmla CLI:\n\ndmla install --gpu'
|
|
83
|
+
: '沙箱镜像未安装。请运行以下命令安装:\n\nnpm run build:sandbox:cpu\n\n或使用 dmla CLI:\n\ndmla install --cpu\n\n注意:如果您已安装 GPU 镜像,它也支持 CPU 执行'
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 如果请求 GPU,检查 GPU 是否可用
|
|
88
|
+
if (actualUseGpu) {
|
|
89
|
+
const gpuAvailable = await checkGPUAvailable()
|
|
90
|
+
if (!gpuAvailable) {
|
|
91
|
+
return res.status(503).json({
|
|
92
|
+
success: false,
|
|
93
|
+
error: `GPU 硬件不可用。请确保系统安装了 NVIDIA GPU 驱动和 nvidia-container-toolkit。\n\n诊断步骤:\n1. 运行 nvidia-smi 检查 GPU 状态\n2. 运行 docker run --rm --gpus all ${SANDBOX_CONFIG.imageGpu} nvidia-smi 测试 Docker GPU 支持\n\n或使用 dmla doctor 进行环境诊断`
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 执行代码(使用确定后的镜像)
|
|
99
|
+
const result = await runPythonCode(code, actualUseGpu, actualImage)
|
|
100
|
+
|
|
101
|
+
res.json(result)
|
|
102
|
+
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error('Sandbox error:', error)
|
|
105
|
+
res.status(500).json({
|
|
106
|
+
success: false,
|
|
107
|
+
error: error.message || 'Internal sandbox error'
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* GPU 状态检查
|
|
114
|
+
*/
|
|
115
|
+
router.get('/gpu', async (req, res) => {
|
|
116
|
+
try {
|
|
117
|
+
const gpuAvailable = await checkGPUAvailable()
|
|
118
|
+
|
|
119
|
+
res.json({
|
|
120
|
+
available: gpuAvailable,
|
|
121
|
+
message: gpuAvailable ? 'GPU is available' : 'No GPU detected'
|
|
122
|
+
})
|
|
123
|
+
} catch (error) {
|
|
124
|
+
res.status(500).json({
|
|
125
|
+
available: false,
|
|
126
|
+
error: error.message
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
export default router
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 沙箱管理模块
|
|
3
|
+
* 负责创建和管理 Docker 容器执行 Python 代码
|
|
4
|
+
*/
|
|
5
|
+
import Docker from 'dockerode'
|
|
6
|
+
import path from 'path'
|
|
7
|
+
import { fileURLToPath } from 'url'
|
|
8
|
+
import fs from 'fs'
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
11
|
+
const __dirname = path.dirname(__filename)
|
|
12
|
+
|
|
13
|
+
// 日志函数
|
|
14
|
+
function log(message) {
|
|
15
|
+
const timestamp = new Date().toISOString()
|
|
16
|
+
console.log(`[${timestamp}] [Sandbox] ${message}`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 启动时记录
|
|
20
|
+
log('Sandbox module initialized')
|
|
21
|
+
|
|
22
|
+
// 检测运行模式并计算正确的路径
|
|
23
|
+
// 开发模式: 从 local-server/src 运行,项目根目录在上两级
|
|
24
|
+
// 独立模式: 从 packages/cli/src/server 运行,无 shared_modules 目录
|
|
25
|
+
function detectProjectRoot() {
|
|
26
|
+
// 尝试向上两级查找 local-server 目录(开发模式)
|
|
27
|
+
const candidateRoot = path.resolve(__dirname, '..', '..')
|
|
28
|
+
const localServerPath = path.join(candidateRoot, 'local-server')
|
|
29
|
+
if (fs.existsSync(localServerPath)) {
|
|
30
|
+
return candidateRoot
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 尝试向上三级查找(独立模式下的项目根目录)
|
|
34
|
+
const standaloneRoot = path.resolve(__dirname, '..', '..', '..')
|
|
35
|
+
const standaloneLocalServer = path.join(standaloneRoot, 'local-server')
|
|
36
|
+
if (fs.existsSync(standaloneLocalServer)) {
|
|
37
|
+
return standaloneRoot
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 独立安装模式,无项目根目录
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PROJECT_ROOT = detectProjectRoot()
|
|
45
|
+
|
|
46
|
+
// 共享模块目录(仅开发模式可用)
|
|
47
|
+
const DEFAULT_SHARED_MODULES_PATH = PROJECT_ROOT
|
|
48
|
+
? path.join(PROJECT_ROOT, 'local-server', 'shared_modules')
|
|
49
|
+
: null
|
|
50
|
+
|
|
51
|
+
// kernel_runner.py 路径(开发模式下可用)
|
|
52
|
+
const DEFAULT_KERNEL_RUNNER_PATH = PROJECT_ROOT
|
|
53
|
+
? path.join(PROJECT_ROOT, 'local-server', 'src', 'kernel_runner.py')
|
|
54
|
+
: null
|
|
55
|
+
|
|
56
|
+
const docker = new Docker()
|
|
57
|
+
|
|
58
|
+
// 沙箱配置
|
|
59
|
+
const SANDBOX_CONFIG = {
|
|
60
|
+
imageCpu: 'dmla-sandbox:cpu',
|
|
61
|
+
imageGpu: 'dmla-sandbox:gpu',
|
|
62
|
+
timeout: 60000, // 60 秒超时
|
|
63
|
+
memory: 4 * 1024 * 1024 * 1024 // 4GB 内存
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 获取共享模块路径
|
|
68
|
+
*/
|
|
69
|
+
function getSharedModulesPath() {
|
|
70
|
+
// 优先使用环境变量指定的路径
|
|
71
|
+
if (process.env.SHARED_MODULES_PATH) {
|
|
72
|
+
return process.env.SHARED_MODULES_PATH
|
|
73
|
+
}
|
|
74
|
+
// 开发模式下的默认路径
|
|
75
|
+
return DEFAULT_SHARED_MODULES_PATH
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 获取 kernel_runner.py 路径
|
|
80
|
+
*/
|
|
81
|
+
function getKernelRunnerPath() {
|
|
82
|
+
// 优先使用环境变量指定的路径
|
|
83
|
+
if (process.env.KERNEL_RUNNER_PATH) {
|
|
84
|
+
return process.env.KERNEL_RUNNER_PATH
|
|
85
|
+
}
|
|
86
|
+
// 开发模式下的默认路径
|
|
87
|
+
return DEFAULT_KERNEL_RUNNER_PATH
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 检查是否启用 Volume Mount
|
|
92
|
+
*/
|
|
93
|
+
function shouldMountSharedModules() {
|
|
94
|
+
return process.env.MOUNT_SHARED_MODULES !== 'false'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 检查是否挂载本地 kernel_runner.py(开发模式)
|
|
99
|
+
*/
|
|
100
|
+
function shouldMountKernelRunner() {
|
|
101
|
+
return process.env.MOUNT_KERNEL_RUNNER !== 'false' && PROJECT_ROOT !== null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 检查 GPU 是否可用
|
|
106
|
+
* 使用已安装的 GPU 镜像运行 nvidia-smi 命令检测 GPU 状态
|
|
107
|
+
*/
|
|
108
|
+
export async function checkGPUAvailable() {
|
|
109
|
+
let container = null
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
// 使用已配置的 GPU 镜像检测,而非硬编码的 nvidia/cuda 镜像
|
|
113
|
+
container = await docker.createContainer({
|
|
114
|
+
Image: SANDBOX_CONFIG.imageGpu,
|
|
115
|
+
Cmd: ['nvidia-smi', '-L'],
|
|
116
|
+
HostConfig: {
|
|
117
|
+
DeviceRequests: [{
|
|
118
|
+
Driver: 'nvidia',
|
|
119
|
+
Count: -1, // 使用所有 GPU
|
|
120
|
+
Capabilities: [['gpu']]
|
|
121
|
+
}]
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// 启动容器
|
|
126
|
+
await container.start()
|
|
127
|
+
|
|
128
|
+
// 等待执行完成
|
|
129
|
+
await container.wait()
|
|
130
|
+
|
|
131
|
+
// 获取输出日志
|
|
132
|
+
const logs = await container.logs({
|
|
133
|
+
stdout: true,
|
|
134
|
+
stderr: true
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// 解析输出
|
|
138
|
+
const output = parseDockerLogs(logs)
|
|
139
|
+
|
|
140
|
+
// 检查输出是否包含 GPU 信息
|
|
141
|
+
return output.includes('GPU')
|
|
142
|
+
} catch {
|
|
143
|
+
// GPU 不可用或 Docker/nvidia-smi 执行失败
|
|
144
|
+
return false
|
|
145
|
+
} finally {
|
|
146
|
+
// 清理容器
|
|
147
|
+
if (container) {
|
|
148
|
+
try {
|
|
149
|
+
await container.remove({ force: true })
|
|
150
|
+
} catch {
|
|
151
|
+
// 忽略清理错误
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 执行 Python 代码
|
|
159
|
+
* 使用 IPython Kernel 执行代码,支持富输出(图片、文本、错误等)
|
|
160
|
+
* @param {string} code - Python 代码
|
|
161
|
+
* @param {boolean} useGpu - 是否启用 GPU 设备
|
|
162
|
+
* @param {string|null} imageOverride - 可选,指定使用的镜像名称(覆盖默认选择)
|
|
163
|
+
* @returns {Promise<{success: boolean, outputs: Array, executionTime: number, gpuUsed: boolean}>}
|
|
164
|
+
*/
|
|
165
|
+
export async function runPythonCode(code, useGpu = false, imageOverride = null) {
|
|
166
|
+
const startTime = Date.now()
|
|
167
|
+
|
|
168
|
+
log(`runPythonCode called, useGpu=${useGpu}, code length=${code.length}, imageOverride=${imageOverride}`)
|
|
169
|
+
|
|
170
|
+
// 选择镜像:优先使用指定的镜像,否则根据 useGpu 选择
|
|
171
|
+
const image = imageOverride || (useGpu ? SANDBOX_CONFIG.imageGpu : SANDBOX_CONFIG.imageCpu)
|
|
172
|
+
log(`Using image: ${image}`)
|
|
173
|
+
|
|
174
|
+
// 创建容器配置 - 使用 kernel_runner.py 执行代码
|
|
175
|
+
const containerConfig = {
|
|
176
|
+
Image: image,
|
|
177
|
+
Cmd: ['python3', '/workspace/kernel_runner.py', '--code', code, '--timeout', String(Math.floor(SANDBOX_CONFIG.timeout / 1000))],
|
|
178
|
+
HostConfig: {
|
|
179
|
+
Memory: SANDBOX_CONFIG.memory,
|
|
180
|
+
AutoRemove: false // 手动移除以获取日志
|
|
181
|
+
},
|
|
182
|
+
Env: [
|
|
183
|
+
'PYTHONUNBUFFERED=1'
|
|
184
|
+
// matplotlib 使用 IPython Kernel 的 inline 后端,自动发送 display_data
|
|
185
|
+
]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
log('Container config created')
|
|
189
|
+
|
|
190
|
+
// Volume Mount 配置
|
|
191
|
+
const useMount = shouldMountSharedModules()
|
|
192
|
+
const sharedModulesPath = getSharedModulesPath()
|
|
193
|
+
const mountKernelRunner = shouldMountKernelRunner()
|
|
194
|
+
const kernelRunnerPath = getKernelRunnerPath()
|
|
195
|
+
|
|
196
|
+
// 收集所有需要挂载的路径
|
|
197
|
+
const binds = []
|
|
198
|
+
|
|
199
|
+
// 挂载共享模块
|
|
200
|
+
if (useMount && sharedModulesPath && fs.existsSync(sharedModulesPath)) {
|
|
201
|
+
binds.push(`${sharedModulesPath}:/usr/local/lib/python3.11/site-packages/shared:ro`)
|
|
202
|
+
console.log(`[Sandbox] 共享模块 Volume Mount: ${sharedModulesPath}`)
|
|
203
|
+
} else if (useMount && sharedModulesPath) {
|
|
204
|
+
console.warn(`[Sandbox] 警告: 共享模块目录不存在: ${sharedModulesPath}`)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 挂载 kernel_runner.py(开发模式调试)
|
|
208
|
+
if (mountKernelRunner && kernelRunnerPath && fs.existsSync(kernelRunnerPath)) {
|
|
209
|
+
binds.push(`${kernelRunnerPath}:/workspace/kernel_runner.py:ro`)
|
|
210
|
+
console.log(`[Sandbox] kernel_runner.py Volume Mount: ${kernelRunnerPath}`)
|
|
211
|
+
} else if (mountKernelRunner && kernelRunnerPath) {
|
|
212
|
+
console.warn(`[Sandbox] 警告: kernel_runner.py 不存在: ${kernelRunnerPath}`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 设置 Binds
|
|
216
|
+
if (binds.length > 0) {
|
|
217
|
+
containerConfig.HostConfig.Binds = binds
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!PROJECT_ROOT) {
|
|
221
|
+
console.log('[Sandbox] 独立安装模式,无 Volume Mount')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// GPU 配置
|
|
225
|
+
if (useGpu) {
|
|
226
|
+
containerConfig.HostConfig.DeviceRequests = [{
|
|
227
|
+
Driver: 'nvidia',
|
|
228
|
+
Count: -1, // 使用所有 GPU
|
|
229
|
+
Capabilities: [['gpu']]
|
|
230
|
+
}]
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let container = null
|
|
234
|
+
let timeoutId = null
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
// 创建容器
|
|
238
|
+
log('Creating container...')
|
|
239
|
+
container = await docker.createContainer(containerConfig)
|
|
240
|
+
log(`Container created: ${container.id}`)
|
|
241
|
+
|
|
242
|
+
// 设置超时
|
|
243
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
244
|
+
timeoutId = setTimeout(() => {
|
|
245
|
+
log('Execution timeout triggered')
|
|
246
|
+
reject(new Error('Execution timeout'))
|
|
247
|
+
}, SANDBOX_CONFIG.timeout + 10000) // 额外 10 秒用于清理
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// 启动容器
|
|
251
|
+
log('Starting container...')
|
|
252
|
+
await container.start()
|
|
253
|
+
log('Container started')
|
|
254
|
+
|
|
255
|
+
// 等待执行完成
|
|
256
|
+
log('Waiting for container to finish...')
|
|
257
|
+
const waitPromise = container.wait()
|
|
258
|
+
|
|
259
|
+
// 竞速: 超时 vs 正常完成
|
|
260
|
+
const result = await Promise.race([waitPromise, timeoutPromise])
|
|
261
|
+
log(`Container finished, result: ${JSON.stringify(result)}`)
|
|
262
|
+
|
|
263
|
+
// 清除超时
|
|
264
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
265
|
+
|
|
266
|
+
// 获取输出
|
|
267
|
+
log('Getting container logs...')
|
|
268
|
+
const logs = await container.logs({
|
|
269
|
+
stdout: true,
|
|
270
|
+
stderr: true
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
// 解析输出 - 分别处理 stdout 和 stderr
|
|
274
|
+
const { stdout, stderr } = parseDockerLogsSeparate(logs)
|
|
275
|
+
log(`Stdout length: ${stdout.length}`)
|
|
276
|
+
log(`Stderr length: ${stderr.length}`)
|
|
277
|
+
|
|
278
|
+
// stderr 可能包含调试信息
|
|
279
|
+
if (stderr.length > 0) {
|
|
280
|
+
log(`Stderr content preview: ${stderr.substring(0, 500)}`)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// stdout 可能包含 CUDA banner + JSON,需要提取 JSON 部分
|
|
284
|
+
// 找到第一个 '{' 作为 JSON 开始
|
|
285
|
+
const jsonStart = stdout.indexOf('{')
|
|
286
|
+
if (jsonStart === -1) {
|
|
287
|
+
log(`No JSON found in stdout`)
|
|
288
|
+
const executionTime = (Date.now() - startTime) / 1000
|
|
289
|
+
return {
|
|
290
|
+
success: false,
|
|
291
|
+
outputs: [{
|
|
292
|
+
type: 'error',
|
|
293
|
+
ename: 'OutputParseError',
|
|
294
|
+
evalue: 'No JSON output found',
|
|
295
|
+
traceback: [stdout.substring(0, 1000)]
|
|
296
|
+
}],
|
|
297
|
+
executionTime,
|
|
298
|
+
gpuUsed: useGpu
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const rawOutput = stdout.substring(jsonStart)
|
|
303
|
+
log(`JSON extracted from position ${jsonStart}, length: ${rawOutput.length}`)
|
|
304
|
+
|
|
305
|
+
// 解析 JSON 输出
|
|
306
|
+
let parsedResult
|
|
307
|
+
try {
|
|
308
|
+
parsedResult = JSON.parse(rawOutput)
|
|
309
|
+
log('Output parsed successfully')
|
|
310
|
+
} catch (parseError) {
|
|
311
|
+
log(`JSON parse error: ${parseError.message}`)
|
|
312
|
+
// 如果 JSON 解析失败,返回原始输出作为错误
|
|
313
|
+
const executionTime = (Date.now() - startTime) / 1000
|
|
314
|
+
return {
|
|
315
|
+
success: false,
|
|
316
|
+
outputs: [{
|
|
317
|
+
type: 'error',
|
|
318
|
+
ename: 'OutputParseError',
|
|
319
|
+
evalue: 'Failed to parse kernel output',
|
|
320
|
+
traceback: [rawOutput]
|
|
321
|
+
}],
|
|
322
|
+
executionTime,
|
|
323
|
+
gpuUsed: useGpu
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
success: parsedResult.success,
|
|
329
|
+
outputs: parsedResult.outputs || [],
|
|
330
|
+
executionTime: parsedResult.executionTime || (Date.now() - startTime) / 1000,
|
|
331
|
+
gpuUsed: useGpu
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
} catch (error) {
|
|
335
|
+
log(`Execution error: ${error.message}`)
|
|
336
|
+
log(`Error stack: ${error.stack}`)
|
|
337
|
+
// 清除超时
|
|
338
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
339
|
+
|
|
340
|
+
const executionTime = (Date.now() - startTime) / 1000
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
success: false,
|
|
344
|
+
outputs: [{
|
|
345
|
+
type: 'error',
|
|
346
|
+
ename: error.name || 'ExecutionError',
|
|
347
|
+
evalue: error.message || 'Unknown error',
|
|
348
|
+
traceback: [error.message || 'Unknown error']
|
|
349
|
+
}],
|
|
350
|
+
executionTime,
|
|
351
|
+
gpuUsed: useGpu
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
} finally {
|
|
355
|
+
// 清理容器
|
|
356
|
+
log('Cleaning up container...')
|
|
357
|
+
if (container) {
|
|
358
|
+
try {
|
|
359
|
+
await container.remove({ force: true })
|
|
360
|
+
log('Container removed')
|
|
361
|
+
} catch (e) {
|
|
362
|
+
log(`Container cleanup error: ${e.message}`)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* 解析 Docker 日志输出
|
|
370
|
+
* Docker 日志格式: [8字节头][数据]
|
|
371
|
+
*/
|
|
372
|
+
function parseDockerLogs(logs) {
|
|
373
|
+
if (!logs || logs.length === 0) return ''
|
|
374
|
+
|
|
375
|
+
// 如果是 Buffer
|
|
376
|
+
if (Buffer.isBuffer(logs)) {
|
|
377
|
+
let output = ''
|
|
378
|
+
let offset = 0
|
|
379
|
+
|
|
380
|
+
while (offset < logs.length) {
|
|
381
|
+
// 跳过 8 字节头
|
|
382
|
+
if (offset + 8 > logs.length) break
|
|
383
|
+
|
|
384
|
+
const streamType = logs[offset]
|
|
385
|
+
const length = logs.readUInt32BE(offset + 4)
|
|
386
|
+
|
|
387
|
+
offset += 8
|
|
388
|
+
|
|
389
|
+
if (offset + length > logs.length) break
|
|
390
|
+
|
|
391
|
+
const chunk = logs.slice(offset, offset + length).toString('utf8')
|
|
392
|
+
output += chunk
|
|
393
|
+
offset += length
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return output
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// 如果是字符串
|
|
400
|
+
return logs.toString()
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* 解析 Docker 日志输出,分别返回 stdout 和 stderr
|
|
405
|
+
* Docker 日志格式: [8字节头][数据]
|
|
406
|
+
* 头部第一个字节: 0=stdin, 1=stdout, 2=stderr
|
|
407
|
+
*/
|
|
408
|
+
function parseDockerLogsSeparate(logs) {
|
|
409
|
+
if (!logs || logs.length === 0) return { stdout: '', stderr: '' }
|
|
410
|
+
|
|
411
|
+
// 如果是 Buffer
|
|
412
|
+
if (Buffer.isBuffer(logs)) {
|
|
413
|
+
let stdout = ''
|
|
414
|
+
let stderr = ''
|
|
415
|
+
let offset = 0
|
|
416
|
+
|
|
417
|
+
while (offset < logs.length) {
|
|
418
|
+
// 跳过 8 字节头
|
|
419
|
+
if (offset + 8 > logs.length) break
|
|
420
|
+
|
|
421
|
+
const streamType = logs[offset] // 1=stdout, 2=stderr
|
|
422
|
+
const length = logs.readUInt32BE(offset + 4)
|
|
423
|
+
|
|
424
|
+
offset += 8
|
|
425
|
+
|
|
426
|
+
if (offset + length > logs.length) break
|
|
427
|
+
|
|
428
|
+
const chunk = logs.slice(offset, offset + length).toString('utf8')
|
|
429
|
+
|
|
430
|
+
if (streamType === 1) {
|
|
431
|
+
stdout += chunk
|
|
432
|
+
} else if (streamType === 2) {
|
|
433
|
+
stderr += chunk
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
offset += length
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return { stdout, stderr }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// 如果是字符串,无法区分,全部作为 stdout
|
|
443
|
+
return { stdout: logs.toString(), stderr: '' }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* 检查沙箱镜像是否存在
|
|
448
|
+
*/
|
|
449
|
+
export async function checkImageExists(useGpu = false) {
|
|
450
|
+
const image = useGpu ? SANDBOX_CONFIG.imageGpu : SANDBOX_CONFIG.imageCpu
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
await docker.getImage(image).inspect()
|
|
454
|
+
return true
|
|
455
|
+
} catch {
|
|
456
|
+
return false
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* 拉取沙箱镜像
|
|
462
|
+
*/
|
|
463
|
+
export async function pullImage(useGpu = false) {
|
|
464
|
+
const image = useGpu ? SANDBOX_CONFIG.imageGpu : SANDBOX_CONFIG.imageCpu
|
|
465
|
+
|
|
466
|
+
return new Promise((resolve, reject) => {
|
|
467
|
+
docker.pull(image, (err, stream) => {
|
|
468
|
+
if (err) {
|
|
469
|
+
reject(err)
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
docker.modem.followProgress(stream, (err, output) => {
|
|
474
|
+
if (err) {
|
|
475
|
+
reject(err)
|
|
476
|
+
} else {
|
|
477
|
+
resolve(output)
|
|
478
|
+
}
|
|
479
|
+
})
|
|
480
|
+
})
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export default {
|
|
485
|
+
runPythonCode,
|
|
486
|
+
checkGPUAvailable,
|
|
487
|
+
checkImageExists,
|
|
488
|
+
pullImage,
|
|
489
|
+
SANDBOX_CONFIG
|
|
490
|
+
}
|
package/tests/cli.test.js
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CLI 命令单元测试
|
|
3
|
-
*/
|
|
4
|
-
import { describe, it, expect, beforeAll } from '@jest/globals'
|
|
5
|
-
import { fileURLToPath } from 'url'
|
|
6
|
-
import path from 'path'
|
|
7
|
-
import fs from 'fs'
|
|
8
|
-
|
|
9
|
-
// ESM 中获取 __dirname
|
|
10
|
-
const __filename = fileURLToPath(import.meta.url)
|
|
11
|
-
const __dirname = path.dirname(__filename)
|
|
12
|
-
|
|
13
|
-
// 基础测试:确保模块可导入
|
|
14
|
-
describe('CLI Module', () => {
|
|
15
|
-
beforeAll(() => {
|
|
16
|
-
// 设置测试环境
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('should have correct package.json', async () => {
|
|
20
|
-
const pkgPath = path.resolve(__dirname, '../package.json')
|
|
21
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
22
|
-
|
|
23
|
-
expect(pkg.name).toBe('@icyfenix-dmla/cli')
|
|
24
|
-
expect(pkg.bin).toBeDefined()
|
|
25
|
-
expect(pkg.bin.dmla).toBe('./bin/dmla.js')
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it('should have commander dependency', async () => {
|
|
29
|
-
const pkgPath = path.resolve(__dirname, '../package.json')
|
|
30
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
31
|
-
|
|
32
|
-
expect(pkg.dependencies.commander).toBeDefined()
|
|
33
|
-
})
|
|
34
|
-
})
|