@axiom-lattice/opensandbox-gateway 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +22 -0
- package/.turbo/turbo-build.log +21 -0
- package/CHANGELOG.md +13 -0
- package/LICENSE +201 -0
- package/README.md +168 -0
- package/dist/chunk-FTPJIUJT.mjs +1081 -0
- package/dist/chunk-FTPJIUJT.mjs.map +1 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +11 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.d.mts +373 -0
- package/dist/index.mjs +11 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +39 -0
- package/src/app.ts +67 -0
- package/src/cli.ts +8 -0
- package/src/controllers/images.ts +38 -0
- package/src/controllers/sandbox.ts +176 -0
- package/src/controllers/volume-fs.ts +163 -0
- package/src/index.ts +3 -0
- package/src/lib/errors.ts +44 -0
- package/src/lib/http.ts +11 -0
- package/src/lib/server-cli.ts +65 -0
- package/src/routes/health.ts +6 -0
- package/src/routes/images.ts +12 -0
- package/src/routes/sandbox.ts +31 -0
- package/src/routes/volume-fs.ts +12 -0
- package/src/schemas/images.ts +12 -0
- package/src/schemas/sandbox.ts +98 -0
- package/src/schemas/volume-fs.ts +23 -0
- package/src/server.ts +50 -0
- package/src/services/ImageService.ts +32 -0
- package/src/services/OpenSandboxRuntimeService.ts +576 -0
- package/src/swagger.ts +40 -0
- package/src/types/runtime-service.ts +79 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { ConnectionConfig, Sandbox, type Volume } from "@alibaba-group/opensandbox";
|
|
2
|
+
import { SandboxManager, type SandboxFilter } from "@alibaba-group/opensandbox";
|
|
3
|
+
import { SandboxException } from "@alibaba-group/opensandbox";
|
|
4
|
+
import type { RuntimeService } from "../types/runtime-service";
|
|
5
|
+
import type { EnsureSandboxInput, ListSandboxesQuery, ShellExecInput } from "../schemas/sandbox";
|
|
6
|
+
import type { SandboxLogsInput } from "../schemas/sandbox";
|
|
7
|
+
import type { SandboxRuntimeMetrics } from "../types/runtime-service";
|
|
8
|
+
import { HttpError } from "../lib/errors";
|
|
9
|
+
|
|
10
|
+
type CachedEntry = {
|
|
11
|
+
sandbox: Sandbox;
|
|
12
|
+
sandboxId: string;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
lastUsedAt: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type VolumeDefinition = NonNullable<EnsureSandboxInput["volumes"]>[string];
|
|
18
|
+
|
|
19
|
+
const NAME_METADATA_KEY = "axiom-lattice/name";
|
|
20
|
+
|
|
21
|
+
function getEnv(key: string, fallback: string): string {
|
|
22
|
+
return process.env[key] ?? fallback;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getEnvNumber(key: string, fallback: number): number {
|
|
26
|
+
const v = process.env[key];
|
|
27
|
+
return v ? Number(v) : fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class OpenSandboxRuntimeService implements RuntimeService {
|
|
31
|
+
private cache = new Map<string, CachedEntry>();
|
|
32
|
+
private pendingCreates = new Map<string, Promise<CachedEntry>>();
|
|
33
|
+
private connectionConfig: ConnectionConfig;
|
|
34
|
+
private defaultImage: string;
|
|
35
|
+
private defaultCpus: number;
|
|
36
|
+
private defaultMemoryMib: number;
|
|
37
|
+
private defaultTimeout: number;
|
|
38
|
+
private idleTimeoutMs: number;
|
|
39
|
+
|
|
40
|
+
constructor() {
|
|
41
|
+
this.connectionConfig = new ConnectionConfig({
|
|
42
|
+
domain: getEnv("OPEN_SANDBOX_DOMAIN", "localhost:8080"),
|
|
43
|
+
protocol: getEnv("OPEN_SANDBOX_PROTOCOL", "http") as "http" | "https",
|
|
44
|
+
apiKey: process.env["OPEN_SANDBOX_API_KEY"],
|
|
45
|
+
useServerProxy: getEnv("OPEN_SANDBOX_USE_SERVER_PROXY", "false") === "true",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
this.defaultImage = getEnv("OPEN_SANDBOX_DEFAULT_IMAGE", "ubuntu:22.04");
|
|
49
|
+
this.defaultCpus = getEnvNumber("OPEN_SANDBOX_DEFAULT_CPUS", 1);
|
|
50
|
+
this.defaultMemoryMib = getEnvNumber("OPEN_SANDBOX_DEFAULT_MEMORY_MIB", 2048);
|
|
51
|
+
this.defaultTimeout = getEnvNumber("OPEN_SANDBOX_DEFAULT_TIMEOUT", 600);
|
|
52
|
+
this.idleTimeoutMs = getEnvNumber("SANDBOX_IDLE_TIMEOUT_MS", 600_000);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private mapVolumes(volumes?: Record<string, VolumeDefinition>): Volume[] {
|
|
56
|
+
if (!volumes) return [];
|
|
57
|
+
return Object.entries(volumes).map(([mountPath, def]) => {
|
|
58
|
+
if (def.type === "bind" && def.source) {
|
|
59
|
+
return { name: mountPath, host: { path: def.source }, mountPath, readOnly: def.readonly };
|
|
60
|
+
}
|
|
61
|
+
if (def.type === "named" && def.name) {
|
|
62
|
+
return { name: mountPath, pvc: { claimName: def.name }, mountPath, readOnly: def.readonly };
|
|
63
|
+
}
|
|
64
|
+
if (def.type === "tmpfs") {
|
|
65
|
+
throw new HttpError(400, "INVALID_REQUEST", "tmpfs volumes are not supported by OpenSandbox");
|
|
66
|
+
}
|
|
67
|
+
throw new HttpError(400, "INVALID_REQUEST", `Unsupported volume type at ${mountPath}`);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private getCached(name: string): CachedEntry {
|
|
72
|
+
const entry = this.cache.get(name);
|
|
73
|
+
if (!entry) {
|
|
74
|
+
throw new HttpError(404, "SANDBOX_NOT_FOUND", `Sandbox '${name}' not found`);
|
|
75
|
+
}
|
|
76
|
+
return entry;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async findByNameViaMetadata(name: string): Promise<CachedEntry | null> {
|
|
80
|
+
try {
|
|
81
|
+
const manager = SandboxManager.create({ connectionConfig: this.connectionConfig });
|
|
82
|
+
const result = await manager.listSandboxInfos({
|
|
83
|
+
metadata: { [NAME_METADATA_KEY]: name },
|
|
84
|
+
pageSize: 1,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (result.items.length === 0) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const info = result.items[0];
|
|
92
|
+
const sandbox = await Sandbox.resume({
|
|
93
|
+
sandboxId: info.id,
|
|
94
|
+
connectionConfig: this.connectionConfig,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const entry: CachedEntry = {
|
|
98
|
+
sandbox,
|
|
99
|
+
sandboxId: info.id,
|
|
100
|
+
createdAt: info.createdAt ? new Date(info.createdAt).toISOString() : new Date().toISOString(),
|
|
101
|
+
lastUsedAt: Date.now(),
|
|
102
|
+
};
|
|
103
|
+
this.cache.set(name, entry);
|
|
104
|
+
return entry;
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (err instanceof SandboxException && err.error.code === "NOT_FOUND") {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
console.warn(`[OpenSandboxRuntimeService] metadata lookup for name=${name} failed:`, err);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async ensureSandbox(
|
|
115
|
+
name: string,
|
|
116
|
+
input: EnsureSandboxInput
|
|
117
|
+
): Promise<{ name: string; status: string }> {
|
|
118
|
+
// 1. Check cache
|
|
119
|
+
const cached = this.cache.get(name);
|
|
120
|
+
if (cached) {
|
|
121
|
+
try {
|
|
122
|
+
const info = await cached.sandbox.getInfo();
|
|
123
|
+
if (info.status.state === "Running") {
|
|
124
|
+
cached.lastUsedAt = Date.now();
|
|
125
|
+
await cached.sandbox.renew(this.defaultTimeout).catch(() => {
|
|
126
|
+
});
|
|
127
|
+
return { name, status: "running" };
|
|
128
|
+
}
|
|
129
|
+
this.cache.delete(name);
|
|
130
|
+
} catch {
|
|
131
|
+
this.cache.delete(name);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 2. Dedup concurrent creates for same name
|
|
136
|
+
const inflight = this.pendingCreates.get(name);
|
|
137
|
+
if (inflight) {
|
|
138
|
+
await inflight;
|
|
139
|
+
return { name, status: "running" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 3. Try metadata recovery (crash recovery)
|
|
143
|
+
const recovered = await this.findByNameViaMetadata(name);
|
|
144
|
+
if (recovered) {
|
|
145
|
+
return { name, status: "running" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 4. Create new sandbox
|
|
149
|
+
const creation = this.doCreateSandbox(name, input);
|
|
150
|
+
this.pendingCreates.set(name, creation);
|
|
151
|
+
try {
|
|
152
|
+
await creation;
|
|
153
|
+
return { name, status: "running" };
|
|
154
|
+
} finally {
|
|
155
|
+
this.pendingCreates.delete(name);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async doCreateSandbox(
|
|
160
|
+
name: string,
|
|
161
|
+
input: EnsureSandboxInput
|
|
162
|
+
): Promise<CachedEntry> {
|
|
163
|
+
const sandbox = await Sandbox.create({
|
|
164
|
+
connectionConfig: this.connectionConfig,
|
|
165
|
+
image: input.image ?? this.defaultImage,
|
|
166
|
+
timeoutSeconds: this.defaultTimeout,
|
|
167
|
+
resource: {
|
|
168
|
+
cpu: String(input.cpus ?? this.defaultCpus),
|
|
169
|
+
memory: `${input.memoryMib ?? this.defaultMemoryMib}Mi`,
|
|
170
|
+
},
|
|
171
|
+
env: input.env ?? {},
|
|
172
|
+
metadata: { [NAME_METADATA_KEY]: name },
|
|
173
|
+
volumes: this.mapVolumes(input.volumes),
|
|
174
|
+
entrypoint: ["tail", "-f", "/dev/null"],
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const info = await sandbox.getInfo();
|
|
178
|
+
|
|
179
|
+
const entry: CachedEntry = {
|
|
180
|
+
sandbox,
|
|
181
|
+
sandboxId: info.id,
|
|
182
|
+
createdAt: info.createdAt ? new Date(info.createdAt).toISOString() : new Date().toISOString(),
|
|
183
|
+
lastUsedAt: Date.now(),
|
|
184
|
+
};
|
|
185
|
+
this.cache.set(name, entry);
|
|
186
|
+
return entry;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private wrapError(err: unknown, code: string, message: string): never {
|
|
190
|
+
if (err instanceof HttpError) throw err;
|
|
191
|
+
if (err instanceof SandboxException) {
|
|
192
|
+
throw new HttpError(
|
|
193
|
+
err.error.code === "NOT_FOUND" ? 404
|
|
194
|
+
: err.error.code === "FORBIDDEN" ? 403
|
|
195
|
+
: err.error.code === "CONFLICT" ? 409
|
|
196
|
+
: err.error.code === "INVALID_REQUEST" ? 400
|
|
197
|
+
: 500,
|
|
198
|
+
err.error.code,
|
|
199
|
+
err.error.message ?? message
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
throw new HttpError(500, code, message);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async startSandbox(name: string): Promise<{ name: string; status: string }> {
|
|
206
|
+
const entry = this.getCached(name);
|
|
207
|
+
try {
|
|
208
|
+
const resumed = await Sandbox.resume({
|
|
209
|
+
sandboxId: entry.sandboxId,
|
|
210
|
+
connectionConfig: this.connectionConfig,
|
|
211
|
+
});
|
|
212
|
+
// Update cache with new Sandbox instance (sandboxId may change on K8s)
|
|
213
|
+
const info = await resumed.getInfo();
|
|
214
|
+
entry.sandbox = resumed;
|
|
215
|
+
entry.sandboxId = info.id;
|
|
216
|
+
entry.lastUsedAt = Date.now();
|
|
217
|
+
return { name, status: "running" };
|
|
218
|
+
} catch (err) {
|
|
219
|
+
throw this.wrapError(err, "SANDBOX_NOT_FOUND", `Failed to start sandbox '${name}'`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async stopSandbox(name: string): Promise<{ name: string; status: string }> {
|
|
224
|
+
const entry = this.getCached(name);
|
|
225
|
+
try {
|
|
226
|
+
await entry.sandbox.pause();
|
|
227
|
+
return { name, status: "stopped" };
|
|
228
|
+
} catch (err) {
|
|
229
|
+
throw this.wrapError(err, "INTERNAL_ERROR", `Failed to stop sandbox '${name}'`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async killSandbox(name: string): Promise<{ name: string; status: string }> {
|
|
234
|
+
const entry = this.cache.get(name);
|
|
235
|
+
if (!entry) {
|
|
236
|
+
throw new HttpError(404, "SANDBOX_NOT_FOUND", `Sandbox '${name}' not found`);
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
await entry.sandbox.kill();
|
|
240
|
+
} catch {
|
|
241
|
+
// kill failure is non-fatal
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
await entry.sandbox.close();
|
|
245
|
+
} catch {
|
|
246
|
+
// close failure is non-fatal
|
|
247
|
+
}
|
|
248
|
+
this.cache.delete(name);
|
|
249
|
+
return { name, status: "unknown" };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async deleteSandbox(name: string): Promise<{ name: string; status: string }> {
|
|
253
|
+
return this.killSandbox(name);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async getStatus(name: string): Promise<{ name: string; status: string }> {
|
|
257
|
+
const entry = this.cache.get(name);
|
|
258
|
+
if (entry) {
|
|
259
|
+
try {
|
|
260
|
+
const info = await entry.sandbox.getInfo();
|
|
261
|
+
const status = info.status.state.toLowerCase();
|
|
262
|
+
return { name, status };
|
|
263
|
+
} catch {
|
|
264
|
+
return { name, status: "unknown" };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Try server-side lookup
|
|
268
|
+
try {
|
|
269
|
+
const manager = SandboxManager.create({ connectionConfig: this.connectionConfig });
|
|
270
|
+
const result = await manager.listSandboxInfos({
|
|
271
|
+
metadata: { [NAME_METADATA_KEY]: name },
|
|
272
|
+
pageSize: 1,
|
|
273
|
+
});
|
|
274
|
+
if (result.items.length > 0) {
|
|
275
|
+
return { name, status: result.items[0].status.state.toLowerCase() };
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
// ignore
|
|
279
|
+
}
|
|
280
|
+
return { name, status: "unknown" };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private resolvePath(path: string): string {
|
|
284
|
+
if (path === "~" || path === "~/") return "/";
|
|
285
|
+
if (path.startsWith("~/")) return `/${path.slice(2)}`;
|
|
286
|
+
return path;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async readFile(sandboxName: string, path: string): Promise<{ path: string; content: string }> {
|
|
290
|
+
const resolvedPath = this.resolvePath(path);
|
|
291
|
+
const entry = this.getCached(sandboxName);
|
|
292
|
+
try {
|
|
293
|
+
const content = await entry.sandbox.files.readFile(resolvedPath);
|
|
294
|
+
return { path: resolvedPath, content };
|
|
295
|
+
} catch (err) {
|
|
296
|
+
throw this.wrapError(err, "INTERNAL_ERROR", `Failed to read file '${resolvedPath}' in sandbox '${sandboxName}'`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async writeFile(sandboxName: string, path: string, content: string): Promise<{ path: string }> {
|
|
301
|
+
const resolvedPath = this.resolvePath(path);
|
|
302
|
+
const entry = this.getCached(sandboxName);
|
|
303
|
+
try {
|
|
304
|
+
// Create parent directories if needed
|
|
305
|
+
const parentDir = resolvedPath.split("/").slice(0, -1).join("/") || "/";
|
|
306
|
+
await entry.sandbox.files.createDirectories([{ path: parentDir }]).catch(() => {
|
|
307
|
+
// parent may already exist
|
|
308
|
+
});
|
|
309
|
+
await entry.sandbox.files.writeFiles([{ path: resolvedPath, data: content }]);
|
|
310
|
+
return { path: resolvedPath };
|
|
311
|
+
} catch (err) {
|
|
312
|
+
throw this.wrapError(err, "INTERNAL_ERROR", `Failed to write file '${resolvedPath}' in sandbox '${sandboxName}'`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async listPath(
|
|
317
|
+
sandboxName: string,
|
|
318
|
+
path: string,
|
|
319
|
+
recursive?: boolean
|
|
320
|
+
): Promise<{ entries: Array<{ path: string; type: string }> }> {
|
|
321
|
+
const resolvedPath = this.resolvePath(path);
|
|
322
|
+
const entry = this.getCached(sandboxName);
|
|
323
|
+
try {
|
|
324
|
+
const files = await entry.sandbox.files.listDirectory({
|
|
325
|
+
path: resolvedPath,
|
|
326
|
+
depth: recursive ? undefined : 1,
|
|
327
|
+
});
|
|
328
|
+
return {
|
|
329
|
+
entries: files.map((f) => ({
|
|
330
|
+
path: f.path,
|
|
331
|
+
type: f.isDir === true ? "directory" : f.isSymlink === true ? "symlink" : "file",
|
|
332
|
+
})),
|
|
333
|
+
};
|
|
334
|
+
} catch (err) {
|
|
335
|
+
throw this.wrapError(err, "INTERNAL_ERROR", `Failed to list path '${resolvedPath}' in sandbox '${sandboxName}'`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async findFiles(sandboxName: string, path: string, pattern: string): Promise<{ files: string[] }> {
|
|
340
|
+
const resolvedPath = this.resolvePath(path);
|
|
341
|
+
const entry = this.getCached(sandboxName);
|
|
342
|
+
try {
|
|
343
|
+
const results = await entry.sandbox.files.search({
|
|
344
|
+
path: resolvedPath,
|
|
345
|
+
pattern: pattern || "*",
|
|
346
|
+
});
|
|
347
|
+
return { files: results.map((r) => r.path) };
|
|
348
|
+
} catch (err) {
|
|
349
|
+
// Fallback to shell find command
|
|
350
|
+
try {
|
|
351
|
+
const result = await entry.sandbox.commands.run(
|
|
352
|
+
`find ${resolvedPath} -name '${pattern}' -type f`,
|
|
353
|
+
{ timeoutSeconds: 30 }
|
|
354
|
+
);
|
|
355
|
+
return { files: result.logs.stdout.map((s) => s.text).join("").split("\n").filter(Boolean) };
|
|
356
|
+
} catch {
|
|
357
|
+
throw this.wrapError(err, "INTERNAL_ERROR", `Failed to find files in '${resolvedPath}'`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async searchInFile(
|
|
363
|
+
sandboxName: string,
|
|
364
|
+
path: string,
|
|
365
|
+
query: string
|
|
366
|
+
): Promise<{ matches: Array<{ line: number; content: string }> }> {
|
|
367
|
+
const resolvedPath = this.resolvePath(path);
|
|
368
|
+
const entry = this.getCached(sandboxName);
|
|
369
|
+
try {
|
|
370
|
+
const result = await entry.sandbox.commands.run(
|
|
371
|
+
`grep -n -E '${query.replace(/'/g, "'\\''")}' ${resolvedPath}`,
|
|
372
|
+
{ timeoutSeconds: 30 }
|
|
373
|
+
);
|
|
374
|
+
const stdout = result.logs.stdout.map((s) => s.text).join("");
|
|
375
|
+
if (!stdout.trim()) return { matches: [] };
|
|
376
|
+
return {
|
|
377
|
+
matches: stdout.split("\n").filter(Boolean).map((line) => {
|
|
378
|
+
const idx = line.indexOf(":");
|
|
379
|
+
return { line: Number(line.slice(0, idx)), content: line.slice(idx + 1) };
|
|
380
|
+
}),
|
|
381
|
+
};
|
|
382
|
+
} catch (err: unknown) {
|
|
383
|
+
// grep returns exit code 1 for no matches — may throw or not
|
|
384
|
+
if (err instanceof SandboxException) return { matches: [] };
|
|
385
|
+
if (typeof err === "object" && err !== null && "code" in err && (err as { code: number }).code === 1) {
|
|
386
|
+
return { matches: [] };
|
|
387
|
+
}
|
|
388
|
+
return { matches: [] };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async replaceInFile(
|
|
393
|
+
sandboxName: string,
|
|
394
|
+
input: { path: string; search: string; replace: string }
|
|
395
|
+
): Promise<{ replaced: number }> {
|
|
396
|
+
const resolvedPath = this.resolvePath(input.path);
|
|
397
|
+
const entry = this.getCached(sandboxName);
|
|
398
|
+
try {
|
|
399
|
+
const original = await entry.sandbox.files.readFile(resolvedPath);
|
|
400
|
+
if (!input.search) return { replaced: 0 };
|
|
401
|
+
const occurrences = original.split(input.search).length - 1;
|
|
402
|
+
if (occurrences === 0) return { replaced: 0 };
|
|
403
|
+
const updated = original.split(input.search).join(input.replace);
|
|
404
|
+
await entry.sandbox.files.writeFiles([{ path: resolvedPath, data: updated }]);
|
|
405
|
+
return { replaced: occurrences };
|
|
406
|
+
} catch (err) {
|
|
407
|
+
throw this.wrapError(err, "INTERNAL_ERROR", `Failed to replace in file '${resolvedPath}'`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async uploadFile(sandboxName: string, path: string, contentBase64: string): Promise<{ path: string }> {
|
|
412
|
+
const resolvedPath = this.resolvePath(path);
|
|
413
|
+
const entry = this.getCached(sandboxName);
|
|
414
|
+
try {
|
|
415
|
+
const data = Buffer.from(contentBase64, "base64").toString("utf-8");
|
|
416
|
+
await entry.sandbox.files.writeFiles([{ path: resolvedPath, data }]);
|
|
417
|
+
return { path: resolvedPath };
|
|
418
|
+
} catch (err) {
|
|
419
|
+
throw this.wrapError(err, "INTERNAL_ERROR", `Failed to upload file '${resolvedPath}'`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async downloadFile(sandboxName: string, path: string): Promise<{ path: string; contentBase64: string }> {
|
|
424
|
+
const resolvedPath = this.resolvePath(path);
|
|
425
|
+
const entry = this.getCached(sandboxName);
|
|
426
|
+
try {
|
|
427
|
+
const data = await entry.sandbox.files.readBytes(resolvedPath);
|
|
428
|
+
const b64 = Buffer.from(data).toString("base64");
|
|
429
|
+
return { path: resolvedPath, contentBase64: b64 };
|
|
430
|
+
} catch (err) {
|
|
431
|
+
throw this.wrapError(err, "INTERNAL_ERROR", `Failed to download file '${resolvedPath}'`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async execCommand(input: ShellExecInput): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
436
|
+
const entry = this.getCached(input.sandboxName);
|
|
437
|
+
try {
|
|
438
|
+
const result = await entry.sandbox.commands.run(input.command, {
|
|
439
|
+
workingDirectory: input.exec_dir,
|
|
440
|
+
timeoutSeconds: input.timeout ? Math.ceil(input.timeout / 1000) : undefined,
|
|
441
|
+
});
|
|
442
|
+
return {
|
|
443
|
+
stdout: result.logs.stdout.map((s) => s.text).join(""),
|
|
444
|
+
stderr: result.logs.stderr.map((s) => s.text).join(""),
|
|
445
|
+
exitCode: result.exitCode ?? 0,
|
|
446
|
+
};
|
|
447
|
+
} catch (err) {
|
|
448
|
+
throw this.wrapError(err, "INTERNAL_ERROR", `Command execution failed in sandbox '${input.sandboxName}'`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async getSandbox(
|
|
453
|
+
name: string
|
|
454
|
+
): Promise<{
|
|
455
|
+
name: string;
|
|
456
|
+
status: string;
|
|
457
|
+
image: unknown;
|
|
458
|
+
cpus?: number;
|
|
459
|
+
memoryMib?: number;
|
|
460
|
+
env: unknown;
|
|
461
|
+
volumes: Record<string, unknown>;
|
|
462
|
+
metrics?: SandboxRuntimeMetrics;
|
|
463
|
+
createdAt: string;
|
|
464
|
+
updatedAt: string;
|
|
465
|
+
} | undefined> {
|
|
466
|
+
// Try cache first
|
|
467
|
+
const entry = this.cache.get(name);
|
|
468
|
+
if (entry) {
|
|
469
|
+
try {
|
|
470
|
+
const info = await entry.sandbox.getInfo();
|
|
471
|
+
return {
|
|
472
|
+
name,
|
|
473
|
+
status: info.status.state.toLowerCase(),
|
|
474
|
+
image: info.image?.uri ?? "unknown",
|
|
475
|
+
cpus: undefined,
|
|
476
|
+
memoryMib: undefined,
|
|
477
|
+
env: {},
|
|
478
|
+
volumes: {},
|
|
479
|
+
metrics: undefined,
|
|
480
|
+
createdAt: info.createdAt ? new Date(info.createdAt).toISOString() : entry.createdAt,
|
|
481
|
+
updatedAt: info.createdAt ? new Date(info.createdAt).toISOString() : entry.createdAt,
|
|
482
|
+
};
|
|
483
|
+
} catch {
|
|
484
|
+
this.cache.delete(name);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Try server-side lookup via metadata
|
|
489
|
+
try {
|
|
490
|
+
const manager = SandboxManager.create({ connectionConfig: this.connectionConfig });
|
|
491
|
+
const result = await manager.listSandboxInfos({
|
|
492
|
+
metadata: { [NAME_METADATA_KEY]: name },
|
|
493
|
+
pageSize: 1,
|
|
494
|
+
});
|
|
495
|
+
if (result.items.length > 0) {
|
|
496
|
+
const info = result.items[0];
|
|
497
|
+
return {
|
|
498
|
+
name,
|
|
499
|
+
status: info.status.state.toLowerCase(),
|
|
500
|
+
image: info.image?.uri ?? "unknown",
|
|
501
|
+
cpus: undefined,
|
|
502
|
+
memoryMib: undefined,
|
|
503
|
+
env: {},
|
|
504
|
+
volumes: {},
|
|
505
|
+
metrics: undefined,
|
|
506
|
+
createdAt: info.createdAt ? new Date(info.createdAt).toISOString() : "",
|
|
507
|
+
updatedAt: info.createdAt ? new Date(info.createdAt).toISOString() : "",
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
} catch {
|
|
511
|
+
// ignore
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return undefined;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async listSandboxes(_query: ListSandboxesQuery): Promise<{
|
|
518
|
+
items: Array<{
|
|
519
|
+
name: string;
|
|
520
|
+
status: string;
|
|
521
|
+
image: unknown;
|
|
522
|
+
cpus?: number;
|
|
523
|
+
memoryMib?: number;
|
|
524
|
+
envCount: number;
|
|
525
|
+
volumeCount: number;
|
|
526
|
+
createdAt: string;
|
|
527
|
+
updatedAt: string;
|
|
528
|
+
}>;
|
|
529
|
+
total: number;
|
|
530
|
+
}> {
|
|
531
|
+
try {
|
|
532
|
+
const manager = SandboxManager.create({ connectionConfig: this.connectionConfig });
|
|
533
|
+
const filter: SandboxFilter = {};
|
|
534
|
+
if (_query.status) filter.states = [_query.status];
|
|
535
|
+
const result = await manager.listSandboxInfos(filter);
|
|
536
|
+
|
|
537
|
+
const items = result.items
|
|
538
|
+
.filter((info) => {
|
|
539
|
+
if (_query.search) {
|
|
540
|
+
const haystack = [info.id, info.image?.uri].filter(Boolean).join(" ").toLowerCase();
|
|
541
|
+
return haystack.includes(_query.search.toLowerCase());
|
|
542
|
+
}
|
|
543
|
+
return true;
|
|
544
|
+
})
|
|
545
|
+
.map((info) => ({
|
|
546
|
+
name: info.id,
|
|
547
|
+
status: info.status.state.toLowerCase(),
|
|
548
|
+
image: info.image?.uri ?? "unknown",
|
|
549
|
+
cpus: undefined,
|
|
550
|
+
memoryMib: undefined,
|
|
551
|
+
envCount: 0,
|
|
552
|
+
volumeCount: 0,
|
|
553
|
+
createdAt: info.createdAt ? new Date(info.createdAt).toISOString() : "",
|
|
554
|
+
updatedAt: info.createdAt ? new Date(info.createdAt).toISOString() : "",
|
|
555
|
+
}));
|
|
556
|
+
|
|
557
|
+
return { items, total: items.length };
|
|
558
|
+
} catch {
|
|
559
|
+
return { items: [], total: 0 };
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async getSandboxLogs(
|
|
564
|
+
_name: string,
|
|
565
|
+
_opts: SandboxLogsInput
|
|
566
|
+
): Promise<{
|
|
567
|
+
entries: Array<{
|
|
568
|
+
timestamp: string;
|
|
569
|
+
source: string;
|
|
570
|
+
sessionId: number | null;
|
|
571
|
+
text: string;
|
|
572
|
+
}>;
|
|
573
|
+
}> {
|
|
574
|
+
return { entries: [] };
|
|
575
|
+
}
|
|
576
|
+
}
|
package/src/swagger.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { FastifyInstance } from "fastify";
|
|
2
|
+
import swagger from "@fastify/swagger";
|
|
3
|
+
import swaggerUi from "@fastify/swagger-ui";
|
|
4
|
+
|
|
5
|
+
const swaggerConfig = {
|
|
6
|
+
openapi: {
|
|
7
|
+
openapi: "3.0.0",
|
|
8
|
+
info: {
|
|
9
|
+
title: "OpenSandbox Gateway API",
|
|
10
|
+
description: "Sandbox lifecycle management backed by OpenSandbox",
|
|
11
|
+
version: "1.0.0",
|
|
12
|
+
},
|
|
13
|
+
servers: [{ url: "http://localhost:4002", description: "Local development" }],
|
|
14
|
+
components: {
|
|
15
|
+
securitySchemes: {
|
|
16
|
+
bearerAuth: { type: "http" as const, scheme: "bearer" as const, bearerFormat: "API Key" },
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
tags: [
|
|
20
|
+
{ name: "Sandboxes", description: "Sandbox lifecycle" },
|
|
21
|
+
{ name: "Files", description: "File operations inside sandboxes" },
|
|
22
|
+
{ name: "Shell", description: "Execute commands inside sandboxes" },
|
|
23
|
+
{ name: "Images", description: "Image management (stub)" },
|
|
24
|
+
{ name: "Volumes", description: "Named volume filesystem operations" },
|
|
25
|
+
{ name: "Health", description: "Service health check" },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const swaggerUiConfig = {
|
|
31
|
+
routePrefix: "/api-docs",
|
|
32
|
+
uiConfig: { docExpansion: "list" as const, deepLinking: true },
|
|
33
|
+
staticCSP: true,
|
|
34
|
+
transformStaticCSP: (header: string) => header,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export async function configureSwagger(app: FastifyInstance) {
|
|
38
|
+
await app.register(swagger, swaggerConfig);
|
|
39
|
+
await app.register(swaggerUi, swaggerUiConfig);
|
|
40
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { EnsureSandboxInput, ListSandboxesQuery, ShellExecInput } from "../schemas/sandbox";
|
|
2
|
+
|
|
3
|
+
export type SandboxRuntimeMetrics = {
|
|
4
|
+
cpuPercent: number;
|
|
5
|
+
memoryBytes: number;
|
|
6
|
+
memoryLimitBytes: number;
|
|
7
|
+
diskReadBytes: number;
|
|
8
|
+
diskWriteBytes: number;
|
|
9
|
+
netRxBytes: number;
|
|
10
|
+
netTxBytes: number;
|
|
11
|
+
uptimeMs: number;
|
|
12
|
+
timestampMs: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface RuntimeService {
|
|
16
|
+
ensureSandbox(name: string, input: EnsureSandboxInput): Promise<{ name: string; status: string }>;
|
|
17
|
+
startSandbox(name: string): Promise<{ name: string; status: string }>;
|
|
18
|
+
stopSandbox(name: string): Promise<{ name: string; status: string }>;
|
|
19
|
+
killSandbox(name: string): Promise<{ name: string; status: string }>;
|
|
20
|
+
deleteSandbox(name: string): Promise<{ name: string; status: string }>;
|
|
21
|
+
listSandboxes(query: ListSandboxesQuery): Promise<{
|
|
22
|
+
items: Array<{
|
|
23
|
+
name: string;
|
|
24
|
+
status: string;
|
|
25
|
+
image: unknown;
|
|
26
|
+
cpus?: number;
|
|
27
|
+
memoryMib?: number;
|
|
28
|
+
envCount: number;
|
|
29
|
+
volumeCount: number;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
updatedAt: string;
|
|
32
|
+
}>;
|
|
33
|
+
total: number;
|
|
34
|
+
}>;
|
|
35
|
+
getSandbox(name: string): Promise<{
|
|
36
|
+
name: string;
|
|
37
|
+
status: string;
|
|
38
|
+
image: unknown;
|
|
39
|
+
cpus?: number;
|
|
40
|
+
memoryMib?: number;
|
|
41
|
+
env: unknown;
|
|
42
|
+
volumes: Record<string, unknown>;
|
|
43
|
+
metrics?: SandboxRuntimeMetrics;
|
|
44
|
+
createdAt: string;
|
|
45
|
+
updatedAt: string;
|
|
46
|
+
} | undefined>;
|
|
47
|
+
getStatus(name: string): Promise<{ name: string; status: string }>;
|
|
48
|
+
readFile(sandboxName: string, path: string): Promise<{ path: string; content: string }>;
|
|
49
|
+
writeFile(sandboxName: string, path: string, content: string): Promise<{ path: string }>;
|
|
50
|
+
listPath(
|
|
51
|
+
sandboxName: string,
|
|
52
|
+
path: string,
|
|
53
|
+
recursive?: boolean
|
|
54
|
+
): Promise<{ entries: Array<{ path: string; type: string }> }>;
|
|
55
|
+
findFiles(sandboxName: string, path: string, pattern: string): Promise<{ files: string[] }>;
|
|
56
|
+
searchInFile(
|
|
57
|
+
sandboxName: string,
|
|
58
|
+
path: string,
|
|
59
|
+
query: string
|
|
60
|
+
): Promise<{ matches: Array<{ line: number; content: string }> }>;
|
|
61
|
+
replaceInFile(
|
|
62
|
+
sandboxName: string,
|
|
63
|
+
input: { path: string; search: string; replace: string }
|
|
64
|
+
): Promise<{ replaced: number }>;
|
|
65
|
+
uploadFile(sandboxName: string, path: string, contentBase64: string): Promise<{ path: string }>;
|
|
66
|
+
downloadFile(sandboxName: string, path: string): Promise<{ path: string; contentBase64: string }>;
|
|
67
|
+
execCommand(input: ShellExecInput): Promise<{ stdout: string; stderr: string; exitCode: number }>;
|
|
68
|
+
getSandboxLogs(
|
|
69
|
+
name: string,
|
|
70
|
+
opts: { tail?: number; since?: string; until?: string; sources?: string[] }
|
|
71
|
+
): Promise<{
|
|
72
|
+
entries: Array<{
|
|
73
|
+
timestamp: string;
|
|
74
|
+
source: string;
|
|
75
|
+
sessionId: number | null;
|
|
76
|
+
text: string;
|
|
77
|
+
}>;
|
|
78
|
+
}>;
|
|
79
|
+
}
|