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.
@@ -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
+ });