Files
lobe-sandbox-backend/orchestrator/src/incus.ts
kang 158faeb655 fix(sandbox): sanitize userId → container name(下划线→横杠)
better-auth 生成的 user id 格式是 \`user_xxx\`(带下划线),
Incus 容器名规则只允许 [a-zA-Z0-9-],用下划线会报
"Invalid instance name: Name can only contain alphanumeric and hyphen characters"。

修法:containerName() 用 \`.replace(/[^a-zA-Z0-9-]/g, '-')\` 把所有非法字符替换。
影响:存量 17 个用户全部成功 provisioned 为 sb-user-xxx(横杠版)。

Phase 5 生产上线完成(2026-04-19):
- orchestrator 绑 0.0.0.0:8700 + iptables 放行 172.17/172.18 网段
- LobeChat .env 加 SANDBOX_BACKEND_URL=http://172.18.0.1:8700 + SECRET
- feat/self-hosted-sandbox 分支 push Gitea,VPS 上 docker build → lobechat-custom:sandbox
- 重 tag :latest 并 docker compose up -d --force-recreate lobe
- 17 个存量用户 backfill 沙箱全成功,池子占 3.7GB(CoW)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:59:10 +08:00

159 lines
5.4 KiB
TypeScript

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;
};
// Incus 容器名只允许 [a-zA-Z0-9-],better-auth user id 是 user_xxx 带下划线 → 替换
export const containerName = (userId: string): string =>
`${env.incus.prefix}${userId.replace(/[^a-zA-Z0-9-]/g, '-')}`;
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 (defaults to sandbox venv + $HOME set for consistent Python env)
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);
// For sandbox user, bake in venv + HOME so `uv pip install xxx` (no --system) just works
if (opts.user === 1000) {
args.push(
'--env',
'VIRTUAL_ENV=/home/sandbox/.venv',
'--env',
'PATH=/home/sandbox/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
'--env',
'HOME=/home/sandbox',
'--env',
'LANG=zh_CN.UTF-8',
);
}
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;
},
};