mirror of
https://github.com/labring/FastGPT.git
synced 2026-05-16 01:09:01 +08:00
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:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user