openlattice-modal 0.0.3
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/dist/config.d.ts +8 -0
- package/dist/config.js +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/modal-provider.d.ts +26 -0
- package/dist/modal-provider.js +363 -0
- package/package.json +37 -0
- package/src/config.ts +8 -0
- package/src/index.ts +2 -0
- package/src/modal-provider.ts +435 -0
- package/tests/conformance.test.ts +24 -0
- package/tests/integration.test.ts +131 -0
- package/tests/modal-provider.test.ts +461 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComputeProvider,
|
|
3
|
+
ComputeSpec,
|
|
4
|
+
ExecOpts,
|
|
5
|
+
ExecResult,
|
|
6
|
+
ExtensionMap,
|
|
7
|
+
FileEntry,
|
|
8
|
+
FileExtension,
|
|
9
|
+
HealthStatus,
|
|
10
|
+
NetworkExtension,
|
|
11
|
+
ProviderCapabilities,
|
|
12
|
+
ProviderNode,
|
|
13
|
+
ProviderNodeStatus,
|
|
14
|
+
SnapshotRef,
|
|
15
|
+
} from "openlattice";
|
|
16
|
+
import type { ModalProviderConfig } from "./config";
|
|
17
|
+
|
|
18
|
+
// Modal SDK types — we import dynamically to avoid ESM/CJS issues
|
|
19
|
+
// and to allow mocking in tests.
|
|
20
|
+
interface ModalClient {
|
|
21
|
+
apps: { fromName(name: string, opts?: { createIfMissing?: boolean }): Promise<any> };
|
|
22
|
+
images: { fromRegistry(ref: string): any };
|
|
23
|
+
sandboxes: {
|
|
24
|
+
create(app: any, image: any, opts?: Record<string, unknown>): Promise<ModalSandbox>;
|
|
25
|
+
fromId(id: string): Promise<ModalSandbox>;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ModalSandbox {
|
|
30
|
+
sandboxId: string;
|
|
31
|
+
exec(args: string[], opts?: Record<string, unknown>): Promise<ModalProcess>;
|
|
32
|
+
snapshotFilesystem(): Promise<{ imageId: string }>;
|
|
33
|
+
terminate(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ModalProcess {
|
|
37
|
+
stdout: { readText(): Promise<string> };
|
|
38
|
+
stderr: { readText(): Promise<string> };
|
|
39
|
+
wait(): Promise<number>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class ModalProvider implements ComputeProvider {
|
|
43
|
+
readonly name = "modal";
|
|
44
|
+
readonly capabilities: ProviderCapabilities = {
|
|
45
|
+
restart: false,
|
|
46
|
+
pause: false,
|
|
47
|
+
snapshot: true,
|
|
48
|
+
gpu: true,
|
|
49
|
+
logs: false,
|
|
50
|
+
tailscale: true, // userspace networking mode, no special perms needed
|
|
51
|
+
coldStartMs: 2000,
|
|
52
|
+
maxConcurrent: 0,
|
|
53
|
+
architectures: ["x86_64"],
|
|
54
|
+
persistentStorage: false,
|
|
55
|
+
constraints: {
|
|
56
|
+
maxLifetimeSeconds: 86400,
|
|
57
|
+
gpuTypes: ["T4", "A10G", "A100", "H100", "H200", "L4", "L40S", "B200"],
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
private readonly config: ModalProviderConfig;
|
|
62
|
+
private client: ModalClient | null = null;
|
|
63
|
+
private app: any = null;
|
|
64
|
+
private readonly sandboxes = new Map<string, ModalSandbox>();
|
|
65
|
+
|
|
66
|
+
constructor(config: ModalProviderConfig = {}) {
|
|
67
|
+
this.config = config;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Required methods ────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
async provision(spec: ComputeSpec): Promise<ProviderNode> {
|
|
73
|
+
const client = await this.getClient();
|
|
74
|
+
const app = await this.getApp(client);
|
|
75
|
+
|
|
76
|
+
const image = this.resolveImage(client, spec.runtime.image);
|
|
77
|
+
|
|
78
|
+
const createOpts: Record<string, unknown> = {};
|
|
79
|
+
if (spec.gpu?.type) {
|
|
80
|
+
createOpts.gpu = spec.gpu.type;
|
|
81
|
+
} else if (spec.gpu?.count) {
|
|
82
|
+
createOpts.gpu = "T4"; // default GPU
|
|
83
|
+
}
|
|
84
|
+
if (spec.duration?.maxSeconds) {
|
|
85
|
+
createOpts.timeout = spec.duration.maxSeconds;
|
|
86
|
+
}
|
|
87
|
+
if (spec.runtime.workdir) {
|
|
88
|
+
createOpts.workdir = spec.runtime.workdir;
|
|
89
|
+
}
|
|
90
|
+
if (spec.runtime.env) {
|
|
91
|
+
createOpts.environmentVariables = spec.runtime.env;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const sandbox = await client.sandboxes.create(app, image, createOpts);
|
|
95
|
+
this.sandboxes.set(sandbox.sandboxId, sandbox);
|
|
96
|
+
|
|
97
|
+
// Run initial command if specified
|
|
98
|
+
if (spec.runtime.command && spec.runtime.command.length > 0) {
|
|
99
|
+
await sandbox.exec(spec.runtime.command, {
|
|
100
|
+
stdout: "pipe",
|
|
101
|
+
stderr: "pipe",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Join Tailscale network if auth key provided
|
|
106
|
+
if (spec.network?.tailscaleAuthKey) {
|
|
107
|
+
await this.tailscaleUp(sandbox, spec.network.tailscaleAuthKey);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
externalId: sandbox.sandboxId,
|
|
112
|
+
endpoints: [],
|
|
113
|
+
metadata: { appName: this.config.appName ?? "openlattice" },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async exec(
|
|
118
|
+
externalId: string,
|
|
119
|
+
command: string[],
|
|
120
|
+
opts?: ExecOpts
|
|
121
|
+
): Promise<ExecResult> {
|
|
122
|
+
const sandbox = await this.getSandbox(externalId);
|
|
123
|
+
|
|
124
|
+
// Build command with cwd and env support
|
|
125
|
+
let cmd = command;
|
|
126
|
+
if (opts?.cwd || opts?.env) {
|
|
127
|
+
const parts: string[] = [];
|
|
128
|
+
if (opts.cwd) {
|
|
129
|
+
parts.push(`cd ${opts.cwd}`);
|
|
130
|
+
}
|
|
131
|
+
if (opts.env) {
|
|
132
|
+
for (const [k, v] of Object.entries(opts.env)) {
|
|
133
|
+
parts.push(`export ${k}='${v.replace(/'/g, "'\\''")}'`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
parts.push(command.join(" "));
|
|
137
|
+
cmd = ["sh", "-c", parts.join(" && ")];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const execOpts: Record<string, unknown> = {
|
|
141
|
+
stdout: "pipe",
|
|
142
|
+
stderr: "pipe",
|
|
143
|
+
};
|
|
144
|
+
if (opts?.timeoutMs) {
|
|
145
|
+
execOpts.timeout = Math.floor(opts.timeoutMs / 1000);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const proc = await sandbox.exec(cmd, execOpts);
|
|
149
|
+
|
|
150
|
+
const [stdout, stderr] = await Promise.all([
|
|
151
|
+
proc.stdout.readText(),
|
|
152
|
+
proc.stderr.readText(),
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
const exitCode = await proc.wait();
|
|
156
|
+
|
|
157
|
+
opts?.onStdout?.(stdout);
|
|
158
|
+
opts?.onStderr?.(stderr);
|
|
159
|
+
|
|
160
|
+
return { exitCode, stdout, stderr };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async destroy(externalId: string): Promise<void> {
|
|
164
|
+
const sandbox = this.sandboxes.get(externalId);
|
|
165
|
+
if (!sandbox) {
|
|
166
|
+
// Try to reconnect and terminate
|
|
167
|
+
try {
|
|
168
|
+
const client = await this.getClient();
|
|
169
|
+
const sb = await client.sandboxes.fromId(externalId);
|
|
170
|
+
await sb.terminate();
|
|
171
|
+
} catch {
|
|
172
|
+
// Already terminated or not found — idempotent
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await sandbox.terminate();
|
|
179
|
+
} catch {
|
|
180
|
+
// Already terminated — idempotent
|
|
181
|
+
}
|
|
182
|
+
this.sandboxes.delete(externalId);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async inspect(externalId: string): Promise<ProviderNodeStatus> {
|
|
186
|
+
try {
|
|
187
|
+
const client = await this.getClient();
|
|
188
|
+
const sandbox = await client.sandboxes.fromId(externalId);
|
|
189
|
+
|
|
190
|
+
// If we can reconnect, it's running
|
|
191
|
+
this.sandboxes.set(externalId, sandbox);
|
|
192
|
+
return { status: "running" };
|
|
193
|
+
} catch {
|
|
194
|
+
this.sandboxes.delete(externalId);
|
|
195
|
+
return { status: "terminated" };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Optional: snapshot / restore ──────────────────────────────
|
|
200
|
+
|
|
201
|
+
async snapshot(externalId: string): Promise<SnapshotRef> {
|
|
202
|
+
const sandbox = await this.getSandbox(externalId);
|
|
203
|
+
const image = await sandbox.snapshotFilesystem();
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
provider: "modal",
|
|
207
|
+
externalId: image.imageId,
|
|
208
|
+
createdAt: new Date(),
|
|
209
|
+
type: "filesystem",
|
|
210
|
+
metadata: { originalSandboxId: externalId },
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async restore(
|
|
215
|
+
snapshotRef: SnapshotRef,
|
|
216
|
+
spec?: Partial<ComputeSpec>
|
|
217
|
+
): Promise<ProviderNode> {
|
|
218
|
+
const client = await this.getClient();
|
|
219
|
+
const app = await this.getApp(client);
|
|
220
|
+
|
|
221
|
+
// The snapshotRef.externalId is the Modal image ID
|
|
222
|
+
const image = { imageId: snapshotRef.externalId };
|
|
223
|
+
|
|
224
|
+
const createOpts: Record<string, unknown> = {};
|
|
225
|
+
if (spec?.gpu?.type) {
|
|
226
|
+
createOpts.gpu = spec.gpu.type;
|
|
227
|
+
}
|
|
228
|
+
if (spec?.duration?.maxSeconds) {
|
|
229
|
+
createOpts.timeout = spec.duration.maxSeconds;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const sandbox = await client.sandboxes.create(app, image, createOpts);
|
|
233
|
+
this.sandboxes.set(sandbox.sandboxId, sandbox);
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
externalId: sandbox.sandboxId,
|
|
237
|
+
endpoints: [],
|
|
238
|
+
metadata: {
|
|
239
|
+
appName: this.config.appName ?? "openlattice",
|
|
240
|
+
restoredFrom: snapshotRef.externalId,
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── Optional: healthCheck ─────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
async healthCheck(): Promise<HealthStatus> {
|
|
248
|
+
const start = Date.now();
|
|
249
|
+
try {
|
|
250
|
+
const client = await this.getClient();
|
|
251
|
+
await client.apps.fromName(this.config.appName ?? "openlattice", {
|
|
252
|
+
createIfMissing: true,
|
|
253
|
+
});
|
|
254
|
+
return {
|
|
255
|
+
healthy: true,
|
|
256
|
+
latencyMs: Date.now() - start,
|
|
257
|
+
};
|
|
258
|
+
} catch (err: unknown) {
|
|
259
|
+
return {
|
|
260
|
+
healthy: false,
|
|
261
|
+
latencyMs: Date.now() - start,
|
|
262
|
+
message: `[modal] service unreachable: ${err instanceof Error ? err.message : String(err)}`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Optional: extensions ──────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
getExtension<K extends keyof ExtensionMap>(
|
|
270
|
+
externalId: string,
|
|
271
|
+
extension: K
|
|
272
|
+
): ExtensionMap[K] | undefined {
|
|
273
|
+
if (extension === "files") {
|
|
274
|
+
return this.createFileExtension(externalId) as ExtensionMap[K];
|
|
275
|
+
}
|
|
276
|
+
if (extension === "network") {
|
|
277
|
+
return this.createNetworkExtension(externalId) as ExtensionMap[K];
|
|
278
|
+
}
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── Private helpers ───────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
private async tailscaleUp(
|
|
285
|
+
sandbox: ModalSandbox,
|
|
286
|
+
authKey: string
|
|
287
|
+
): Promise<void> {
|
|
288
|
+
const proc = await sandbox.exec(
|
|
289
|
+
[
|
|
290
|
+
"sh",
|
|
291
|
+
"-c",
|
|
292
|
+
`pgrep tailscaled >/dev/null 2>&1 || tailscaled --state=/var/lib/tailscale/tailscaled.state & sleep 1 && tailscale up --authkey=${authKey}`,
|
|
293
|
+
],
|
|
294
|
+
{ stdout: "pipe", stderr: "pipe" }
|
|
295
|
+
);
|
|
296
|
+
const stderr = await proc.stderr.readText();
|
|
297
|
+
const exitCode = await proc.wait();
|
|
298
|
+
if (exitCode !== 0) {
|
|
299
|
+
throw new Error(`[modal] tailscale up failed: ${stderr.trim()}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private async getClient(): Promise<ModalClient> {
|
|
304
|
+
if (this.client) return this.client;
|
|
305
|
+
|
|
306
|
+
// Set Modal credential env vars if provided in config
|
|
307
|
+
if (this.config.tokenId) {
|
|
308
|
+
process.env.MODAL_TOKEN_ID = this.config.tokenId;
|
|
309
|
+
}
|
|
310
|
+
if (this.config.tokenSecret) {
|
|
311
|
+
process.env.MODAL_TOKEN_SECRET = this.config.tokenSecret;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Dynamic import to handle ESM/CJS
|
|
315
|
+
const modal = await import("modal");
|
|
316
|
+
const ModalClientClass = (modal as any).ModalClient ?? (modal as any).default?.ModalClient;
|
|
317
|
+
|
|
318
|
+
if (!ModalClientClass) {
|
|
319
|
+
throw new Error("[modal] could not import ModalClient from modal package");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
this.client = new ModalClientClass() as ModalClient;
|
|
323
|
+
return this.client;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private async getApp(client: ModalClient): Promise<any> {
|
|
327
|
+
if (this.app) return this.app;
|
|
328
|
+
this.app = await client.apps.fromName(
|
|
329
|
+
this.config.appName ?? "openlattice",
|
|
330
|
+
{ createIfMissing: true }
|
|
331
|
+
);
|
|
332
|
+
return this.app;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private resolveImage(client: ModalClient, image: string): any {
|
|
336
|
+
// If it looks like an OCI reference (contains / or :), use fromRegistry
|
|
337
|
+
if (image.includes("/") || image.includes(":")) {
|
|
338
|
+
return client.images.fromRegistry(image);
|
|
339
|
+
}
|
|
340
|
+
// Otherwise assume it's a Modal image ID
|
|
341
|
+
return { imageId: image };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private async getSandbox(externalId: string): Promise<ModalSandbox> {
|
|
345
|
+
const cached = this.sandboxes.get(externalId);
|
|
346
|
+
if (cached) return cached;
|
|
347
|
+
|
|
348
|
+
const client = await this.getClient();
|
|
349
|
+
const sandbox = await client.sandboxes.fromId(externalId);
|
|
350
|
+
this.sandboxes.set(externalId, sandbox);
|
|
351
|
+
return sandbox;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private createFileExtension(externalId: string): FileExtension {
|
|
355
|
+
const provider = this;
|
|
356
|
+
return {
|
|
357
|
+
async read(path: string): Promise<string | Buffer> {
|
|
358
|
+
const sb = await provider.getSandbox(externalId);
|
|
359
|
+
const proc = await sb.exec(["cat", path], { stdout: "pipe", stderr: "pipe" });
|
|
360
|
+
const content = await proc.stdout.readText();
|
|
361
|
+
await proc.wait();
|
|
362
|
+
return content;
|
|
363
|
+
},
|
|
364
|
+
async write(filePath: string, content: string | Buffer): Promise<void> {
|
|
365
|
+
const sb = await provider.getSandbox(externalId);
|
|
366
|
+
const safePath = filePath.replace(/'/g, "'\\''");
|
|
367
|
+
|
|
368
|
+
if (Buffer.isBuffer(content)) {
|
|
369
|
+
// Binary content: base64 encode, decode on remote
|
|
370
|
+
const b64 = content.toString("base64");
|
|
371
|
+
const proc = await sb.exec(
|
|
372
|
+
["sh", "-c", `printf '%s' '${b64}' | base64 -d > '${safePath}'`],
|
|
373
|
+
{ stdout: "pipe", stderr: "pipe" }
|
|
374
|
+
);
|
|
375
|
+
await proc.wait();
|
|
376
|
+
} else {
|
|
377
|
+
// Text content: write via printf with proper escaping
|
|
378
|
+
const escaped = content.replace(/\\/g, "\\\\").replace(/'/g, "'\\''");
|
|
379
|
+
const proc = await sb.exec(
|
|
380
|
+
["sh", "-c", `printf '%s' '${escaped}' > '${safePath}'`],
|
|
381
|
+
{ stdout: "pipe", stderr: "pipe" }
|
|
382
|
+
);
|
|
383
|
+
await proc.wait();
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
async list(dirPath: string): Promise<FileEntry[]> {
|
|
387
|
+
const sb = await provider.getSandbox(externalId);
|
|
388
|
+
const proc = await sb.exec(
|
|
389
|
+
["ls", "-1apL", dirPath],
|
|
390
|
+
{ stdout: "pipe", stderr: "pipe" }
|
|
391
|
+
);
|
|
392
|
+
const output = await proc.stdout.readText();
|
|
393
|
+
await proc.wait();
|
|
394
|
+
return output
|
|
395
|
+
.split("\n")
|
|
396
|
+
.filter((l) => l.length > 0 && l !== "./" && l !== "../")
|
|
397
|
+
.map((name) => {
|
|
398
|
+
const isDir = name.endsWith("/");
|
|
399
|
+
const cleanName = isDir ? name.slice(0, -1) : name;
|
|
400
|
+
return {
|
|
401
|
+
name: cleanName,
|
|
402
|
+
path: `${dirPath}/${cleanName}`.replace(/\/\//g, "/"),
|
|
403
|
+
type: isDir ? ("directory" as const) : ("file" as const),
|
|
404
|
+
};
|
|
405
|
+
});
|
|
406
|
+
},
|
|
407
|
+
async remove(path: string): Promise<void> {
|
|
408
|
+
const sb = await provider.getSandbox(externalId);
|
|
409
|
+
const proc = await sb.exec(["rm", "-rf", path], { stdout: "pipe", stderr: "pipe" });
|
|
410
|
+
await proc.wait();
|
|
411
|
+
},
|
|
412
|
+
async mkdir(path: string): Promise<void> {
|
|
413
|
+
const sb = await provider.getSandbox(externalId);
|
|
414
|
+
const proc = await sb.exec(["mkdir", "-p", path], { stdout: "pipe", stderr: "pipe" });
|
|
415
|
+
await proc.wait();
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private createNetworkExtension(externalId: string): NetworkExtension {
|
|
421
|
+
const provider = this;
|
|
422
|
+
return {
|
|
423
|
+
async getUrl(port: number): Promise<string> {
|
|
424
|
+
const sb = await provider.getSandbox(externalId);
|
|
425
|
+
// Modal provides tunnel URLs via sandbox
|
|
426
|
+
const tunnelUrl = (sb as any).tunnelUrl ?? (sb as any).getTunnelUrl;
|
|
427
|
+
if (typeof tunnelUrl === "function") {
|
|
428
|
+
return tunnelUrl(port);
|
|
429
|
+
}
|
|
430
|
+
// Fallback: construct from sandbox ID
|
|
431
|
+
return `https://${externalId}--${port}.modal.run`;
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { runProviderConformanceTests } from "openlattice/testing";
|
|
3
|
+
import { ModalProvider } from "../src/modal-provider";
|
|
4
|
+
|
|
5
|
+
const HAS_MODAL =
|
|
6
|
+
!!process.env.MODAL_TOKEN_ID && !!process.env.MODAL_TOKEN_SECRET;
|
|
7
|
+
|
|
8
|
+
describe.skipIf(!HAS_MODAL)("ModalProvider conformance", () => {
|
|
9
|
+
runProviderConformanceTests(
|
|
10
|
+
{
|
|
11
|
+
createProvider: () =>
|
|
12
|
+
new ModalProvider({
|
|
13
|
+
tokenId: process.env.MODAL_TOKEN_ID,
|
|
14
|
+
tokenSecret: process.env.MODAL_TOKEN_SECRET,
|
|
15
|
+
appName: process.env.MODAL_APP_NAME ?? "openlattice-test",
|
|
16
|
+
}),
|
|
17
|
+
createSpec: () => ({
|
|
18
|
+
runtime: { image: "python:3.12-slim" },
|
|
19
|
+
}),
|
|
20
|
+
timeoutMs: 120_000,
|
|
21
|
+
},
|
|
22
|
+
{ describe, it, expect, beforeEach, afterEach }
|
|
23
|
+
);
|
|
24
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
|
2
|
+
import { ModalProvider } from "../src/modal-provider";
|
|
3
|
+
|
|
4
|
+
const HAS_MODAL =
|
|
5
|
+
!!process.env.MODAL_TOKEN_ID && !!process.env.MODAL_TOKEN_SECRET;
|
|
6
|
+
|
|
7
|
+
describe.skipIf(!HAS_MODAL)("ModalProvider integration", () => {
|
|
8
|
+
let provider: ModalProvider;
|
|
9
|
+
const toCleanup: string[] = [];
|
|
10
|
+
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
provider = new ModalProvider({
|
|
13
|
+
tokenId: process.env.MODAL_TOKEN_ID,
|
|
14
|
+
tokenSecret: process.env.MODAL_TOKEN_SECRET,
|
|
15
|
+
appName: process.env.MODAL_APP_NAME ?? "openlattice-test",
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
for (const id of toCleanup) {
|
|
21
|
+
try {
|
|
22
|
+
await provider.destroy(id);
|
|
23
|
+
} catch {
|
|
24
|
+
/* best-effort */
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
toCleanup.length = 0;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it(
|
|
31
|
+
"provisions, execs, and destroys",
|
|
32
|
+
async () => {
|
|
33
|
+
const node = await provider.provision({
|
|
34
|
+
runtime: { image: "python:3.12-slim" },
|
|
35
|
+
});
|
|
36
|
+
toCleanup.push(node.externalId);
|
|
37
|
+
|
|
38
|
+
expect(node.externalId).toBeTruthy();
|
|
39
|
+
|
|
40
|
+
const result = await provider.exec(node.externalId, [
|
|
41
|
+
"echo",
|
|
42
|
+
"hello from modal",
|
|
43
|
+
]);
|
|
44
|
+
expect(result.exitCode).toBe(0);
|
|
45
|
+
expect(result.stdout).toContain("hello from modal");
|
|
46
|
+
|
|
47
|
+
await provider.destroy(node.externalId);
|
|
48
|
+
toCleanup.pop();
|
|
49
|
+
|
|
50
|
+
const status = await provider.inspect(node.externalId);
|
|
51
|
+
expect(status.status).toBe("terminated");
|
|
52
|
+
},
|
|
53
|
+
120_000
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
it(
|
|
57
|
+
"exec captures stderr and exit codes",
|
|
58
|
+
async () => {
|
|
59
|
+
const node = await provider.provision({
|
|
60
|
+
runtime: { image: "python:3.12-slim" },
|
|
61
|
+
});
|
|
62
|
+
toCleanup.push(node.externalId);
|
|
63
|
+
|
|
64
|
+
const result = await provider.exec(node.externalId, [
|
|
65
|
+
"sh",
|
|
66
|
+
"-c",
|
|
67
|
+
"echo err >&2; exit 42",
|
|
68
|
+
]);
|
|
69
|
+
expect(result.exitCode).toBe(42);
|
|
70
|
+
expect(result.stderr).toContain("err");
|
|
71
|
+
},
|
|
72
|
+
120_000
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
it(
|
|
76
|
+
"snapshot and restore",
|
|
77
|
+
async () => {
|
|
78
|
+
const node = await provider.provision({
|
|
79
|
+
runtime: { image: "python:3.12-slim" },
|
|
80
|
+
});
|
|
81
|
+
toCleanup.push(node.externalId);
|
|
82
|
+
|
|
83
|
+
// Write a file
|
|
84
|
+
await provider.exec(node.externalId, [
|
|
85
|
+
"sh",
|
|
86
|
+
"-c",
|
|
87
|
+
"echo snapshot-test > /tmp/test.txt",
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
// Snapshot
|
|
91
|
+
const snap = await provider.snapshot(node.externalId);
|
|
92
|
+
expect(snap.type).toBe("filesystem");
|
|
93
|
+
|
|
94
|
+
// Restore
|
|
95
|
+
const restored = await provider.restore(snap);
|
|
96
|
+
toCleanup.push(restored.externalId);
|
|
97
|
+
|
|
98
|
+
// Verify file persisted
|
|
99
|
+
const result = await provider.exec(restored.externalId, [
|
|
100
|
+
"cat",
|
|
101
|
+
"/tmp/test.txt",
|
|
102
|
+
]);
|
|
103
|
+
expect(result.stdout).toContain("snapshot-test");
|
|
104
|
+
},
|
|
105
|
+
120_000
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
it(
|
|
109
|
+
"healthCheck returns healthy",
|
|
110
|
+
async () => {
|
|
111
|
+
const health = await provider.healthCheck();
|
|
112
|
+
expect(health.healthy).toBe(true);
|
|
113
|
+
},
|
|
114
|
+
30_000
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
it(
|
|
118
|
+
"destroy is idempotent",
|
|
119
|
+
async () => {
|
|
120
|
+
const node = await provider.provision({
|
|
121
|
+
runtime: { image: "python:3.12-slim" },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await provider.destroy(node.externalId);
|
|
125
|
+
await expect(
|
|
126
|
+
provider.destroy(node.externalId)
|
|
127
|
+
).resolves.toBeUndefined();
|
|
128
|
+
},
|
|
129
|
+
120_000
|
|
130
|
+
);
|
|
131
|
+
});
|