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.
- package/dist/config.d.ts +10 -0
- package/dist/config.js +2 -0
- package/dist/fly-provider.d.ts +24 -0
- package/dist/fly-provider.js +279 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/package.json +35 -0
- package/src/config.ts +10 -0
- package/src/fly-provider.ts +405 -0
- package/src/index.ts +2 -0
- package/tests/conformance.test.ts +24 -0
- package/tests/fly-provider.test.ts +538 -0
- package/tests/integration.test.ts +118 -0
- package/tsconfig.json +16 -0
|
@@ -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
|
+
}
|