auto-save 2026-04-19 13:31 (+1, ~1)

This commit is contained in:
2026-04-19 13:33:05 +08:00
parent 9f348be145
commit 8b26a2155a
6 changed files with 252 additions and 0 deletions

View File

@@ -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
View 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
View 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
View 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
View 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;
},
};

View 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"]
}