From 8b26a2155af1a101aea1aeca5070292ac0ac87d8 Mon Sep 17 00:00:00 2001 From: kang Date: Sun, 19 Apr 2026 13:33:05 +0800 Subject: [PATCH] auto-save 2026-04-19 13:31 (+1, ~1) --- .memory/worklog.json | 7 ++ orchestrator/.env.example | 26 +++++++ orchestrator/package.json | 21 ++++++ orchestrator/src/env.ts | 36 ++++++++++ orchestrator/src/incus.ts | 143 +++++++++++++++++++++++++++++++++++++ orchestrator/tsconfig.json | 19 +++++ 6 files changed, 252 insertions(+) create mode 100644 orchestrator/.env.example create mode 100644 orchestrator/package.json create mode 100644 orchestrator/src/env.ts create mode 100644 orchestrator/src/incus.ts create mode 100644 orchestrator/tsconfig.json diff --git a/.memory/worklog.json b/.memory/worklog.json index ee1ef52..6e90b3b 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -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 } ] } diff --git a/orchestrator/.env.example b/orchestrator/.env.example new file mode 100644 index 0000000..96f2a7f --- /dev/null +++ b/orchestrator/.env.example @@ -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 diff --git a/orchestrator/package.json b/orchestrator/package.json new file mode 100644 index 0000000..41de475 --- /dev/null +++ b/orchestrator/package.json @@ -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" + } +} diff --git a/orchestrator/src/env.ts b/orchestrator/src/env.ts new file mode 100644 index 0000000..dc28465 --- /dev/null +++ b/orchestrator/src/env.ts @@ -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', + }, +}; diff --git a/orchestrator/src/incus.ts b/orchestrator/src/incus.ts new file mode 100644 index 0000000..efb6152 --- /dev/null +++ b/orchestrator/src/incus.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + const s = await incus.state(name); + if (s === 'RUNNING') { + await run(['stop', name, ...projArgs]); + } + }, + + remove: async (name: string): Promise => { + 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 => { + 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 => { + 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 => { + 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; + }, +}; diff --git a/orchestrator/tsconfig.json b/orchestrator/tsconfig.json new file mode 100644 index 0000000..9e5e700 --- /dev/null +++ b/orchestrator/tsconfig.json @@ -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"] +}