auto-save 2026-04-19 13:31 (+1, ~1)
This commit is contained in:
@@ -76,6 +76,13 @@
|
||||
"message": "auto-save 2026-04-19 12:15 (~1)",
|
||||
"hash": "69ab3a3",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-19T13:09:38+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-19 13:09 (~1)",
|
||||
"hash": "9f348be",
|
||||
"files_changed": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
26
orchestrator/.env.example
Normal file
26
orchestrator/.env.example
Normal file
@@ -0,0 +1,26 @@
|
||||
# Orchestrator 配置
|
||||
PORT=8700
|
||||
HOST=127.0.0.1
|
||||
|
||||
# 授权 header (LobeChat 侧 SANDBOX_BACKEND_SECRET 要一致)
|
||||
SANDBOX_ORCH_SECRET=change-me-to-long-random-string
|
||||
|
||||
# Incus
|
||||
INCUS_PROJECT=lobe-sandbox
|
||||
INCUS_PROFILE=sandbox-default
|
||||
INCUS_BASE_IMAGE=lobe-sandbox-base
|
||||
INCUS_CONTAINER_PREFIX=sb-
|
||||
|
||||
# 空闲回收:毫秒
|
||||
IDLE_TIMEOUT_MS=1800000
|
||||
|
||||
# SQLite 状态持久化
|
||||
STATE_DB_PATH=/var/lib/lobe-sandbox/state.sqlite
|
||||
|
||||
# MinIO(exportFile 用)
|
||||
S3_ENDPOINT=http://192.168.2.221:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=lobe-sandbox-exports
|
||||
S3_ACCESS_KEY_ID=admin
|
||||
S3_SECRET_ACCESS_KEY=CHANGEME
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
21
orchestrator/package.json
Normal file
21
orchestrator/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "lobe-sandbox-orchestrator",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"start": "bun src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.670.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.670.0",
|
||||
"hono": "^4.6.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
36
orchestrator/src/env.ts
Normal file
36
orchestrator/src/env.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
const required = (key: string): string => {
|
||||
const v = process.env[key];
|
||||
if (!v) throw new Error(`Missing env: ${key}`);
|
||||
return v;
|
||||
};
|
||||
|
||||
const num = (key: string, def: number): number => {
|
||||
const v = process.env[key];
|
||||
return v ? Number.parseInt(v, 10) : def;
|
||||
};
|
||||
|
||||
export const env = {
|
||||
port: num('PORT', 8700),
|
||||
host: process.env.HOST ?? '127.0.0.1',
|
||||
|
||||
orchSecret: required('SANDBOX_ORCH_SECRET'),
|
||||
|
||||
incus: {
|
||||
project: process.env.INCUS_PROJECT ?? 'lobe-sandbox',
|
||||
profile: process.env.INCUS_PROFILE ?? 'sandbox-default',
|
||||
baseImage: process.env.INCUS_BASE_IMAGE ?? 'lobe-sandbox-base',
|
||||
prefix: process.env.INCUS_CONTAINER_PREFIX ?? 'sb-',
|
||||
},
|
||||
|
||||
idleTimeoutMs: num('IDLE_TIMEOUT_MS', 30 * 60 * 1000),
|
||||
stateDbPath: process.env.STATE_DB_PATH ?? '/var/lib/lobe-sandbox/state.sqlite',
|
||||
|
||||
s3: {
|
||||
endpoint: process.env.S3_ENDPOINT ?? 'http://192.168.2.221:9000',
|
||||
region: process.env.S3_REGION ?? 'us-east-1',
|
||||
bucket: process.env.S3_BUCKET ?? 'lobe-sandbox-exports',
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? '',
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? '',
|
||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== 'false',
|
||||
},
|
||||
};
|
||||
143
orchestrator/src/incus.ts
Normal file
143
orchestrator/src/incus.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { env } from './env.ts';
|
||||
|
||||
export interface ExecResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
const run = async (args: string[], input?: string): Promise<ExecResult> => {
|
||||
const proc = Bun.spawn(['incus', ...args], {
|
||||
stdin: input ? 'pipe' : 'ignore',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
if (input && proc.stdin) {
|
||||
proc.stdin.write(input);
|
||||
proc.stdin.end();
|
||||
}
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
return { stdout, stderr, exitCode };
|
||||
};
|
||||
|
||||
const requireOk = (r: ExecResult, msg: string): ExecResult => {
|
||||
if (r.exitCode !== 0) {
|
||||
throw new Error(`${msg}: exit=${r.exitCode} stderr=${r.stderr.slice(0, 500)}`);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
export const containerName = (userId: string): string => `${env.incus.prefix}${userId}`;
|
||||
|
||||
const projArgs = ['--project', env.incus.project];
|
||||
|
||||
export const incus = {
|
||||
exists: async (name: string): Promise<boolean> => {
|
||||
const r = await run(['info', name, ...projArgs]);
|
||||
return r.exitCode === 0;
|
||||
},
|
||||
|
||||
state: async (name: string): Promise<'RUNNING' | 'STOPPED' | 'MISSING'> => {
|
||||
const r = await run(['list', name, '--format', 'csv', '-c', 'ns', ...projArgs]);
|
||||
if (r.exitCode !== 0) return 'MISSING';
|
||||
const line = r.stdout.trim().split('\n').find((l) => l.startsWith(`${name},`));
|
||||
if (!line) return 'MISSING';
|
||||
const state = line.split(',')[1]?.trim().toUpperCase();
|
||||
return state === 'RUNNING' ? 'RUNNING' : 'STOPPED';
|
||||
},
|
||||
|
||||
// 从 base 镜像 init 出一个新容器(不启动),依赖 btrfs CoW 秒建
|
||||
provision: async (name: string): Promise<void> => {
|
||||
if (await incus.exists(name)) return;
|
||||
requireOk(
|
||||
await run(['init', env.incus.baseImage, name, ...projArgs, '-p', env.incus.profile]),
|
||||
`incus init ${name}`,
|
||||
);
|
||||
},
|
||||
|
||||
start: async (name: string): Promise<void> => {
|
||||
requireOk(await run(['start', name, ...projArgs]), `incus start ${name}`);
|
||||
// 等网络就绪
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const r = await run(['exec', name, ...projArgs, '--', 'bash', '-c',
|
||||
'ip -4 addr show eth0 | grep -q "inet "']);
|
||||
if (r.exitCode === 0) return;
|
||||
await Bun.sleep(500);
|
||||
}
|
||||
throw new Error(`container ${name} network not ready`);
|
||||
},
|
||||
|
||||
stop: async (name: string): Promise<void> => {
|
||||
const s = await incus.state(name);
|
||||
if (s === 'RUNNING') {
|
||||
await run(['stop', name, ...projArgs]);
|
||||
}
|
||||
},
|
||||
|
||||
remove: async (name: string): Promise<void> => {
|
||||
if (await incus.exists(name)) {
|
||||
await run(['delete', name, ...projArgs, '--force']);
|
||||
}
|
||||
},
|
||||
|
||||
// user=0 root, user=1000 sandbox
|
||||
exec: async (
|
||||
name: string,
|
||||
cmd: string[],
|
||||
opts: { user?: number; cwd?: string; stdin?: string; timeoutMs?: number } = {},
|
||||
): Promise<ExecResult> => {
|
||||
const args = ['exec', name, ...projArgs];
|
||||
if (opts.user !== undefined) args.push('--user', String(opts.user));
|
||||
if (opts.cwd) args.push('--cwd', opts.cwd);
|
||||
args.push('--');
|
||||
args.push(...cmd);
|
||||
const proc = Bun.spawn(['incus', ...args], {
|
||||
stdin: opts.stdin ? 'pipe' : 'ignore',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
if (opts.stdin && proc.stdin) {
|
||||
proc.stdin.write(opts.stdin);
|
||||
proc.stdin.end();
|
||||
}
|
||||
const timer = opts.timeoutMs
|
||||
? setTimeout(() => proc.kill('SIGKILL'), opts.timeoutMs)
|
||||
: null;
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
if (timer) clearTimeout(timer);
|
||||
return { stdout, stderr, exitCode };
|
||||
},
|
||||
|
||||
// push file from host → container (用 stdin 流,避免写磁盘)
|
||||
push: async (name: string, remotePath: string, content: string, opts: { mode?: string } = {}): Promise<void> => {
|
||||
const args = ['file', 'push', '--mode', opts.mode ?? '0644', ...projArgs, '-', `${name}${remotePath}`];
|
||||
const proc = Bun.spawn(['incus', ...args], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' });
|
||||
proc.stdin.write(content);
|
||||
proc.stdin.end();
|
||||
const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
|
||||
if (exitCode !== 0) throw new Error(`incus file push ${remotePath}: ${stderr}`);
|
||||
},
|
||||
|
||||
// pull file from container → local Uint8Array (出文件导出用)
|
||||
pull: async (name: string, remotePath: string): Promise<Uint8Array> => {
|
||||
const proc = Bun.spawn(['incus', 'file', 'pull', ...projArgs, `${name}${remotePath}`, '-'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const [bytes, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).bytes(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
if (exitCode !== 0) throw new Error(`incus file pull ${remotePath}: ${stderr}`);
|
||||
return bytes;
|
||||
},
|
||||
};
|
||||
19
orchestrator/tsconfig.json
Normal file
19
orchestrator/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["bun-types"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user