Opensandbox (#6657)

* Opensandbox (#6651)

* volumn manager

* feat: opensandbox volumn

* perf: action (#6654)

* perf: action

* doc

* doc

* deploy tml

* update template
This commit is contained in:
Archer
2026-03-26 18:25:57 +08:00
committed by GitHub
parent d0f96723ea
commit cc3a91d009
114 changed files with 1966 additions and 953 deletions
@@ -0,0 +1,70 @@
import type { IVolumeDriver, EnsureResult } from './IVolumeDriver';
import { toVolumeName } from '../utils/naming';
import { env } from '../env';
import { logDebug } from '../utils/logger';
export class DockerVolumeDriver implements IVolumeDriver {
private readonly socketPath: string;
private readonly prefix: string;
constructor(socketPath = env.VM_DOCKER_SOCKET, prefix = env.VM_VOLUME_NAME_PREFIX) {
this.socketPath = socketPath;
this.prefix = prefix;
}
private dockerFetch(path: string, init?: RequestInit): Promise<Response> {
// Bun supports unix socket via the `unix` fetch option
return fetch(`http://localhost/v1.41${path}`, {
...init,
// @ts-ignore - Bun-specific option
unix: this.socketPath
});
}
async ensure(sessionId: string): Promise<EnsureResult> {
const name = toVolumeName(this.prefix, sessionId);
// Check if volume already exists
logDebug(`Docker inspect volume name=${name}`);
const inspectRes = await this.dockerFetch(`/volumes/${name}`);
logDebug(`Docker inspect volume status=${inspectRes.status}`);
if (inspectRes.ok) {
return { claimName: name, created: false };
}
if (inspectRes.status !== 404) {
const text = await inspectRes.text().catch(() => '');
throw new Error(`Docker volume inspect failed (${inspectRes.status}): ${text}`);
}
// Create volume
logDebug(`Docker create volume name=${name}`);
const createRes = await this.dockerFetch('/volumes/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Name: name })
});
logDebug(`Docker create volume status=${createRes.status}`);
if (!createRes.ok) {
const text = await createRes.text().catch(() => '');
throw new Error(`Docker volume create failed (${createRes.status}): ${text}`);
}
return { claimName: name, created: true };
}
async remove(sessionId: string): Promise<void> {
const name = toVolumeName(this.prefix, sessionId);
logDebug(`Docker remove volume name=${name}`);
const res = await this.dockerFetch(`/volumes/${name}`, { method: 'DELETE' });
logDebug(`Docker remove volume status=${res.status}`);
// 404 is idempotent success
if (!res.ok && res.status !== 404) {
const text = await res.text().catch(() => '');
throw new Error(`Docker volume delete failed (${res.status}): ${text}`);
}
}
}
@@ -0,0 +1,9 @@
export type EnsureResult = {
claimName: string;
created: boolean;
};
export interface IVolumeDriver {
ensure(sessionId: string): Promise<EnsureResult>;
remove(sessionId: string): Promise<void>;
}
@@ -0,0 +1,114 @@
import { readFileSync } from 'fs';
import type { IVolumeDriver, EnsureResult } from './IVolumeDriver';
import { toVolumeName } from '../utils/naming';
import { env } from '../env';
import { logDebug } from '../utils/logger';
const K8S_API = 'https://kubernetes.default.svc';
const TOKEN_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/token';
const CA_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt';
function readToken(): string {
return readFileSync(TOKEN_PATH, 'utf-8').trim();
}
function fetchOpts(extra: RequestInit = {}): RequestInit {
return { ...extra, tls: { ca: readFileSync(CA_PATH, 'utf-8') } } as RequestInit;
}
function pvcBody(name: string, sessionId: string): object {
return {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: {
name,
namespace: env.VM_K8S_NAMESPACE,
labels: { 'fastgpt/session-id': sessionId }
},
spec: {
accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: env.VM_K8S_PVC_STORAGE_SIZE } },
storageClassName: env.VM_K8S_PVC_STORAGE_CLASS
}
};
}
export class K8sVolumeDriver implements IVolumeDriver {
private readonly namespace: string;
private readonly prefix: string;
constructor(namespace = env.VM_K8S_NAMESPACE, prefix = env.VM_VOLUME_NAME_PREFIX) {
this.namespace = namespace;
this.prefix = prefix;
}
private headers(): Record<string, string> {
return {
Authorization: `Bearer ${readToken()}`,
'Content-Type': 'application/json',
Accept: 'application/json'
};
}
private pvcUrl(name?: string): string {
const base = `${K8S_API}/api/v1/namespaces/${this.namespace}/persistentvolumeclaims`;
return name ? `${base}/${name}` : base;
}
async ensure(sessionId: string): Promise<EnsureResult> {
const name = toVolumeName(this.prefix, sessionId);
const getUrl = this.pvcUrl(name);
logDebug(`K8s GET PVC url=${getUrl}`);
const getRes = await fetch(getUrl, fetchOpts({ headers: this.headers() }));
logDebug(`K8s GET PVC status=${getRes.status}`);
if (getRes.ok) {
return { claimName: name, created: false };
}
if (getRes.status !== 404) {
const text = await getRes.text().catch(() => '');
throw new Error(`K8s PVC GET failed (${getRes.status}): ${text}`);
}
const postUrl = this.pvcUrl();
logDebug(`K8s POST PVC url=${postUrl} name=${name}`);
const createRes = await fetch(
postUrl,
fetchOpts({
method: 'POST',
headers: this.headers(),
body: JSON.stringify(pvcBody(name, sessionId))
})
);
logDebug(`K8s POST PVC status=${createRes.status}`);
if (!createRes.ok) {
const text = await createRes.text().catch(() => '');
throw new Error(`K8s PVC create failed (${createRes.status}): ${text}`);
}
return { claimName: name, created: true };
}
async remove(sessionId: string): Promise<void> {
const name = toVolumeName(this.prefix, sessionId);
const delUrl = this.pvcUrl(name);
logDebug(`K8s DELETE PVC url=${delUrl}`);
const res = await fetch(
delUrl,
fetchOpts({
method: 'DELETE',
headers: this.headers()
})
);
logDebug(`K8s DELETE PVC status=${res.status}`);
if (!res.ok && res.status !== 404) {
const text = await res.text().catch(() => '');
throw new Error(`K8s PVC delete failed (${res.status}): ${text}`);
}
}
}
+23
View File
@@ -0,0 +1,23 @@
import { z } from 'zod';
const schema = z.object({
VM_AUTH_TOKEN: z.string().min(1),
VM_RUNTIME: z.enum(['docker', 'kubernetes']).default('kubernetes'),
VM_DOCKER_SOCKET: z.string().default('/var/run/docker.sock'),
VM_K8S_NAMESPACE: z.string().default('opensandbox'),
VM_K8S_PVC_STORAGE_CLASS: z.string().default('standard'),
VM_K8S_PVC_STORAGE_SIZE: z.string().default('1Gi'),
VM_VOLUME_NAME_PREFIX: z.string().default('fastgpt-session'),
VM_PORT: z.coerce.number().default(3001),
VM_LOG_LEVEL: z.enum(['debug', 'info', 'none']).default('info')
});
const result = schema.safeParse(process.env);
if (!result.success) {
const missing = result.error.issues.map((i) => i.path.join('.')).join(', ');
console.error(`[volume-manager] Invalid environment variables: ${missing}`);
process.exit(1);
}
export const env = result.data;
+34
View File
@@ -0,0 +1,34 @@
import { Hono } from 'hono';
import { env } from './env';
import { DockerVolumeDriver } from './drivers/DockerVolumeDriver';
import { K8sVolumeDriver } from './drivers/K8sVolumeDriver';
import { VolumeService } from './services/VolumeService';
import { volumeRoutes } from './routes/volumes';
// Select driver based on runtime
const driver = env.VM_RUNTIME === 'docker' ? new DockerVolumeDriver() : new K8sVolumeDriver();
const service = new VolumeService(driver);
const app = new Hono();
// Health check (no auth)
app.get('/health', (c) => c.json({ status: 'ok' }));
// Auth middleware for /v1/* routes
app.use('/v1/*', async (c, next) => {
const authHeader = c.req.header('Authorization');
if (authHeader !== `Bearer ${env.VM_AUTH_TOKEN}`) {
return c.json({ error: 'Unauthorized' }, 401);
}
await next();
});
// Volume routes
app.route('/v1/volumes', volumeRoutes(service));
const server = Bun.serve({
port: env.VM_PORT,
fetch: app.fetch
});
console.log(`[volume-manager] Listening on port ${server.port} (runtime: ${env.VM_RUNTIME})`);
@@ -0,0 +1,39 @@
import { Hono } from 'hono';
import { z } from 'zod';
import type { VolumeService } from '../services/VolumeService';
import { logInfo } from '../utils/logger';
const ensureBodySchema = z.object({
sessionId: z.string()
});
export function volumeRoutes(service: VolumeService): Hono {
const app = new Hono();
// POST /v1/volumes/ensure
app.post('/ensure', async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = ensureBodySchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'Invalid request body', details: parsed.error.issues }, 400);
}
const { sessionId } = parsed.data;
logInfo(`POST /v1/volumes/ensure sessionId=${sessionId}`);
const result = await service.ensure(sessionId);
const status = result.created ? 201 : 200;
logInfo(`ensure done claimName=${result.claimName} created=${result.created} status=${status}`);
return c.json(result, status);
});
// DELETE /v1/volumes/:sessionId
app.delete('/:sessionId', async (c) => {
const sessionId = c.req.param('sessionId');
logInfo(`DELETE /v1/volumes/${sessionId}`);
await service.remove(sessionId);
logInfo(`remove done sessionId=${sessionId}`);
return c.body(null, 204);
});
return app;
}
@@ -0,0 +1,19 @@
import type { IVolumeDriver, EnsureResult } from '../drivers/IVolumeDriver';
import { logDebug } from '../utils/logger';
export class VolumeService {
constructor(private readonly driver: IVolumeDriver) {}
async ensure(sessionId: string): Promise<EnsureResult> {
logDebug(`VolumeService.ensure sessionId=${sessionId}`);
const result = await this.driver.ensure(sessionId);
logDebug(`VolumeService.ensure done claimName=${result.claimName} created=${result.created}`);
return result;
}
async remove(sessionId: string): Promise<void> {
logDebug(`VolumeService.remove sessionId=${sessionId}`);
await this.driver.remove(sessionId);
logDebug(`VolumeService.remove done sessionId=${sessionId}`);
}
}
@@ -0,0 +1,11 @@
import { env } from '../env';
export function logInfo(msg: string, ...args: unknown[]): void {
if (env.VM_LOG_LEVEL === 'none') return;
console.log(`[volume-manager] ${msg}`, ...args);
}
export function logDebug(msg: string, ...args: unknown[]): void {
if (env.VM_LOG_LEVEL !== 'debug') return;
console.log(`[volume-manager:debug] ${msg}`, ...args);
}
@@ -0,0 +1,12 @@
// sessionId: lowercase alphanumeric and hyphens, no leading/trailing hyphen, 1-253 chars
const SESSION_ID_RE = /^[a-z0-9]([a-z0-9-]{0,251}[a-z0-9])?$/;
export function toVolumeName(prefix: string, sessionId: string): string {
const normalized = sessionId.toLowerCase();
if (!SESSION_ID_RE.test(normalized)) {
throw new Error(
`Invalid sessionId: must be lowercase alphanumeric/hyphens, got "${sessionId}"`
);
}
return `${prefix}-${normalized}`;
}