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.
- package/dist/config.d.ts +30 -0
- package/dist/config.js +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/ssh-provider.d.ts +30 -0
- package/dist/ssh-provider.js +441 -0
- package/package.json +38 -0
- package/src/config.ts +42 -0
- package/src/index.ts +2 -0
- package/src/ssh-provider.ts +559 -0
- package/tests/conformance.test.ts +28 -0
- package/tests/integration.test.ts +152 -0
- package/tests/ssh-provider.test.ts +778 -0
- package/tsconfig.json +16 -0
package/dist/config.d.ts
ADDED
|
@@ -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
package/dist/index.d.ts
ADDED
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