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