openlattice-ssh 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,30 @@
1
+ export interface SSHProviderConfig {
2
+ /** SSH connection targets. At least one required. */
3
+ hosts: SSHHostConfig[];
4
+ /** Default user for SSH connections. */
5
+ defaultUser?: string;
6
+ /** Default private key content (string or Buffer). */
7
+ defaultPrivateKey?: string | Buffer;
8
+ /** Path to default private key file. Used if defaultPrivateKey is not set. */
9
+ defaultKeyPath?: string;
10
+ /** Connection timeout in ms. Default: 20_000. */
11
+ connectTimeoutMs?: number;
12
+ /** Keepalive interval in ms. Default: 10_000. */
13
+ keepaliveIntervalMs?: number;
14
+ }
15
+ export interface SSHHostConfig {
16
+ /** Hostname or IP address. */
17
+ host: string;
18
+ /** SSH port. Default: 22. */
19
+ port?: number;
20
+ /** Username. Overrides defaultUser. */
21
+ username?: string;
22
+ /** Private key (content or path). Overrides defaultPrivateKey. */
23
+ privateKey?: string | Buffer;
24
+ /** Password (not recommended). */
25
+ password?: string;
26
+ /** Labels for host selection. */
27
+ labels?: Record<string, string>;
28
+ /** Whether this host has GPU. */
29
+ gpuAvailable?: boolean;
30
+ }
package/dist/config.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,2 @@
1
+ export { SSHProvider } from "./ssh-provider";
2
+ export type { SSHProviderConfig, SSHHostConfig } from "./config";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SSHProvider = void 0;
4
+ var ssh_provider_1 = require("./ssh-provider");
5
+ Object.defineProperty(exports, "SSHProvider", { enumerable: true, get: function () { return ssh_provider_1.SSHProvider; } });
@@ -0,0 +1,30 @@
1
+ import type { ComputeProvider, ComputeSpec, ExecOpts, ExecResult, ExtensionMap, HealthStatus, ProviderCapabilities, ProviderNode, ProviderNodeStatus } from "openlattice";
2
+ import type { SSHProviderConfig } from "./config";
3
+ export declare class SSHProvider implements ComputeProvider {
4
+ readonly name = "ssh";
5
+ readonly capabilities: ProviderCapabilities;
6
+ private readonly config;
7
+ private readonly connections;
8
+ private readonly nodes;
9
+ private roundRobinIdx;
10
+ constructor(config: SSHProviderConfig);
11
+ provision(spec: ComputeSpec): Promise<ProviderNode>;
12
+ exec(externalId: string, command: string[], opts?: ExecOpts): Promise<ExecResult>;
13
+ destroy(externalId: string): Promise<void>;
14
+ inspect(externalId: string): Promise<ProviderNodeStatus>;
15
+ stop(externalId: string): Promise<void>;
16
+ start(externalId: string): Promise<void>;
17
+ healthCheck(): Promise<HealthStatus>;
18
+ getExtension<K extends keyof ExtensionMap>(externalId: string, extension: K): ExtensionMap[K] | undefined;
19
+ /** Close all SSH connections. Call when shutting down the provider. */
20
+ close(): Promise<void>;
21
+ private createNetworkExtension;
22
+ private selectHost;
23
+ private getNodeState;
24
+ private getConnection;
25
+ private ensureTailscale;
26
+ private tailscaleUp;
27
+ private sshExec;
28
+ private getSftp;
29
+ private createFileExtension;
30
+ }
@@ -0,0 +1,441 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SSHProvider = void 0;
4
+ const fs_1 = require("fs");
5
+ const ssh2_1 = require("ssh2");
6
+ let nextSessionId = 1;
7
+ class SSHProvider {
8
+ constructor(config) {
9
+ this.name = "ssh";
10
+ this.connections = new Map();
11
+ this.nodes = new Map();
12
+ this.roundRobinIdx = 0;
13
+ if (!config.hosts || config.hosts.length === 0) {
14
+ throw new Error("[ssh] at least one host must be configured");
15
+ }
16
+ // Load private key from file path if defaultKeyPath is set and no key content
17
+ if (config.defaultKeyPath && !config.defaultPrivateKey) {
18
+ config.defaultPrivateKey = (0, fs_1.readFileSync)(config.defaultKeyPath);
19
+ }
20
+ this.config = config;
21
+ const hasGpu = config.hosts.some((h) => h.gpuAvailable);
22
+ this.capabilities = {
23
+ restart: true,
24
+ pause: false, // SIGSTOP unreliable via SSH
25
+ snapshot: false,
26
+ gpu: hasGpu,
27
+ logs: false,
28
+ tailscale: true,
29
+ coldStartMs: 1000,
30
+ maxConcurrent: 0,
31
+ architectures: ["x86_64", "arm64"],
32
+ persistentStorage: true,
33
+ };
34
+ }
35
+ // ── Required methods ────────────────────────────────────────────
36
+ async provision(spec) {
37
+ const host = this.selectHost(spec);
38
+ const conn = await this.getConnection(host);
39
+ const sessionId = `ssh-${nextSessionId++}`;
40
+ const hostKey = hostKeyOf(host);
41
+ const externalId = `${hostKey}/${sessionId}`;
42
+ const workdir = `/tmp/openlattice/${sessionId}`;
43
+ // Create working directory and validate connectivity
44
+ await this.sshExec(conn, `mkdir -p ${workdir}`);
45
+ // Store node state
46
+ const state = {
47
+ host,
48
+ workdir,
49
+ startedAt: new Date(),
50
+ };
51
+ this.nodes.set(externalId, state);
52
+ // Ensure Tailscale is installed and running if requested (authKey implies tailscale)
53
+ if (spec.network?.tailscale || spec.network?.tailscaleAuthKey) {
54
+ await this.ensureTailscale(conn);
55
+ if (spec.network?.tailscaleAuthKey) {
56
+ await this.tailscaleUp(conn, spec.network.tailscaleAuthKey);
57
+ }
58
+ }
59
+ // Run initial command in background if specified
60
+ if (spec.runtime.command && spec.runtime.command.length > 0) {
61
+ const cmd = spec.runtime.command.join(" ");
62
+ const envPrefix = buildEnvPrefix(spec.runtime.env);
63
+ state.command = cmd;
64
+ state.envPrefix = envPrefix;
65
+ const result = await this.sshExec(conn, `cd ${workdir} && ${envPrefix}nohup ${cmd} > ${workdir}/.stdout 2> ${workdir}/.stderr & echo $!`);
66
+ const pid = parseInt(result.stdout.trim(), 10);
67
+ if (!isNaN(pid)) {
68
+ state.pid = pid;
69
+ }
70
+ }
71
+ return {
72
+ externalId,
73
+ endpoints: [
74
+ {
75
+ type: "ssh",
76
+ host: host.host,
77
+ port: host.port ?? 22,
78
+ },
79
+ ],
80
+ metadata: { hostKey, sessionId, workdir },
81
+ };
82
+ }
83
+ async exec(externalId, command, opts) {
84
+ const state = this.getNodeState(externalId);
85
+ const conn = await this.getConnection(state.host);
86
+ const cmdStr = command.join(" ");
87
+ const cwd = opts?.cwd ?? state.workdir;
88
+ const envPrefix = buildEnvPrefix(opts?.env);
89
+ const fullCmd = `cd ${cwd} && ${envPrefix}${cmdStr}`;
90
+ return this.sshExec(conn, fullCmd, opts);
91
+ }
92
+ async destroy(externalId) {
93
+ const state = this.nodes.get(externalId);
94
+ if (!state)
95
+ return; // idempotent
96
+ try {
97
+ const conn = await this.getConnection(state.host);
98
+ // Kill managed process if any
99
+ if (state.pid) {
100
+ await this.sshExec(conn, `kill ${state.pid} 2>/dev/null || true`);
101
+ }
102
+ // Remove working directory
103
+ await this.sshExec(conn, `rm -rf ${state.workdir}`);
104
+ }
105
+ catch {
106
+ // Best-effort cleanup
107
+ }
108
+ this.nodes.delete(externalId);
109
+ }
110
+ async inspect(externalId) {
111
+ const state = this.nodes.get(externalId);
112
+ if (!state) {
113
+ return { status: "terminated" };
114
+ }
115
+ try {
116
+ const conn = await this.getConnection(state.host);
117
+ if (state.pid) {
118
+ // Check if process is still running
119
+ const result = await this.sshExec(conn, `kill -0 ${state.pid} 2>/dev/null && echo running || echo stopped`);
120
+ const status = result.stdout.trim() === "running" ? "running" : "stopped";
121
+ return {
122
+ status,
123
+ startedAt: state.startedAt,
124
+ };
125
+ }
126
+ // No PID tracked — just check SSH connectivity
127
+ await this.sshExec(conn, "echo ok");
128
+ return {
129
+ status: "running",
130
+ startedAt: state.startedAt,
131
+ };
132
+ }
133
+ catch {
134
+ return { status: "unknown" };
135
+ }
136
+ }
137
+ // ── Optional: stop / start ──────────────────────────────────────
138
+ async stop(externalId) {
139
+ const state = this.getNodeState(externalId);
140
+ if (!state.pid)
141
+ return;
142
+ const conn = await this.getConnection(state.host);
143
+ await this.sshExec(conn, `kill ${state.pid} 2>/dev/null || true`);
144
+ state.pid = undefined;
145
+ }
146
+ async start(externalId) {
147
+ const state = this.getNodeState(externalId);
148
+ const conn = await this.getConnection(state.host);
149
+ // Re-run the original command if one was stored
150
+ if (state.command) {
151
+ const envPrefix = state.envPrefix ?? "";
152
+ const result = await this.sshExec(conn, `cd ${state.workdir} && ${envPrefix}nohup ${state.command} > ${state.workdir}/.stdout 2> ${state.workdir}/.stderr & echo $!`);
153
+ const pid = parseInt(result.stdout.trim(), 10);
154
+ if (!isNaN(pid)) {
155
+ state.pid = pid;
156
+ }
157
+ }
158
+ else {
159
+ // No command to restart, just verify connectivity
160
+ await this.sshExec(conn, "echo ok");
161
+ }
162
+ }
163
+ // ── Optional: healthCheck ───────────────────────────────────────
164
+ async healthCheck() {
165
+ const start = Date.now();
166
+ const results = [];
167
+ for (const host of this.config.hosts) {
168
+ try {
169
+ const conn = await this.getConnection(host);
170
+ await this.sshExec(conn, "echo ok");
171
+ results.push({ host: host.host, ok: true });
172
+ }
173
+ catch (err) {
174
+ results.push({
175
+ host: host.host,
176
+ ok: false,
177
+ error: err instanceof Error ? err.message : String(err),
178
+ });
179
+ }
180
+ }
181
+ const allHealthy = results.every((r) => r.ok);
182
+ const unhealthy = results.filter((r) => !r.ok);
183
+ return {
184
+ healthy: allHealthy,
185
+ latencyMs: Date.now() - start,
186
+ message: allHealthy
187
+ ? undefined
188
+ : `Unhealthy hosts: ${unhealthy.map((r) => `${r.host} (${r.error})`).join(", ")}`,
189
+ };
190
+ }
191
+ // ── Optional: extensions ────────────────────────────────────────
192
+ getExtension(externalId, extension) {
193
+ if (extension === "files") {
194
+ return this.createFileExtension(externalId);
195
+ }
196
+ if (extension === "network") {
197
+ return this.createNetworkExtension(externalId);
198
+ }
199
+ return undefined;
200
+ }
201
+ /** Close all SSH connections. Call when shutting down the provider. */
202
+ async close() {
203
+ for (const conn of this.connections.values()) {
204
+ conn.end();
205
+ }
206
+ this.connections.clear();
207
+ this.nodes.clear();
208
+ }
209
+ // ── Private helpers ─────────────────────────────────────────────
210
+ createNetworkExtension(externalId) {
211
+ const state = this.getNodeState(externalId);
212
+ const host = state.host.host;
213
+ return {
214
+ async getUrl(port) {
215
+ return `http://${host}:${port}`;
216
+ },
217
+ };
218
+ }
219
+ selectHost(spec) {
220
+ let candidates = [...this.config.hosts];
221
+ // Filter by GPU if requested
222
+ if (spec.gpu && spec.gpu.count > 0) {
223
+ candidates = candidates.filter((h) => h.gpuAvailable);
224
+ if (candidates.length === 0) {
225
+ throw new Error("[ssh] no hosts with GPU available");
226
+ }
227
+ }
228
+ // Filter by labels if specified
229
+ if (spec.labels) {
230
+ candidates = candidates.filter((h) => {
231
+ if (!h.labels)
232
+ return false;
233
+ return Object.entries(spec.labels).every(([k, v]) => h.labels[k] === v);
234
+ });
235
+ if (candidates.length === 0) {
236
+ throw new Error("[ssh] no hosts matching labels");
237
+ }
238
+ }
239
+ if (candidates.length === 0) {
240
+ throw new Error("[ssh] no hosts available");
241
+ }
242
+ // Round-robin selection
243
+ const host = candidates[this.roundRobinIdx % candidates.length];
244
+ this.roundRobinIdx++;
245
+ return host;
246
+ }
247
+ getNodeState(externalId) {
248
+ const state = this.nodes.get(externalId);
249
+ if (!state) {
250
+ throw new Error(`[ssh] node not found: ${externalId}`);
251
+ }
252
+ return state;
253
+ }
254
+ async getConnection(host) {
255
+ const key = hostKeyOf(host);
256
+ const existing = this.connections.get(key);
257
+ if (existing)
258
+ return existing;
259
+ const conn = new ssh2_1.Client();
260
+ return new Promise((resolve, reject) => {
261
+ conn.on("ready", () => {
262
+ this.connections.set(key, conn);
263
+ resolve(conn);
264
+ });
265
+ conn.on("error", (err) => {
266
+ this.connections.delete(key);
267
+ reject(new Error(`[ssh] connection error (${key}): ${err.message}`));
268
+ });
269
+ conn.on("close", () => {
270
+ this.connections.delete(key);
271
+ });
272
+ conn.connect({
273
+ host: host.host,
274
+ port: host.port ?? 22,
275
+ username: host.username ?? this.config.defaultUser,
276
+ privateKey: host.privateKey ?? this.config.defaultPrivateKey,
277
+ password: host.password,
278
+ readyTimeout: this.config.connectTimeoutMs ?? 20000,
279
+ keepaliveInterval: this.config.keepaliveIntervalMs ?? 10000,
280
+ keepaliveCountMax: 3,
281
+ });
282
+ });
283
+ }
284
+ async ensureTailscale(conn) {
285
+ // Check if tailscale is installed
286
+ const check = await this.sshExec(conn, "which tailscale 2>/dev/null");
287
+ if (check.exitCode !== 0) {
288
+ // Install Tailscale via official install script
289
+ const install = await this.sshExec(conn, "curl -fsSL https://tailscale.com/install.sh | sh", { timeoutMs: 120000 });
290
+ if (install.exitCode !== 0) {
291
+ throw new Error(`[ssh] failed to install tailscale: ${install.stderr.trim()}`);
292
+ }
293
+ }
294
+ // Ensure tailscaled is running
295
+ const daemonCheck = await this.sshExec(conn, "pgrep tailscaled >/dev/null 2>&1 || sudo tailscaled --state=/var/lib/tailscale/tailscaled.state &", { timeoutMs: 10000 });
296
+ if (daemonCheck.exitCode !== 0) {
297
+ // Try systemd as fallback
298
+ await this.sshExec(conn, "sudo systemctl start tailscaled 2>/dev/null || true", {
299
+ timeoutMs: 10000,
300
+ });
301
+ }
302
+ }
303
+ async tailscaleUp(conn, authKey) {
304
+ const result = await this.sshExec(conn, `sudo tailscale up --authkey=${authKey}`, { timeoutMs: 30000 });
305
+ if (result.exitCode !== 0) {
306
+ throw new Error(`[ssh] tailscale up failed: ${result.stderr.trim()}`);
307
+ }
308
+ }
309
+ sshExec(conn, command, opts) {
310
+ return new Promise((resolve, reject) => {
311
+ let settled = false;
312
+ let timer;
313
+ const settle = (fn) => {
314
+ if (settled)
315
+ return;
316
+ settled = true;
317
+ if (timer)
318
+ clearTimeout(timer);
319
+ fn();
320
+ };
321
+ conn.exec(command, (err, stream) => {
322
+ if (err) {
323
+ return settle(() => reject(new Error(`[ssh] exec failed: ${err.message}`)));
324
+ }
325
+ let stdout = "";
326
+ let stderr = "";
327
+ // Enforce timeout
328
+ if (opts?.timeoutMs && opts.timeoutMs > 0) {
329
+ timer = setTimeout(() => {
330
+ stream.close();
331
+ settle(() => resolve({
332
+ exitCode: 124, // Conventional timeout exit code
333
+ stdout,
334
+ stderr: stderr + `\n[ssh] command timed out after ${opts.timeoutMs}ms`,
335
+ }));
336
+ }, opts.timeoutMs);
337
+ }
338
+ stream.on("data", (data) => {
339
+ const str = data.toString();
340
+ stdout += str;
341
+ opts?.onStdout?.(str);
342
+ });
343
+ stream.stderr.on("data", (data) => {
344
+ const str = data.toString();
345
+ stderr += str;
346
+ opts?.onStderr?.(str);
347
+ });
348
+ stream.on("close", (code) => {
349
+ settle(() => resolve({ exitCode: code ?? 1, stdout, stderr }));
350
+ });
351
+ });
352
+ });
353
+ }
354
+ getSftp(conn) {
355
+ return new Promise((resolve, reject) => {
356
+ conn.sftp((err, sftp) => {
357
+ if (err)
358
+ return reject(new Error(`[ssh] SFTP failed: ${err.message}`));
359
+ resolve(sftp);
360
+ });
361
+ });
362
+ }
363
+ createFileExtension(externalId) {
364
+ const provider = this;
365
+ return {
366
+ async read(path) {
367
+ const state = provider.getNodeState(externalId);
368
+ const conn = await provider.getConnection(state.host);
369
+ const sftp = await provider.getSftp(conn);
370
+ return new Promise((resolve, reject) => {
371
+ sftp.readFile(path, (err, data) => {
372
+ if (err)
373
+ return reject(err);
374
+ resolve(data.toString());
375
+ });
376
+ });
377
+ },
378
+ async write(path, content) {
379
+ const state = provider.getNodeState(externalId);
380
+ const conn = await provider.getConnection(state.host);
381
+ const sftp = await provider.getSftp(conn);
382
+ return new Promise((resolve, reject) => {
383
+ sftp.writeFile(path, typeof content === "string" ? content : content, (err) => {
384
+ if (err)
385
+ return reject(err);
386
+ resolve();
387
+ });
388
+ });
389
+ },
390
+ async list(dirPath) {
391
+ const state = provider.getNodeState(externalId);
392
+ const conn = await provider.getConnection(state.host);
393
+ const sftp = await provider.getSftp(conn);
394
+ return new Promise((resolve, reject) => {
395
+ sftp.readdir(dirPath, (err, list) => {
396
+ if (err)
397
+ return reject(err);
398
+ resolve(list.map((entry) => ({
399
+ name: entry.filename,
400
+ path: `${dirPath}/${entry.filename}`,
401
+ type: (entry.longname.startsWith("d")
402
+ ? "directory"
403
+ : "file"),
404
+ size: entry.attrs.size,
405
+ })));
406
+ });
407
+ });
408
+ },
409
+ async remove(path) {
410
+ const state = provider.getNodeState(externalId);
411
+ const conn = await provider.getConnection(state.host);
412
+ const sftp = await provider.getSftp(conn);
413
+ return new Promise((resolve, reject) => {
414
+ sftp.unlink(path, (err) => {
415
+ if (err)
416
+ return reject(err);
417
+ resolve();
418
+ });
419
+ });
420
+ },
421
+ async mkdir(dirPath) {
422
+ const state = provider.getNodeState(externalId);
423
+ const conn = await provider.getConnection(state.host);
424
+ // Use exec with mkdir -p for recursive directory creation
425
+ await provider.sshExec(conn, `mkdir -p '${dirPath.replace(/'/g, "'\\''")}'`);
426
+ },
427
+ };
428
+ }
429
+ }
430
+ exports.SSHProvider = SSHProvider;
431
+ // ── Utility functions ───────────────────────────────────────────────
432
+ function hostKeyOf(host) {
433
+ return `${host.host}:${host.port ?? 22}`;
434
+ }
435
+ function buildEnvPrefix(env) {
436
+ if (!env)
437
+ return "";
438
+ return (Object.entries(env)
439
+ .map(([k, v]) => `${k}='${v.replace(/'/g, "'\\''")}'`)
440
+ .join(" ") + " ");
441
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "openlattice-ssh",
3
+ "version": "0.0.3",
4
+ "description": "SSH 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": "TEST_SSH=1 vitest run tests/integration.test.ts tests/conformance.test.ts"
17
+ },
18
+ "keywords": [
19
+ "compute",
20
+ "ssh",
21
+ "openlattice",
22
+ "provider"
23
+ ],
24
+ "license": "ISC",
25
+ "peerDependencies": {
26
+ "openlattice": "^0.0.3"
27
+ },
28
+ "dependencies": {
29
+ "ssh2": "^1.16.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/ssh2": "^1.15.5",
33
+ "@types/node": "^25.2.2",
34
+ "openlattice": "^0.0.3",
35
+ "typescript": "^5.9.3",
36
+ "vitest": "^4.0.18"
37
+ }
38
+ }
package/src/config.ts ADDED
@@ -0,0 +1,42 @@
1
+ export interface SSHProviderConfig {
2
+ /** SSH connection targets. At least one required. */
3
+ hosts: SSHHostConfig[];
4
+
5
+ /** Default user for SSH connections. */
6
+ defaultUser?: string;
7
+
8
+ /** Default private key content (string or Buffer). */
9
+ defaultPrivateKey?: string | Buffer;
10
+
11
+ /** Path to default private key file. Used if defaultPrivateKey is not set. */
12
+ defaultKeyPath?: string;
13
+
14
+ /** Connection timeout in ms. Default: 20_000. */
15
+ connectTimeoutMs?: number;
16
+
17
+ /** Keepalive interval in ms. Default: 10_000. */
18
+ keepaliveIntervalMs?: number;
19
+ }
20
+
21
+ export interface SSHHostConfig {
22
+ /** Hostname or IP address. */
23
+ host: string;
24
+
25
+ /** SSH port. Default: 22. */
26
+ port?: number;
27
+
28
+ /** Username. Overrides defaultUser. */
29
+ username?: string;
30
+
31
+ /** Private key (content or path). Overrides defaultPrivateKey. */
32
+ privateKey?: string | Buffer;
33
+
34
+ /** Password (not recommended). */
35
+ password?: string;
36
+
37
+ /** Labels for host selection. */
38
+ labels?: Record<string, string>;
39
+
40
+ /** Whether this host has GPU. */
41
+ gpuAvailable?: boolean;
42
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { SSHProvider } from "./ssh-provider";
2
+ export type { SSHProviderConfig, SSHHostConfig } from "./config";