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,10 @@
1
+ export interface FlyProviderConfig {
2
+ /** Fly.io API token. Default: reads FLY_API_TOKEN env var. */
3
+ apiToken?: string;
4
+ /** Fly.io app name. Required. */
5
+ appName: string;
6
+ /** API base URL. Default: "https://api.machines.dev". */
7
+ apiBaseUrl?: string;
8
+ /** Default region. Default: "ord". */
9
+ region?: string;
10
+ }
package/dist/config.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,24 @@
1
+ import type { ComputeProvider, ComputeSpec, ExecOpts, ExecResult, ExtensionMap, HealthStatus, ProviderCapabilities, ProviderNode, ProviderNodeStatus } from "openlattice";
2
+ import type { FlyProviderConfig } from "./config";
3
+ export declare class FlyProvider implements ComputeProvider {
4
+ readonly name = "fly";
5
+ readonly capabilities: ProviderCapabilities;
6
+ private readonly config;
7
+ private readonly apiToken;
8
+ private readonly apiBaseUrl;
9
+ constructor(config: FlyProviderConfig);
10
+ provision(spec: ComputeSpec): Promise<ProviderNode>;
11
+ exec(externalId: string, command: string[], opts?: ExecOpts): Promise<ExecResult>;
12
+ destroy(externalId: string): Promise<void>;
13
+ inspect(externalId: string): Promise<ProviderNodeStatus>;
14
+ stop(externalId: string): Promise<void>;
15
+ start(externalId: string): Promise<void>;
16
+ pause(externalId: string): Promise<void>;
17
+ resume(externalId: string): Promise<void>;
18
+ healthCheck(): Promise<HealthStatus>;
19
+ getExtension<K extends keyof ExtensionMap>(externalId: string, extension: K): ExtensionMap[K] | undefined;
20
+ private tailscaleUp;
21
+ private buildMachineConfig;
22
+ private flyRequest;
23
+ private createNetworkExtension;
24
+ }
@@ -0,0 +1,279 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FlyProvider = void 0;
4
+ class FlyProvider {
5
+ constructor(config) {
6
+ this.name = "fly";
7
+ this.capabilities = {
8
+ restart: true,
9
+ pause: true,
10
+ snapshot: false,
11
+ gpu: true,
12
+ logs: false,
13
+ tailscale: true,
14
+ coldStartMs: 500,
15
+ maxConcurrent: 0,
16
+ architectures: ["x86_64"],
17
+ persistentStorage: true,
18
+ constraints: {
19
+ maxPauseMemoryMiB: 2048,
20
+ pauseExcludesGpu: true,
21
+ gpuTypes: ["a10", "l40s", "a100-40gb", "a100-80gb"],
22
+ },
23
+ };
24
+ if (!config.appName) {
25
+ throw new Error("[fly] appName is required");
26
+ }
27
+ this.config = config;
28
+ this.apiToken = config.apiToken ?? process.env.FLY_API_TOKEN ?? "";
29
+ this.apiBaseUrl = config.apiBaseUrl ?? "https://api.machines.dev";
30
+ if (!this.apiToken) {
31
+ throw new Error("[fly] API token is required (set FLY_API_TOKEN or pass apiToken)");
32
+ }
33
+ }
34
+ // ── Required methods ────────────────────────────────────────────
35
+ async provision(spec) {
36
+ const machineConfig = this.buildMachineConfig(spec);
37
+ const machine = await this.flyRequest("POST", "/machines", {
38
+ name: spec.name,
39
+ region: this.config.region ?? "ord",
40
+ config: machineConfig,
41
+ });
42
+ // Wait for machine to be started
43
+ await this.flyRequest("GET", `/machines/${machine.id}/wait?state=started&timeout=60`);
44
+ // Join Tailscale network if auth key provided
45
+ if (spec.network?.tailscaleAuthKey) {
46
+ await this.tailscaleUp(machine.id, spec.network.tailscaleAuthKey);
47
+ }
48
+ const endpoints = [];
49
+ if (spec.network?.ports) {
50
+ for (const p of spec.network.ports) {
51
+ endpoints.push({
52
+ type: "fly",
53
+ host: `${machine.id}.fly.dev`,
54
+ port: p.port,
55
+ url: `https://${this.config.appName}.fly.dev`,
56
+ });
57
+ }
58
+ }
59
+ return {
60
+ externalId: machine.id,
61
+ endpoints,
62
+ metadata: {
63
+ appName: this.config.appName,
64
+ region: machine.region,
65
+ machineName: machine.name,
66
+ },
67
+ };
68
+ }
69
+ async exec(externalId, command, opts) {
70
+ // Build command with cwd and env support
71
+ let cmd = command;
72
+ if (opts?.cwd || opts?.env) {
73
+ const parts = [];
74
+ if (opts.cwd) {
75
+ parts.push(`cd ${opts.cwd}`);
76
+ }
77
+ if (opts.env) {
78
+ for (const [k, v] of Object.entries(opts.env)) {
79
+ parts.push(`export ${k}='${v.replace(/'/g, "'\\''")}'`);
80
+ }
81
+ }
82
+ parts.push(command.join(" "));
83
+ cmd = ["sh", "-c", parts.join(" && ")];
84
+ }
85
+ const timeout = opts?.timeoutMs ? Math.floor(opts.timeoutMs / 1000) : 60;
86
+ const result = await this.flyRequest("POST", `/machines/${externalId}/exec`, { cmd, timeout });
87
+ const stdout = result.stdout ?? "";
88
+ const stderr = result.stderr ?? "";
89
+ opts?.onStdout?.(stdout);
90
+ opts?.onStderr?.(stderr);
91
+ return {
92
+ exitCode: result.exit_code ?? 0,
93
+ stdout,
94
+ stderr,
95
+ };
96
+ }
97
+ async destroy(externalId) {
98
+ try {
99
+ await this.flyRequest("DELETE", `/machines/${externalId}?force=true`);
100
+ }
101
+ catch (err) {
102
+ if (!isNotFound(err)) {
103
+ throw new Error(`[fly] destroy failed: ${err instanceof Error ? err.message : String(err)}`);
104
+ }
105
+ }
106
+ }
107
+ async inspect(externalId) {
108
+ try {
109
+ const machine = await this.flyRequest("GET", `/machines/${externalId}`);
110
+ return {
111
+ status: mapFlyState(machine.state),
112
+ startedAt: machine.created_at
113
+ ? new Date(machine.created_at)
114
+ : undefined,
115
+ };
116
+ }
117
+ catch (err) {
118
+ if (isNotFound(err)) {
119
+ return { status: "terminated" };
120
+ }
121
+ return { status: "unknown" };
122
+ }
123
+ }
124
+ // ── Optional: stop / start ──────────────────────────────────────
125
+ async stop(externalId) {
126
+ try {
127
+ await this.flyRequest("POST", `/machines/${externalId}/stop`, { signal: "SIGTERM", timeout: "10s" });
128
+ }
129
+ catch (err) {
130
+ if (!isNotFound(err)) {
131
+ throw new Error(`[fly] stop failed: ${err instanceof Error ? err.message : String(err)}`);
132
+ }
133
+ }
134
+ }
135
+ async start(externalId) {
136
+ await this.flyRequest("POST", `/machines/${externalId}/start`);
137
+ await this.flyRequest("GET", `/machines/${externalId}/wait?state=started&timeout=60`);
138
+ }
139
+ // ── Optional: pause / resume ──────────────────────────────────
140
+ async pause(externalId) {
141
+ await this.flyRequest("POST", `/machines/${externalId}/suspend`);
142
+ }
143
+ async resume(externalId) {
144
+ // start auto-detects suspended machines and resumes from snapshot
145
+ await this.start(externalId);
146
+ }
147
+ // ── Optional: healthCheck ─────────────────────────────────────
148
+ async healthCheck() {
149
+ const start = Date.now();
150
+ try {
151
+ await this.flyRequest("GET", "/machines");
152
+ return {
153
+ healthy: true,
154
+ latencyMs: Date.now() - start,
155
+ };
156
+ }
157
+ catch (err) {
158
+ return {
159
+ healthy: false,
160
+ latencyMs: Date.now() - start,
161
+ message: `[fly] API unreachable: ${err instanceof Error ? err.message : String(err)}`,
162
+ };
163
+ }
164
+ }
165
+ // ── Optional: extensions ──────────────────────────────────────
166
+ getExtension(externalId, extension) {
167
+ if (extension === "network") {
168
+ return this.createNetworkExtension(externalId);
169
+ }
170
+ return undefined;
171
+ }
172
+ // ── Private helpers ───────────────────────────────────────────
173
+ async tailscaleUp(machineId, authKey) {
174
+ const result = await this.exec(machineId, [
175
+ "sh",
176
+ "-c",
177
+ `pgrep tailscaled >/dev/null 2>&1 || tailscaled --state=/var/lib/tailscale/tailscaled.state & sleep 1 && tailscale up --authkey=${authKey}`,
178
+ ]);
179
+ if (result.exitCode !== 0) {
180
+ throw new Error(`[fly] tailscale up failed: ${result.stderr.trim()}`);
181
+ }
182
+ }
183
+ buildMachineConfig(spec) {
184
+ const config = {
185
+ image: spec.runtime.image,
186
+ env: spec.runtime.env,
187
+ init: spec.runtime.command
188
+ ? { exec: spec.runtime.command }
189
+ : { exec: ["/bin/sleep", "inf"] },
190
+ guest: {
191
+ cpus: spec.cpu?.cores ?? 1,
192
+ memory_mb: spec.memory ? spec.memory.sizeGiB * 1024 : 256,
193
+ cpu_kind: "shared",
194
+ ...(spec.gpu?.type ? { gpu_kind: spec.gpu.type } : {}),
195
+ },
196
+ auto_destroy: false,
197
+ restart: { policy: "no" },
198
+ };
199
+ if (spec.network?.ports && spec.network.ports.length > 0) {
200
+ config.services = spec.network.ports.map((p) => ({
201
+ ports: [{ port: p.port, handlers: ["tls", "http"] }],
202
+ protocol: "tcp",
203
+ internal_port: p.port,
204
+ }));
205
+ }
206
+ return config;
207
+ }
208
+ async flyRequest(method, path, body) {
209
+ const url = `${this.apiBaseUrl}/v1/apps/${this.config.appName}${path}`;
210
+ const maxRetries = 3;
211
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
212
+ const response = await fetch(url, {
213
+ method,
214
+ headers: {
215
+ Authorization: `Bearer ${this.apiToken}`,
216
+ "Content-Type": "application/json",
217
+ },
218
+ body: body ? JSON.stringify(body) : undefined,
219
+ });
220
+ // Retry on 429 with exponential backoff
221
+ if (response.status === 429 && attempt < maxRetries) {
222
+ const backoffMs = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
223
+ await new Promise((r) => setTimeout(r, backoffMs));
224
+ continue;
225
+ }
226
+ if (!response.ok) {
227
+ const text = await response.text();
228
+ const err = new Error(`[fly] ${method} ${path} failed (${response.status}): ${text}`);
229
+ err.statusCode = response.status;
230
+ throw err;
231
+ }
232
+ if (response.status === 204 || response.headers.get("content-length") === "0") {
233
+ return undefined;
234
+ }
235
+ return response.json();
236
+ }
237
+ throw new Error(`[fly] ${method} ${path} failed: max retries exceeded`);
238
+ }
239
+ createNetworkExtension(externalId) {
240
+ const appName = this.config.appName;
241
+ return {
242
+ async getUrl(port) {
243
+ // Fly.io routes HTTPS traffic through its proxy on port 443.
244
+ // Internal ports are mapped via fly.toml [[services]] config.
245
+ // Standard HTTPS (443) omits the port for a clean URL.
246
+ if (port === 443 || port === 80) {
247
+ return `https://${appName}.fly.dev`;
248
+ }
249
+ // For non-standard ports, use Fly's internal DNS with .flycast
250
+ // which supports direct port access within the private network.
251
+ return `http://${appName}.flycast:${port}`;
252
+ },
253
+ };
254
+ }
255
+ }
256
+ exports.FlyProvider = FlyProvider;
257
+ // ── Utility functions ─────────────────────────────────────────────
258
+ function mapFlyState(state) {
259
+ switch (state) {
260
+ case "started":
261
+ case "replacing":
262
+ return "running";
263
+ case "stopped":
264
+ return "stopped";
265
+ case "suspended":
266
+ return "paused";
267
+ case "destroyed":
268
+ case "destroying":
269
+ return "terminated";
270
+ default:
271
+ return "unknown";
272
+ }
273
+ }
274
+ function isNotFound(err) {
275
+ if (err && typeof err === "object") {
276
+ return err.statusCode === 404;
277
+ }
278
+ return false;
279
+ }
@@ -0,0 +1,2 @@
1
+ export { FlyProvider } from "./fly-provider";
2
+ export type { FlyProviderConfig } from "./config";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FlyProvider = void 0;
4
+ var fly_provider_1 = require("./fly-provider");
5
+ Object.defineProperty(exports, "FlyProvider", { enumerable: true, get: function () { return fly_provider_1.FlyProvider; } });
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "openlattice-fly",
3
+ "version": "0.0.3",
4
+ "description": "Fly.io compute provider for OpenLattice",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "test": "vitest run",
16
+ "test:integration": "vitest run tests/integration.test.ts tests/conformance.test.ts"
17
+ },
18
+ "keywords": [
19
+ "compute",
20
+ "fly",
21
+ "fly.io",
22
+ "openlattice",
23
+ "provider"
24
+ ],
25
+ "license": "ISC",
26
+ "peerDependencies": {
27
+ "openlattice": "^0.0.3"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^25.2.2",
31
+ "openlattice": "^0.0.3",
32
+ "typescript": "^5.9.3",
33
+ "vitest": "^4.0.18"
34
+ }
35
+ }
package/src/config.ts ADDED
@@ -0,0 +1,10 @@
1
+ export interface FlyProviderConfig {
2
+ /** Fly.io API token. Default: reads FLY_API_TOKEN env var. */
3
+ apiToken?: string;
4
+ /** Fly.io app name. Required. */
5
+ appName: string;
6
+ /** API base URL. Default: "https://api.machines.dev". */
7
+ apiBaseUrl?: string;
8
+ /** Default region. Default: "ord". */
9
+ region?: string;
10
+ }