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>
159 lines
5.4 KiB
TypeScript
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;
|
|
},
|
|
};
|