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,405 @@
1
+ import type {
2
+ ComputeProvider,
3
+ ComputeSpec,
4
+ ExecOpts,
5
+ ExecResult,
6
+ ExtensionMap,
7
+ HealthStatus,
8
+ NetworkExtension,
9
+ ProviderCapabilities,
10
+ ProviderNode,
11
+ ProviderNodeStatus,
12
+ } from "openlattice";
13
+ import type { FlyProviderConfig } from "./config";
14
+
15
+ /** Fly Machine state as returned by the Machines API. */
16
+ interface FlyMachine {
17
+ id: string;
18
+ name?: string;
19
+ state: string;
20
+ region: string;
21
+ instance_id?: string;
22
+ config?: Record<string, unknown>;
23
+ created_at?: string;
24
+ }
25
+
26
+ /** Exec response from the Machines API. */
27
+ interface FlyExecResponse {
28
+ exit_code: number;
29
+ stdout: string;
30
+ stderr: string;
31
+ }
32
+
33
+ export class FlyProvider implements ComputeProvider {
34
+ readonly name = "fly";
35
+ readonly capabilities: ProviderCapabilities = {
36
+ restart: true,
37
+ pause: true,
38
+ snapshot: false,
39
+ gpu: true,
40
+ logs: false,
41
+ tailscale: true,
42
+ coldStartMs: 500,
43
+ maxConcurrent: 0,
44
+ architectures: ["x86_64"],
45
+ persistentStorage: true,
46
+ constraints: {
47
+ maxPauseMemoryMiB: 2048,
48
+ pauseExcludesGpu: true,
49
+ gpuTypes: ["a10", "l40s", "a100-40gb", "a100-80gb"],
50
+ },
51
+ };
52
+
53
+ private readonly config: FlyProviderConfig;
54
+ private readonly apiToken: string;
55
+ private readonly apiBaseUrl: string;
56
+
57
+ constructor(config: FlyProviderConfig) {
58
+ if (!config.appName) {
59
+ throw new Error("[fly] appName is required");
60
+ }
61
+ this.config = config;
62
+ this.apiToken = config.apiToken ?? process.env.FLY_API_TOKEN ?? "";
63
+ this.apiBaseUrl = config.apiBaseUrl ?? "https://api.machines.dev";
64
+
65
+ if (!this.apiToken) {
66
+ throw new Error("[fly] API token is required (set FLY_API_TOKEN or pass apiToken)");
67
+ }
68
+ }
69
+
70
+ // ── Required methods ────────────────────────────────────────────
71
+
72
+ async provision(spec: ComputeSpec): Promise<ProviderNode> {
73
+ const machineConfig = this.buildMachineConfig(spec);
74
+
75
+ const machine = await this.flyRequest<FlyMachine>(
76
+ "POST",
77
+ "/machines",
78
+ {
79
+ name: spec.name,
80
+ region: this.config.region ?? "ord",
81
+ config: machineConfig,
82
+ }
83
+ );
84
+
85
+ // Wait for machine to be started
86
+ await this.flyRequest<void>(
87
+ "GET",
88
+ `/machines/${machine.id}/wait?state=started&timeout=60`
89
+ );
90
+
91
+ // Join Tailscale network if auth key provided
92
+ if (spec.network?.tailscaleAuthKey) {
93
+ await this.tailscaleUp(machine.id, spec.network.tailscaleAuthKey);
94
+ }
95
+
96
+ const endpoints = [];
97
+ if (spec.network?.ports) {
98
+ for (const p of spec.network.ports) {
99
+ endpoints.push({
100
+ type: "fly",
101
+ host: `${machine.id}.fly.dev`,
102
+ port: p.port,
103
+ url: `https://${this.config.appName}.fly.dev`,
104
+ });
105
+ }
106
+ }
107
+
108
+ return {
109
+ externalId: machine.id,
110
+ endpoints,
111
+ metadata: {
112
+ appName: this.config.appName,
113
+ region: machine.region,
114
+ machineName: machine.name,
115
+ },
116
+ };
117
+ }
118
+
119
+ async exec(
120
+ externalId: string,
121
+ command: string[],
122
+ opts?: ExecOpts
123
+ ): Promise<ExecResult> {
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 timeout = opts?.timeoutMs ? Math.floor(opts.timeoutMs / 1000) : 60;
141
+
142
+ const result = await this.flyRequest<FlyExecResponse>(
143
+ "POST",
144
+ `/machines/${externalId}/exec`,
145
+ { cmd, timeout }
146
+ );
147
+
148
+ const stdout = result.stdout ?? "";
149
+ const stderr = result.stderr ?? "";
150
+
151
+ opts?.onStdout?.(stdout);
152
+ opts?.onStderr?.(stderr);
153
+
154
+ return {
155
+ exitCode: result.exit_code ?? 0,
156
+ stdout,
157
+ stderr,
158
+ };
159
+ }
160
+
161
+ async destroy(externalId: string): Promise<void> {
162
+ try {
163
+ await this.flyRequest<void>(
164
+ "DELETE",
165
+ `/machines/${externalId}?force=true`
166
+ );
167
+ } catch (err: unknown) {
168
+ if (!isNotFound(err)) {
169
+ throw new Error(
170
+ `[fly] destroy failed: ${err instanceof Error ? err.message : String(err)}`
171
+ );
172
+ }
173
+ }
174
+ }
175
+
176
+ async inspect(externalId: string): Promise<ProviderNodeStatus> {
177
+ try {
178
+ const machine = await this.flyRequest<FlyMachine>(
179
+ "GET",
180
+ `/machines/${externalId}`
181
+ );
182
+
183
+ return {
184
+ status: mapFlyState(machine.state),
185
+ startedAt: machine.created_at
186
+ ? new Date(machine.created_at)
187
+ : undefined,
188
+ };
189
+ } catch (err: unknown) {
190
+ if (isNotFound(err)) {
191
+ return { status: "terminated" };
192
+ }
193
+ return { status: "unknown" };
194
+ }
195
+ }
196
+
197
+ // ── Optional: stop / start ──────────────────────────────────────
198
+
199
+ async stop(externalId: string): Promise<void> {
200
+ try {
201
+ await this.flyRequest<void>(
202
+ "POST",
203
+ `/machines/${externalId}/stop`,
204
+ { signal: "SIGTERM", timeout: "10s" }
205
+ );
206
+ } catch (err: unknown) {
207
+ if (!isNotFound(err)) {
208
+ throw new Error(
209
+ `[fly] stop failed: ${err instanceof Error ? err.message : String(err)}`
210
+ );
211
+ }
212
+ }
213
+ }
214
+
215
+ async start(externalId: string): Promise<void> {
216
+ await this.flyRequest<void>(
217
+ "POST",
218
+ `/machines/${externalId}/start`
219
+ );
220
+ await this.flyRequest<void>(
221
+ "GET",
222
+ `/machines/${externalId}/wait?state=started&timeout=60`
223
+ );
224
+ }
225
+
226
+ // ── Optional: pause / resume ──────────────────────────────────
227
+
228
+ async pause(externalId: string): Promise<void> {
229
+ await this.flyRequest<void>(
230
+ "POST",
231
+ `/machines/${externalId}/suspend`
232
+ );
233
+ }
234
+
235
+ async resume(externalId: string): Promise<void> {
236
+ // start auto-detects suspended machines and resumes from snapshot
237
+ await this.start(externalId);
238
+ }
239
+
240
+ // ── Optional: healthCheck ─────────────────────────────────────
241
+
242
+ async healthCheck(): Promise<HealthStatus> {
243
+ const start = Date.now();
244
+ try {
245
+ await this.flyRequest<FlyMachine[]>("GET", "/machines");
246
+ return {
247
+ healthy: true,
248
+ latencyMs: Date.now() - start,
249
+ };
250
+ } catch (err: unknown) {
251
+ return {
252
+ healthy: false,
253
+ latencyMs: Date.now() - start,
254
+ message: `[fly] API unreachable: ${err instanceof Error ? err.message : String(err)}`,
255
+ };
256
+ }
257
+ }
258
+
259
+ // ── Optional: extensions ──────────────────────────────────────
260
+
261
+ getExtension<K extends keyof ExtensionMap>(
262
+ externalId: string,
263
+ extension: K
264
+ ): ExtensionMap[K] | undefined {
265
+ if (extension === "network") {
266
+ return this.createNetworkExtension(externalId) as ExtensionMap[K];
267
+ }
268
+ return undefined;
269
+ }
270
+
271
+ // ── Private helpers ───────────────────────────────────────────
272
+
273
+ private async tailscaleUp(
274
+ machineId: string,
275
+ authKey: string
276
+ ): Promise<void> {
277
+ const result = await this.exec(machineId, [
278
+ "sh",
279
+ "-c",
280
+ `pgrep tailscaled >/dev/null 2>&1 || tailscaled --state=/var/lib/tailscale/tailscaled.state & sleep 1 && tailscale up --authkey=${authKey}`,
281
+ ]);
282
+ if (result.exitCode !== 0) {
283
+ throw new Error(
284
+ `[fly] tailscale up failed: ${result.stderr.trim()}`
285
+ );
286
+ }
287
+ }
288
+
289
+ private buildMachineConfig(spec: ComputeSpec): Record<string, unknown> {
290
+ const config: Record<string, unknown> = {
291
+ image: spec.runtime.image,
292
+ env: spec.runtime.env,
293
+ init: spec.runtime.command
294
+ ? { exec: spec.runtime.command }
295
+ : { exec: ["/bin/sleep", "inf"] },
296
+ guest: {
297
+ cpus: spec.cpu?.cores ?? 1,
298
+ memory_mb: spec.memory ? spec.memory.sizeGiB * 1024 : 256,
299
+ cpu_kind: "shared",
300
+ ...(spec.gpu?.type ? { gpu_kind: spec.gpu.type } : {}),
301
+ },
302
+ auto_destroy: false,
303
+ restart: { policy: "no" },
304
+ };
305
+
306
+ if (spec.network?.ports && spec.network.ports.length > 0) {
307
+ config.services = spec.network.ports.map((p) => ({
308
+ ports: [{ port: p.port, handlers: ["tls", "http"] }],
309
+ protocol: "tcp",
310
+ internal_port: p.port,
311
+ }));
312
+ }
313
+
314
+ return config;
315
+ }
316
+
317
+ private async flyRequest<T>(
318
+ method: string,
319
+ path: string,
320
+ body?: unknown
321
+ ): Promise<T> {
322
+ const url = `${this.apiBaseUrl}/v1/apps/${this.config.appName}${path}`;
323
+ const maxRetries = 3;
324
+
325
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
326
+ const response = await fetch(url, {
327
+ method,
328
+ headers: {
329
+ Authorization: `Bearer ${this.apiToken}`,
330
+ "Content-Type": "application/json",
331
+ },
332
+ body: body ? JSON.stringify(body) : undefined,
333
+ });
334
+
335
+ // Retry on 429 with exponential backoff
336
+ if (response.status === 429 && attempt < maxRetries) {
337
+ const backoffMs = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
338
+ await new Promise((r) => setTimeout(r, backoffMs));
339
+ continue;
340
+ }
341
+
342
+ if (!response.ok) {
343
+ const text = await response.text();
344
+ const err = new Error(
345
+ `[fly] ${method} ${path} failed (${response.status}): ${text}`
346
+ );
347
+ (err as any).statusCode = response.status;
348
+ throw err;
349
+ }
350
+
351
+ if (response.status === 204 || response.headers.get("content-length") === "0") {
352
+ return undefined as T;
353
+ }
354
+
355
+ return response.json() as Promise<T>;
356
+ }
357
+
358
+ throw new Error(`[fly] ${method} ${path} failed: max retries exceeded`);
359
+ }
360
+
361
+ private createNetworkExtension(externalId: string): NetworkExtension {
362
+ const appName = this.config.appName;
363
+ return {
364
+ async getUrl(port: number): Promise<string> {
365
+ // Fly.io routes HTTPS traffic through its proxy on port 443.
366
+ // Internal ports are mapped via fly.toml [[services]] config.
367
+ // Standard HTTPS (443) omits the port for a clean URL.
368
+ if (port === 443 || port === 80) {
369
+ return `https://${appName}.fly.dev`;
370
+ }
371
+ // For non-standard ports, use Fly's internal DNS with .flycast
372
+ // which supports direct port access within the private network.
373
+ return `http://${appName}.flycast:${port}`;
374
+ },
375
+ };
376
+ }
377
+ }
378
+
379
+ // ── Utility functions ─────────────────────────────────────────────
380
+
381
+ function mapFlyState(
382
+ state: string
383
+ ): "running" | "stopped" | "paused" | "terminated" | "unknown" {
384
+ switch (state) {
385
+ case "started":
386
+ case "replacing":
387
+ return "running";
388
+ case "stopped":
389
+ return "stopped";
390
+ case "suspended":
391
+ return "paused";
392
+ case "destroyed":
393
+ case "destroying":
394
+ return "terminated";
395
+ default:
396
+ return "unknown";
397
+ }
398
+ }
399
+
400
+ function isNotFound(err: unknown): boolean {
401
+ if (err && typeof err === "object") {
402
+ return (err as any).statusCode === 404;
403
+ }
404
+ return false;
405
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { FlyProvider } from "./fly-provider";
2
+ export type { FlyProviderConfig } from "./config";
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { runProviderConformanceTests } from "openlattice/testing";
3
+ import { FlyProvider } from "../src/fly-provider";
4
+
5
+ const HAS_FLY =
6
+ !!process.env.FLY_API_TOKEN && !!process.env.FLY_APP_NAME;
7
+
8
+ describe.skipIf(!HAS_FLY)("FlyProvider conformance", () => {
9
+ runProviderConformanceTests(
10
+ {
11
+ createProvider: () =>
12
+ 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
+ createSpec: () => ({
18
+ runtime: { image: "alpine:3.19" },
19
+ }),
20
+ timeoutMs: 120_000,
21
+ },
22
+ { describe, it, expect, beforeEach, afterEach }
23
+ );
24
+ });