openlattice-fly 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,538 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { FlyProvider } from "../src/fly-provider";
3
+
4
+ // ── Mock fetch ──────────────────────────────────────────────────────
5
+
6
+ const mocks = vi.hoisted(() => {
7
+ const mockFetch = vi.fn();
8
+ return { mockFetch };
9
+ });
10
+
11
+ vi.stubGlobal("fetch", mocks.mockFetch);
12
+
13
+ // ── Helpers ─────────────────────────────────────────────────────────
14
+
15
+ function mockFetchResponse(
16
+ status: number,
17
+ body: unknown,
18
+ headers?: Record<string, string>
19
+ ) {
20
+ return {
21
+ ok: status >= 200 && status < 300,
22
+ status,
23
+ headers: {
24
+ get: (key: string) => headers?.[key] ?? null,
25
+ },
26
+ json: () => Promise.resolve(body),
27
+ text: () => Promise.resolve(JSON.stringify(body)),
28
+ };
29
+ }
30
+
31
+ function setupDefaultMocks() {
32
+ mocks.mockFetch.mockImplementation((url: string, opts?: RequestInit) => {
33
+ const method = opts?.method ?? "GET";
34
+ const path = new URL(url).pathname;
35
+
36
+ // POST /machines — create
37
+ if (method === "POST" && path.endsWith("/machines")) {
38
+ return Promise.resolve(
39
+ mockFetchResponse(200, {
40
+ id: "mach_test123",
41
+ name: "test-machine",
42
+ state: "started",
43
+ region: "ord",
44
+ created_at: "2024-01-01T00:00:00Z",
45
+ })
46
+ );
47
+ }
48
+
49
+ // GET /machines/{id}/wait — wait for state
50
+ if (method === "GET" && path.includes("/wait")) {
51
+ return Promise.resolve(mockFetchResponse(200, {}));
52
+ }
53
+
54
+ // GET /machines/{id} — inspect
55
+ if (method === "GET" && path.match(/\/machines\/[^/]+$/)) {
56
+ return Promise.resolve(
57
+ mockFetchResponse(200, {
58
+ id: "mach_test123",
59
+ state: "started",
60
+ region: "ord",
61
+ created_at: "2024-01-01T00:00:00Z",
62
+ })
63
+ );
64
+ }
65
+
66
+ // GET /machines — list
67
+ if (method === "GET" && path.endsWith("/machines")) {
68
+ return Promise.resolve(mockFetchResponse(200, []));
69
+ }
70
+
71
+ // POST /machines/{id}/exec — exec
72
+ if (method === "POST" && path.includes("/exec")) {
73
+ return Promise.resolve(
74
+ mockFetchResponse(200, {
75
+ exit_code: 0,
76
+ stdout: "",
77
+ stderr: "",
78
+ })
79
+ );
80
+ }
81
+
82
+ // DELETE /machines/{id} — destroy
83
+ if (method === "DELETE") {
84
+ return Promise.resolve(mockFetchResponse(200, { ok: true }));
85
+ }
86
+
87
+ // POST /machines/{id}/stop
88
+ if (method === "POST" && path.includes("/stop")) {
89
+ return Promise.resolve(mockFetchResponse(200, { ok: true }));
90
+ }
91
+
92
+ // POST /machines/{id}/start
93
+ if (method === "POST" && path.includes("/start")) {
94
+ return Promise.resolve(mockFetchResponse(200, {}));
95
+ }
96
+
97
+ // POST /machines/{id}/suspend
98
+ if (method === "POST" && path.includes("/suspend")) {
99
+ return Promise.resolve(mockFetchResponse(200, { ok: true }));
100
+ }
101
+
102
+ return Promise.resolve(mockFetchResponse(200, {}));
103
+ });
104
+ }
105
+
106
+ // ── Tests ───────────────────────────────────────────────────────────
107
+
108
+ describe("FlyProvider", () => {
109
+ let provider: FlyProvider;
110
+
111
+ const defaultConfig = {
112
+ appName: "test-app",
113
+ apiToken: "test-token",
114
+ };
115
+
116
+ beforeEach(() => {
117
+ vi.clearAllMocks();
118
+ setupDefaultMocks();
119
+ provider = new FlyProvider(defaultConfig);
120
+ });
121
+
122
+ describe("constructor", () => {
123
+ it("sets name to 'fly'", () => {
124
+ expect(provider.name).toBe("fly");
125
+ });
126
+
127
+ it("declares expected capabilities", () => {
128
+ expect(provider.capabilities.restart).toBe(true);
129
+ expect(provider.capabilities.pause).toBe(true);
130
+ expect(provider.capabilities.snapshot).toBe(false);
131
+ expect(provider.capabilities.gpu).toBe(true);
132
+ expect(provider.capabilities.logs).toBe(false);
133
+ expect(provider.capabilities.tailscale).toBe(true);
134
+ expect(provider.capabilities.architectures).toContain("x86_64");
135
+ });
136
+
137
+ it("throws if appName is missing", () => {
138
+ expect(
139
+ () => new FlyProvider({ appName: "" } as any)
140
+ ).toThrow("appName");
141
+ });
142
+
143
+ it("throws if API token is missing", () => {
144
+ const origEnv = process.env.FLY_API_TOKEN;
145
+ delete process.env.FLY_API_TOKEN;
146
+ expect(
147
+ () => new FlyProvider({ appName: "test" })
148
+ ).toThrow("API token");
149
+ process.env.FLY_API_TOKEN = origEnv;
150
+ });
151
+ });
152
+
153
+ describe("provision", () => {
154
+ it("creates a machine and returns node info", async () => {
155
+ const node = await provider.provision({
156
+ runtime: { image: "python:3.12" },
157
+ });
158
+
159
+ expect(node.externalId).toBe("mach_test123");
160
+ expect(mocks.mockFetch).toHaveBeenCalled();
161
+ });
162
+
163
+ it("sends correct image in machine config", async () => {
164
+ await provider.provision({
165
+ runtime: { image: "nginx:latest" },
166
+ });
167
+
168
+ const createCall = mocks.mockFetch.mock.calls.find(
169
+ (c: any[]) => c[1]?.method === "POST" && c[0].includes("/machines") && !c[0].includes("/")
170
+ );
171
+ // Check any POST to /machines
172
+ const postCalls = mocks.mockFetch.mock.calls.filter(
173
+ (c: any[]) => c[1]?.method === "POST"
174
+ );
175
+ const body = JSON.parse(postCalls[0][1].body);
176
+ expect(body.config.image).toBe("nginx:latest");
177
+ });
178
+
179
+ it("uses sleep inf as default command", async () => {
180
+ await provider.provision({
181
+ runtime: { image: "python:3.12" },
182
+ });
183
+
184
+ const postCalls = mocks.mockFetch.mock.calls.filter(
185
+ (c: any[]) => c[1]?.method === "POST" && !c[0].includes("/wait")
186
+ );
187
+ const body = JSON.parse(postCalls[0][1].body);
188
+ expect(body.config.init.exec).toEqual(["/bin/sleep", "inf"]);
189
+ });
190
+
191
+ it("uses custom command when provided", async () => {
192
+ await provider.provision({
193
+ runtime: {
194
+ image: "python:3.12",
195
+ command: ["python", "server.py"],
196
+ },
197
+ });
198
+
199
+ const postCalls = mocks.mockFetch.mock.calls.filter(
200
+ (c: any[]) => c[1]?.method === "POST" && !c[0].includes("/wait")
201
+ );
202
+ const body = JSON.parse(postCalls[0][1].body);
203
+ expect(body.config.init.exec).toEqual(["python", "server.py"]);
204
+ });
205
+
206
+ it("sets resource limits", async () => {
207
+ await provider.provision({
208
+ runtime: { image: "python:3.12" },
209
+ cpu: { cores: 2 },
210
+ memory: { sizeGiB: 4 },
211
+ });
212
+
213
+ const postCalls = mocks.mockFetch.mock.calls.filter(
214
+ (c: any[]) => c[1]?.method === "POST" && !c[0].includes("/wait")
215
+ );
216
+ const body = JSON.parse(postCalls[0][1].body);
217
+ expect(body.config.guest.cpus).toBe(2);
218
+ expect(body.config.guest.memory_mb).toBe(4096);
219
+ });
220
+
221
+ it("sets GPU when specified", async () => {
222
+ await provider.provision({
223
+ runtime: { image: "python:3.12" },
224
+ gpu: { type: "a100-80gb", count: 1 },
225
+ });
226
+
227
+ const postCalls = mocks.mockFetch.mock.calls.filter(
228
+ (c: any[]) => c[1]?.method === "POST" && !c[0].includes("/wait")
229
+ );
230
+ const body = JSON.parse(postCalls[0][1].body);
231
+ expect(body.config.guest.gpu_kind).toBe("a100-80gb");
232
+ });
233
+
234
+ it("includes endpoints when ports are specified", async () => {
235
+ const node = await provider.provision({
236
+ runtime: { image: "nginx" },
237
+ network: { ports: [{ port: 80 }] },
238
+ });
239
+
240
+ expect(node.endpoints).toHaveLength(1);
241
+ expect(node.endpoints[0].port).toBe(80);
242
+ });
243
+
244
+ it("waits for machine to start", async () => {
245
+ await provider.provision({
246
+ runtime: { image: "python:3.12" },
247
+ });
248
+
249
+ const waitCalls = mocks.mockFetch.mock.calls.filter(
250
+ (c: any[]) => c[0].includes("/wait")
251
+ );
252
+ expect(waitCalls).toHaveLength(1);
253
+ });
254
+
255
+ it("calls tailscale up via exec when tailscaleAuthKey is set", async () => {
256
+ await provider.provision({
257
+ runtime: { image: "tailscale/fly" },
258
+ network: { tailscaleAuthKey: "tskey-auth-abc123" },
259
+ });
260
+
261
+ // After machine create + wait, there should be an exec call
262
+ const execCalls = mocks.mockFetch.mock.calls.filter(
263
+ (c: any[]) => c[1]?.method === "POST" && c[0].includes("/exec")
264
+ );
265
+ expect(execCalls.length).toBeGreaterThanOrEqual(1);
266
+ const execBody = JSON.parse(execCalls[0][1].body);
267
+ expect(execBody.cmd.join(" ")).toContain("tskey-auth-abc123");
268
+ });
269
+ });
270
+
271
+ describe("exec", () => {
272
+ it("executes command via Machines API", async () => {
273
+ mocks.mockFetch.mockImplementation((url: string, opts?: RequestInit) => {
274
+ if (opts?.method === "POST" && url.includes("/exec")) {
275
+ return Promise.resolve(
276
+ mockFetchResponse(200, {
277
+ exit_code: 0,
278
+ stdout: "hello world\n",
279
+ stderr: "",
280
+ })
281
+ );
282
+ }
283
+ // Default mocks for provision
284
+ if (opts?.method === "POST" && url.endsWith("/machines")) {
285
+ return Promise.resolve(
286
+ mockFetchResponse(200, {
287
+ id: "mach_exec",
288
+ state: "started",
289
+ region: "ord",
290
+ })
291
+ );
292
+ }
293
+ if (url.includes("/wait")) {
294
+ return Promise.resolve(mockFetchResponse(200, {}));
295
+ }
296
+ return Promise.resolve(mockFetchResponse(200, {}));
297
+ });
298
+
299
+ const node = await provider.provision({
300
+ runtime: { image: "python:3.12" },
301
+ });
302
+
303
+ const result = await provider.exec(node.externalId, [
304
+ "echo",
305
+ "hello world",
306
+ ]);
307
+
308
+ expect(result.exitCode).toBe(0);
309
+ expect(result.stdout).toContain("hello world");
310
+ });
311
+
312
+ it("captures stderr and exit codes", async () => {
313
+ mocks.mockFetch.mockImplementation((url: string, opts?: RequestInit) => {
314
+ if (opts?.method === "POST" && url.includes("/exec")) {
315
+ return Promise.resolve(
316
+ mockFetchResponse(200, {
317
+ exit_code: 42,
318
+ stdout: "",
319
+ stderr: "error occurred\n",
320
+ })
321
+ );
322
+ }
323
+ if (opts?.method === "POST" && url.endsWith("/machines")) {
324
+ return Promise.resolve(
325
+ mockFetchResponse(200, { id: "mach1", state: "started", region: "ord" })
326
+ );
327
+ }
328
+ if (url.includes("/wait")) {
329
+ return Promise.resolve(mockFetchResponse(200, {}));
330
+ }
331
+ return Promise.resolve(mockFetchResponse(200, {}));
332
+ });
333
+
334
+ const node = await provider.provision({
335
+ runtime: { image: "python:3.12" },
336
+ });
337
+
338
+ const result = await provider.exec(node.externalId, [
339
+ "sh",
340
+ "-c",
341
+ "exit 42",
342
+ ]);
343
+
344
+ expect(result.exitCode).toBe(42);
345
+ expect(result.stderr).toContain("error occurred");
346
+ });
347
+
348
+ it("wraps command with cwd and env", async () => {
349
+ const node = await provider.provision({
350
+ runtime: { image: "python:3.12" },
351
+ });
352
+
353
+ await provider.exec(node.externalId, ["ls"], {
354
+ cwd: "/app",
355
+ env: { FOO: "bar" },
356
+ });
357
+
358
+ const execCall = mocks.mockFetch.mock.calls.find(
359
+ (c: any[]) => c[0].includes("/exec")
360
+ );
361
+ const body = JSON.parse(execCall[1].body);
362
+ expect(body.cmd).toEqual([
363
+ "sh",
364
+ "-c",
365
+ "cd /app && export FOO='bar' && ls",
366
+ ]);
367
+ });
368
+ });
369
+
370
+ describe("destroy", () => {
371
+ it("deletes machine with force=true", async () => {
372
+ const node = await provider.provision({
373
+ runtime: { image: "python:3.12" },
374
+ });
375
+
376
+ await provider.destroy(node.externalId);
377
+
378
+ const deleteCall = mocks.mockFetch.mock.calls.find(
379
+ (c: any[]) => c[1]?.method === "DELETE"
380
+ );
381
+ expect(deleteCall).toBeDefined();
382
+ expect(deleteCall[0]).toContain("force=true");
383
+ });
384
+
385
+ it("is idempotent for 404", async () => {
386
+ mocks.mockFetch.mockImplementation((url: string, opts?: RequestInit) => {
387
+ if (opts?.method === "DELETE") {
388
+ const err = mockFetchResponse(404, { error: "not found" });
389
+ return Promise.resolve(err);
390
+ }
391
+ return Promise.resolve(mockFetchResponse(200, {}));
392
+ });
393
+
394
+ await expect(
395
+ provider.destroy("nonexistent")
396
+ ).resolves.toBeUndefined();
397
+ });
398
+ });
399
+
400
+ describe("inspect", () => {
401
+ it("returns running for started machine", async () => {
402
+ const status = await provider.inspect("mach_test123");
403
+ expect(status.status).toBe("running");
404
+ });
405
+
406
+ it("returns paused for suspended machine", async () => {
407
+ mocks.mockFetch.mockImplementation((url: string, opts?: RequestInit) => {
408
+ if (url.includes("/machines/") && opts?.method !== "POST") {
409
+ return Promise.resolve(
410
+ mockFetchResponse(200, {
411
+ id: "mach_sus",
412
+ state: "suspended",
413
+ region: "ord",
414
+ })
415
+ );
416
+ }
417
+ return Promise.resolve(mockFetchResponse(200, {}));
418
+ });
419
+
420
+ const status = await provider.inspect("mach_sus");
421
+ expect(status.status).toBe("paused");
422
+ });
423
+
424
+ it("returns terminated for 404", async () => {
425
+ mocks.mockFetch.mockImplementation(() => {
426
+ const resp = mockFetchResponse(404, { error: "not found" });
427
+ return Promise.resolve(resp);
428
+ });
429
+
430
+ const status = await provider.inspect("nonexistent");
431
+ expect(status.status).toBe("terminated");
432
+ });
433
+ });
434
+
435
+ describe("stop / start", () => {
436
+ it("stop sends stop request", async () => {
437
+ const node = await provider.provision({
438
+ runtime: { image: "python:3.12" },
439
+ });
440
+
441
+ await provider.stop(node.externalId);
442
+
443
+ const stopCall = mocks.mockFetch.mock.calls.find(
444
+ (c: any[]) => c[0].includes("/stop")
445
+ );
446
+ expect(stopCall).toBeDefined();
447
+ });
448
+
449
+ it("start sends start request and waits", async () => {
450
+ const node = await provider.provision({
451
+ runtime: { image: "python:3.12" },
452
+ });
453
+
454
+ await provider.start(node.externalId);
455
+
456
+ const startCall = mocks.mockFetch.mock.calls.find(
457
+ (c: any[]) => c[0].includes("/start")
458
+ );
459
+ expect(startCall).toBeDefined();
460
+ });
461
+ });
462
+
463
+ describe("pause / resume", () => {
464
+ it("pause sends suspend request", async () => {
465
+ const node = await provider.provision({
466
+ runtime: { image: "python:3.12" },
467
+ });
468
+
469
+ await provider.pause(node.externalId);
470
+
471
+ const suspendCall = mocks.mockFetch.mock.calls.find(
472
+ (c: any[]) => c[0].includes("/suspend")
473
+ );
474
+ expect(suspendCall).toBeDefined();
475
+ });
476
+
477
+ it("resume sends start request", async () => {
478
+ const node = await provider.provision({
479
+ runtime: { image: "python:3.12" },
480
+ });
481
+
482
+ await provider.resume(node.externalId);
483
+
484
+ const startCall = mocks.mockFetch.mock.calls.find(
485
+ (c: any[]) => c[0].includes("/start")
486
+ );
487
+ expect(startCall).toBeDefined();
488
+ });
489
+ });
490
+
491
+ describe("healthCheck", () => {
492
+ it("returns healthy when API is reachable", async () => {
493
+ const health = await provider.healthCheck();
494
+ expect(health.healthy).toBe(true);
495
+ expect(health.latencyMs).toBeTypeOf("number");
496
+ });
497
+
498
+ it("returns unhealthy when API fails", async () => {
499
+ mocks.mockFetch.mockRejectedValue(new Error("network error"));
500
+
501
+ const health = await provider.healthCheck();
502
+ expect(health.healthy).toBe(false);
503
+ expect(health.message).toContain("unreachable");
504
+ });
505
+ });
506
+
507
+ describe("getExtension", () => {
508
+ it("returns network extension", async () => {
509
+ const node = await provider.provision({
510
+ runtime: { image: "nginx" },
511
+ });
512
+
513
+ const ext = provider.getExtension(node.externalId, "network");
514
+ expect(ext).toBeDefined();
515
+ expect(ext).toHaveProperty("getUrl");
516
+ });
517
+
518
+ it("network extension returns fly.dev URL", async () => {
519
+ const node = await provider.provision({
520
+ runtime: { image: "nginx" },
521
+ });
522
+
523
+ const ext = provider.getExtension(node.externalId, "network")!;
524
+ const url = await ext.getUrl(80);
525
+ expect(url).toContain("test-app.fly.dev");
526
+ });
527
+
528
+ it("returns undefined for unsupported extensions", async () => {
529
+ const node = await provider.provision({
530
+ runtime: { image: "python:3.12" },
531
+ });
532
+
533
+ expect(
534
+ provider.getExtension(node.externalId, "files")
535
+ ).toBeUndefined();
536
+ });
537
+ });
538
+ });
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect, beforeAll, afterEach } from "vitest";
2
+ import { FlyProvider } from "../src/fly-provider";
3
+
4
+ const HAS_FLY =
5
+ !!process.env.FLY_API_TOKEN && !!process.env.FLY_APP_NAME;
6
+
7
+ describe.skipIf(!HAS_FLY)("FlyProvider integration", () => {
8
+ let provider: FlyProvider;
9
+ const toCleanup: string[] = [];
10
+
11
+ beforeAll(() => {
12
+ provider = new FlyProvider({
13
+ appName: process.env.FLY_APP_NAME!,
14
+ apiToken: process.env.FLY_API_TOKEN,
15
+ region: process.env.FLY_REGION ?? "ord",
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: "alpine:3.19" },
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 fly",
43
+ ]);
44
+ expect(result.exitCode).toBe(0);
45
+ expect(result.stdout).toContain("hello from fly");
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
+ "stop and start",
58
+ async () => {
59
+ const node = await provider.provision({
60
+ runtime: { image: "alpine:3.19" },
61
+ });
62
+ toCleanup.push(node.externalId);
63
+
64
+ await provider.stop(node.externalId);
65
+ const stopped = await provider.inspect(node.externalId);
66
+ expect(stopped.status).toBe("stopped");
67
+
68
+ await provider.start(node.externalId);
69
+ const started = await provider.inspect(node.externalId);
70
+ expect(started.status).toBe("running");
71
+ },
72
+ 120_000
73
+ );
74
+
75
+ it(
76
+ "pause and resume (suspend)",
77
+ async () => {
78
+ const node = await provider.provision({
79
+ runtime: { image: "alpine:3.19" },
80
+ memory: { sizeGiB: 0.25 }, // Must be <= 2GB for suspend
81
+ });
82
+ toCleanup.push(node.externalId);
83
+
84
+ await provider.pause(node.externalId);
85
+ const paused = await provider.inspect(node.externalId);
86
+ expect(paused.status).toBe("paused");
87
+
88
+ await provider.resume(node.externalId);
89
+ const resumed = await provider.inspect(node.externalId);
90
+ expect(resumed.status).toBe("running");
91
+ },
92
+ 120_000
93
+ );
94
+
95
+ it(
96
+ "healthCheck returns healthy",
97
+ async () => {
98
+ const health = await provider.healthCheck();
99
+ expect(health.healthy).toBe(true);
100
+ },
101
+ 30_000
102
+ );
103
+
104
+ it(
105
+ "destroy is idempotent",
106
+ async () => {
107
+ const node = await provider.provision({
108
+ runtime: { image: "alpine:3.19" },
109
+ });
110
+
111
+ await provider.destroy(node.externalId);
112
+ await expect(
113
+ provider.destroy(node.externalId)
114
+ ).resolves.toBeUndefined();
115
+ },
116
+ 120_000
117
+ );
118
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "commonjs",
5
+ "declaration": true,
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "moduleResolution": "node"
14
+ },
15
+ "include": ["src"]
16
+ }