@dbx-tools/lakebox 0.0.0-stage → 0.9.132
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/LICENSE +202 -0
- package/README.md +146 -2
- package/index.ts +18 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.js +15 -0
- package/lib/src/_ssh.d.ts +152 -0
- package/lib/src/_ssh.js +489 -0
- package/lib/src/_ssh_openssh.d.ts +18 -0
- package/lib/src/_ssh_openssh.js +382 -0
- package/lib/src/_ssh_ssh2.d.ts +16 -0
- package/lib/src/_ssh_ssh2.js +225 -0
- package/lib/src/key-store.d.ts +39 -0
- package/lib/src/key-store.js +145 -0
- package/lib/src/lifecycle.d.ts +18 -0
- package/lib/src/lifecycle.js +75 -0
- package/lib/src/rest.d.ts +81 -0
- package/lib/src/rest.js +199 -0
- package/lib/src/session.d.ts +89 -0
- package/lib/src/session.js +309 -0
- package/lib/src/transport.d.ts +15 -0
- package/lib/src/transport.js +16 -0
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/package.json +117 -4
- package/src/_ssh.ts +610 -0
- package/src/_ssh_openssh.ts +471 -0
- package/src/_ssh_ssh2.ts +273 -0
- package/src/key-store.ts +177 -0
- package/src/lifecycle.ts +88 -0
- package/src/rest.ts +264 -0
- package/src/session.ts +371 -0
- package/src/transport.ts +25 -0
package/src/_ssh_ssh2.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import type { Duplex } from "node:stream";
|
|
2
|
+
import { log } from "@dbx-tools/shared-core";
|
|
3
|
+
import type { Client as Ssh2Client, ClientChannel } from "ssh2";
|
|
4
|
+
import {
|
|
5
|
+
PooledSshTransport,
|
|
6
|
+
type SshCommandExecution,
|
|
7
|
+
type SshPoolOptions,
|
|
8
|
+
type SshResourceLifecycle,
|
|
9
|
+
type SshTransportOptions,
|
|
10
|
+
} from "./_ssh.ts";
|
|
11
|
+
|
|
12
|
+
const logger = log.logger("lakebox:ssh2");
|
|
13
|
+
const patchedSsh2Protocols = new WeakSet<Ssh2Protocol>();
|
|
14
|
+
|
|
15
|
+
interface Ssh2Resource {
|
|
16
|
+
readonly client: Ssh2Client;
|
|
17
|
+
alive: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Exclusive ssh2 connection pool for commands and direct TCP forwarding. */
|
|
21
|
+
export class Ssh2Transport extends PooledSshTransport<Ssh2Resource> {
|
|
22
|
+
readonly kind = "ssh2" as const;
|
|
23
|
+
private readonly connectTimeoutMs: number;
|
|
24
|
+
|
|
25
|
+
constructor(options: SshTransportOptions, poolOptions: SshPoolOptions = {}) {
|
|
26
|
+
super(ssh2Lifecycle(options), {
|
|
27
|
+
acquireTimeoutMillis: options.connectTimeoutMs,
|
|
28
|
+
...poolOptions,
|
|
29
|
+
});
|
|
30
|
+
this.connectTimeoutMs = options.connectTimeoutMs;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
protected runWithResource(
|
|
34
|
+
resource: Ssh2Resource,
|
|
35
|
+
script: string,
|
|
36
|
+
execution: SshCommandExecution,
|
|
37
|
+
): Promise<number> {
|
|
38
|
+
return executeCommand(resource, script, execution);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
protected connectWithResource(
|
|
42
|
+
resource: Ssh2Resource,
|
|
43
|
+
remoteHost: string,
|
|
44
|
+
remotePort: number,
|
|
45
|
+
): Promise<Duplex> {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
let settled = false;
|
|
48
|
+
let timeout: ReturnType<typeof setTimeout>;
|
|
49
|
+
const cleanup = () => {
|
|
50
|
+
clearTimeout(timeout);
|
|
51
|
+
resource.client.removeListener("close", clientClosed);
|
|
52
|
+
resource.client.removeListener("error", clientFailed);
|
|
53
|
+
};
|
|
54
|
+
const fail = (error: Error) => {
|
|
55
|
+
if (settled) return;
|
|
56
|
+
settled = true;
|
|
57
|
+
cleanup();
|
|
58
|
+
reject(error);
|
|
59
|
+
};
|
|
60
|
+
const clientClosed = () => {
|
|
61
|
+
fail(new Error("ssh2 connection closed while opening forwarding"));
|
|
62
|
+
};
|
|
63
|
+
const clientFailed = (error: Error) => {
|
|
64
|
+
fail(error);
|
|
65
|
+
};
|
|
66
|
+
timeout = setTimeout(
|
|
67
|
+
() => fail(new Error(`ssh2 forwarding did not open within ${this.connectTimeoutMs}ms`)),
|
|
68
|
+
this.connectTimeoutMs,
|
|
69
|
+
);
|
|
70
|
+
timeout.unref?.();
|
|
71
|
+
resource.client.once("close", clientClosed);
|
|
72
|
+
resource.client.once("error", clientFailed);
|
|
73
|
+
resource.client.forwardOut("127.0.0.1", 0, remoteHost, remotePort, (error, channel) => {
|
|
74
|
+
if (settled) {
|
|
75
|
+
channel?.destroy();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
settled = true;
|
|
79
|
+
cleanup();
|
|
80
|
+
if (error) reject(error);
|
|
81
|
+
else resolve(channel);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function ssh2Lifecycle(options: SshTransportOptions): SshResourceLifecycle<Ssh2Resource> {
|
|
88
|
+
const patchMode = options.ssh2Patch ?? "auto";
|
|
89
|
+
const requirePatch = patchMode === "true";
|
|
90
|
+
const patchCommands = requirePatch || (patchMode === "auto" && isNodeDevelopment());
|
|
91
|
+
let initialClient: Ssh2Client | undefined = options.ssh2Client;
|
|
92
|
+
const initialPatched = patchCommands ? patchSsh2ExecWithoutReply(options.ssh2Client) : false;
|
|
93
|
+
if (requirePatch && !initialPatched) {
|
|
94
|
+
options.ssh2Client.destroy();
|
|
95
|
+
throw new Error("Installed ssh2 internals do not support the required exec no-reply patch");
|
|
96
|
+
}
|
|
97
|
+
let logged = false;
|
|
98
|
+
return {
|
|
99
|
+
create: async () => {
|
|
100
|
+
const seeded = initialClient !== undefined;
|
|
101
|
+
const client = initialClient ?? (await options.createSsh2CommandClient());
|
|
102
|
+
initialClient = undefined;
|
|
103
|
+
const resource = trackClient(client);
|
|
104
|
+
const patched = seeded
|
|
105
|
+
? initialPatched
|
|
106
|
+
: patchCommands
|
|
107
|
+
? patchSsh2ExecWithoutReply(client)
|
|
108
|
+
: false;
|
|
109
|
+
if (requirePatch && !patched) {
|
|
110
|
+
client.destroy();
|
|
111
|
+
throw new Error("Installed ssh2 internals do not support the required exec no-reply patch");
|
|
112
|
+
}
|
|
113
|
+
if (!logged) {
|
|
114
|
+
logged = true;
|
|
115
|
+
logger.warn("using pooled ssh2 Lakebox transport", {
|
|
116
|
+
patchMode,
|
|
117
|
+
patched,
|
|
118
|
+
nodeEnv: process.env.NODE_ENV,
|
|
119
|
+
transportMode: options.transport ?? "auto",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return resource;
|
|
123
|
+
},
|
|
124
|
+
destroy: (resource) => closeClient(resource, options.connectTimeoutMs),
|
|
125
|
+
// A remote no-op would consume the connection's only permitted operation.
|
|
126
|
+
test: async (resource) => resource.alive,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function trackClient(client: Ssh2Client): Ssh2Resource {
|
|
131
|
+
const resource: Ssh2Resource = { client, alive: true };
|
|
132
|
+
const markClosed = () => {
|
|
133
|
+
resource.alive = false;
|
|
134
|
+
};
|
|
135
|
+
client.once("close", markClosed);
|
|
136
|
+
client.once("error", markClosed);
|
|
137
|
+
return resource;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function closeClient(resource: Ssh2Resource, timeoutMs: number): Promise<void> {
|
|
141
|
+
if (!resource.alive) {
|
|
142
|
+
resource.client.destroy();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
await new Promise<void>((resolve) => {
|
|
146
|
+
let settled = false;
|
|
147
|
+
const finish = () => {
|
|
148
|
+
if (settled) return;
|
|
149
|
+
settled = true;
|
|
150
|
+
clearTimeout(timeout);
|
|
151
|
+
resource.alive = false;
|
|
152
|
+
resolve();
|
|
153
|
+
};
|
|
154
|
+
const timeout = setTimeout(
|
|
155
|
+
() => {
|
|
156
|
+
resource.client.destroy();
|
|
157
|
+
finish();
|
|
158
|
+
},
|
|
159
|
+
Math.min(timeoutMs, 1_000),
|
|
160
|
+
);
|
|
161
|
+
timeout.unref?.();
|
|
162
|
+
resource.client.once("close", finish);
|
|
163
|
+
resource.client.once("error", finish);
|
|
164
|
+
resource.client.end();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function executeCommand(
|
|
169
|
+
resource: Ssh2Resource,
|
|
170
|
+
script: string,
|
|
171
|
+
execution: SshCommandExecution,
|
|
172
|
+
): Promise<number> {
|
|
173
|
+
return new Promise<number>((resolve, reject) => {
|
|
174
|
+
let channel: ClientChannel | undefined;
|
|
175
|
+
let exitCode: number | undefined;
|
|
176
|
+
let settled = false;
|
|
177
|
+
const cleanup = () => {
|
|
178
|
+
resource.client.removeListener("close", clientClosed);
|
|
179
|
+
resource.client.removeListener("error", clientClosed);
|
|
180
|
+
};
|
|
181
|
+
const finish = (code: number) => {
|
|
182
|
+
if (settled) return;
|
|
183
|
+
settled = true;
|
|
184
|
+
cleanup();
|
|
185
|
+
resolve(code);
|
|
186
|
+
};
|
|
187
|
+
const fail = (error: Error) => {
|
|
188
|
+
if (settled) return;
|
|
189
|
+
settled = true;
|
|
190
|
+
cleanup();
|
|
191
|
+
reject(error);
|
|
192
|
+
};
|
|
193
|
+
const clientClosed = () => fail(new Error("ssh2 connection closed during command execution"));
|
|
194
|
+
resource.client.once("close", clientClosed);
|
|
195
|
+
resource.client.once("error", clientClosed);
|
|
196
|
+
execution.setCancel(() => {
|
|
197
|
+
if (settled) return;
|
|
198
|
+
try {
|
|
199
|
+
channel?.signal("KILL");
|
|
200
|
+
} catch {
|
|
201
|
+
// The remote may close the channel before the signal is written.
|
|
202
|
+
}
|
|
203
|
+
finish(130);
|
|
204
|
+
channel?.close();
|
|
205
|
+
});
|
|
206
|
+
if (execution.signal.aborted) {
|
|
207
|
+
finish(130);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
resource.client.exec(script, (error, openedChannel) => {
|
|
211
|
+
if (error) {
|
|
212
|
+
fail(error);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (settled) {
|
|
216
|
+
openedChannel.close();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
channel = openedChannel;
|
|
220
|
+
channel.on("data", execution.stdout);
|
|
221
|
+
channel.stderr.on("data", execution.stderr);
|
|
222
|
+
channel.on("exit", (code: number | null) => {
|
|
223
|
+
exitCode = code ?? 1;
|
|
224
|
+
});
|
|
225
|
+
channel.once("error", fail);
|
|
226
|
+
channel.once("close", () => finish(exitCode ?? 1));
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Apply the Databricks gateway exec-request workaround when internals permit it. */
|
|
232
|
+
function patchSsh2ExecWithoutReply(client: Ssh2Client): boolean {
|
|
233
|
+
const internals = client as unknown as Ssh2ClientInternals;
|
|
234
|
+
const protocol = internals._protocol;
|
|
235
|
+
const channelManager = internals._chanMgr;
|
|
236
|
+
if (!protocol || typeof protocol.exec !== "function" || !channelManager) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
if (patchedSsh2Protocols.has(protocol)) return true;
|
|
240
|
+
const originalExec = protocol.exec.bind(protocol);
|
|
241
|
+
protocol.exec = (channelId, command) => {
|
|
242
|
+
originalExec(channelId, command, false);
|
|
243
|
+
const channel = Object.values(channelManager._channels).find(
|
|
244
|
+
(candidate) => candidate?.outgoing.id === channelId,
|
|
245
|
+
);
|
|
246
|
+
const callback = channel?._callbacks.shift();
|
|
247
|
+
if (!callback) {
|
|
248
|
+
throw new Error("ssh2 exec callback queue was not available for the no-reply workaround");
|
|
249
|
+
}
|
|
250
|
+
queueMicrotask(() => callback(false));
|
|
251
|
+
};
|
|
252
|
+
patchedSsh2Protocols.add(protocol);
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isNodeDevelopment(): boolean {
|
|
257
|
+
const nodeEnv = process.env.NODE_ENV?.trim().toLowerCase();
|
|
258
|
+
return nodeEnv === "development" || nodeEnv === "dev";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
interface Ssh2ExecChannel {
|
|
262
|
+
outgoing: { id: number };
|
|
263
|
+
_callbacks: Array<(hadError: boolean | Error) => void>;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
interface Ssh2Protocol {
|
|
267
|
+
exec(channelId: number, command: string | Buffer, wantReply?: boolean): void;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
interface Ssh2ClientInternals {
|
|
271
|
+
_protocol?: Ssh2Protocol;
|
|
272
|
+
_chanMgr?: { _channels: Record<number, Ssh2ExecChannel | undefined> };
|
|
273
|
+
}
|
package/src/key-store.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileLock } from "@dbx-tools/core";
|
|
6
|
+
import ssh2 from "ssh2";
|
|
7
|
+
import type { LakeboxClient } from "./rest.ts";
|
|
8
|
+
|
|
9
|
+
const { utils } = ssh2;
|
|
10
|
+
|
|
11
|
+
interface StoredKey {
|
|
12
|
+
privateKey: string;
|
|
13
|
+
privateKeyPath: string;
|
|
14
|
+
publicKey: string;
|
|
15
|
+
keyHash: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Stores caller-scoped SSH keys and trusted Databricks gateway host keys.
|
|
20
|
+
*
|
|
21
|
+
* Each workspace and caller identity receives a separate Ed25519 key pair. The
|
|
22
|
+
* store writes private keys, gateway fingerprints, and OpenSSH known-host files
|
|
23
|
+
* with owner-only permissions beneath the configured root directory.
|
|
24
|
+
*/
|
|
25
|
+
export class SshKeyStore {
|
|
26
|
+
/** Creates a key store rooted at the default dbx-tools application-data path. */
|
|
27
|
+
constructor(private readonly root = join(homedir(), ".dbx-tools", "lakebox")) {}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Loads or creates the caller key pair and registers its public key when the
|
|
31
|
+
* workspace does not already contain the matching key hash.
|
|
32
|
+
*
|
|
33
|
+
* @returns The local key material and the gateway host returned by Databricks,
|
|
34
|
+
* when available.
|
|
35
|
+
*/
|
|
36
|
+
async ensureRegistered(
|
|
37
|
+
api: LakeboxClient,
|
|
38
|
+
owner: string,
|
|
39
|
+
signal?: AbortSignal,
|
|
40
|
+
): Promise<StoredKey & { gatewayHost?: string }> {
|
|
41
|
+
const key = await this.loadOrCreate(await api.workspaceScope(), owner);
|
|
42
|
+
const keys = await api.listKeys(signal);
|
|
43
|
+
const existing = keys.find((entry) => entry.keyHash === key.keyHash);
|
|
44
|
+
if (existing) {
|
|
45
|
+
return { ...key, ...(existing.gatewayHost ? { gatewayHost: existing.gatewayHost } : {}) };
|
|
46
|
+
}
|
|
47
|
+
const registered = await api.registerKey(
|
|
48
|
+
key.publicKey,
|
|
49
|
+
`dbx-tools-lakebox:${key.keyHash}`,
|
|
50
|
+
signal,
|
|
51
|
+
);
|
|
52
|
+
return {
|
|
53
|
+
...key,
|
|
54
|
+
...(registered.gatewayHost ? { gatewayHost: registered.gatewayHost } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Returns the stored SHA-256 gateway fingerprint, or `undefined` when untrusted. */
|
|
59
|
+
async knownHost(
|
|
60
|
+
scope: string,
|
|
61
|
+
owner: string,
|
|
62
|
+
host: string,
|
|
63
|
+
port: number,
|
|
64
|
+
): Promise<string | undefined> {
|
|
65
|
+
try {
|
|
66
|
+
const value = await readFile(this.hostFingerprintPath(scope, owner, host, port), "utf8");
|
|
67
|
+
return value.trim() || undefined;
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Returns the OpenSSH known-host file path for one workspace, user, and gateway. */
|
|
74
|
+
knownHostsPath(scope: string, owner: string, host: string, port: number): string {
|
|
75
|
+
return join(this.directory(scope, owner), `host-${digest(`${host}\0${port}`)}.known_hosts`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Stores the accepted gateway fingerprint and OpenSSH host-key entry. */
|
|
79
|
+
async rememberHost(
|
|
80
|
+
scope: string,
|
|
81
|
+
owner: string,
|
|
82
|
+
host: string,
|
|
83
|
+
port: number,
|
|
84
|
+
fingerprint: string,
|
|
85
|
+
key: Buffer,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
const directory = this.directory(scope, owner);
|
|
88
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
89
|
+
const fingerprintPath = this.hostFingerprintPath(scope, owner, host, port);
|
|
90
|
+
const knownHostsPath = this.knownHostsPath(scope, owner, host, port);
|
|
91
|
+
const knownHost = `[${host}]:${port} ${sshKeyAlgorithm(key)} ${key.toString("base64")}\n`;
|
|
92
|
+
await Promise.all([
|
|
93
|
+
writeFile(fingerprintPath, `${fingerprint}\n`, { mode: 0o600 }),
|
|
94
|
+
writeFile(knownHostsPath, knownHost, { mode: 0o600 }),
|
|
95
|
+
]);
|
|
96
|
+
await Promise.all([chmod(fingerprintPath, 0o600), chmod(knownHostsPath, 0o600)]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async loadOrCreate(scope: string, owner: string): Promise<StoredKey> {
|
|
100
|
+
const directory = this.directory(scope, owner);
|
|
101
|
+
const privatePath = join(directory, "id_ed25519");
|
|
102
|
+
const publicPath = `${privatePath}.pub`;
|
|
103
|
+
const stored = await readStoredKey(privatePath, publicPath);
|
|
104
|
+
if (stored) return stored;
|
|
105
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
106
|
+
return fileLock.withFileLock(["lakebox-ssh-key", privatePath], async () => {
|
|
107
|
+
const rechecked = await readStoredKey(privatePath, publicPath);
|
|
108
|
+
if (rechecked) return rechecked;
|
|
109
|
+
|
|
110
|
+
const generated = utils.generateKeyPairSync("ed25519");
|
|
111
|
+
const privateKey = generated.private;
|
|
112
|
+
const publicKey = generated.public;
|
|
113
|
+
const suffix = `${process.pid}-${randomBytes(8).toString("hex")}`;
|
|
114
|
+
const stagedPrivate = `${privatePath}.${suffix}`;
|
|
115
|
+
const stagedPublic = `${publicPath}.${suffix}`;
|
|
116
|
+
try {
|
|
117
|
+
await writeFile(stagedPrivate, privateKey, { mode: 0o600 });
|
|
118
|
+
await writeFile(stagedPublic, `${publicKey.trim()}\n`, { mode: 0o600 });
|
|
119
|
+
await rename(stagedPrivate, privatePath);
|
|
120
|
+
await rename(stagedPublic, publicPath);
|
|
121
|
+
await Promise.all([chmod(privatePath, 0o600), chmod(publicPath, 0o600)]);
|
|
122
|
+
const created = await readStoredKey(privatePath, publicPath);
|
|
123
|
+
if (!created) throw new Error("SSH key files were not published atomically");
|
|
124
|
+
return created;
|
|
125
|
+
} finally {
|
|
126
|
+
await Promise.all([rm(stagedPrivate, { force: true }), rm(stagedPublic, { force: true })]);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private hostFingerprintPath(scope: string, owner: string, host: string, port: number): string {
|
|
132
|
+
return join(this.directory(scope, owner), `host-${digest(`${host}\0${port}`)}.sha256`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private directory(scope: string, owner: string): string {
|
|
136
|
+
return join(this.root, digest(`${scope}\0${owner}`));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function readStoredKey(
|
|
141
|
+
privatePath: string,
|
|
142
|
+
publicPath: string,
|
|
143
|
+
): Promise<StoredKey | undefined> {
|
|
144
|
+
try {
|
|
145
|
+
const [privateKey, publicKey] = await Promise.all([
|
|
146
|
+
readFile(privatePath, "utf8"),
|
|
147
|
+
readFile(publicPath, "utf8"),
|
|
148
|
+
]);
|
|
149
|
+
return {
|
|
150
|
+
privateKey,
|
|
151
|
+
privateKeyPath: privatePath,
|
|
152
|
+
publicKey,
|
|
153
|
+
keyHash: publicKeyHash(publicKey),
|
|
154
|
+
};
|
|
155
|
+
} catch {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function publicKeyHash(publicKey: string): string {
|
|
161
|
+
const normalized = publicKey.trim().split(/\s+/).slice(0, 2).join(" ");
|
|
162
|
+
return createHash("sha256").update(normalized).digest().subarray(0, 16).toString("hex");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function digest(value: string): string {
|
|
166
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 32);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sshKeyAlgorithm(key: Buffer): string {
|
|
170
|
+
if (key.length < 5) throw new Error("SSH gateway returned an invalid host key");
|
|
171
|
+
const length = key.readUInt32BE(0);
|
|
172
|
+
const end = 4 + length;
|
|
173
|
+
if (length === 0 || end > key.length) throw new Error("SSH gateway returned an invalid host key");
|
|
174
|
+
const algorithm = key.subarray(4, end).toString("ascii");
|
|
175
|
+
if (/\s/.test(algorithm)) throw new Error("SSH gateway returned an invalid host key algorithm");
|
|
176
|
+
return algorithm;
|
|
177
|
+
}
|
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { asyncUtils } from "@dbx-tools/shared-core";
|
|
2
|
+
import type { LakeboxStatus } from "@dbx-tools/shared-lakebox";
|
|
3
|
+
import type { LakeboxClient, LakeboxEntry } from "./rest.ts";
|
|
4
|
+
|
|
5
|
+
const POLL_INTERVAL_MS = 1_000;
|
|
6
|
+
const LIFECYCLE_TIMEOUT_MS = 300_000;
|
|
7
|
+
|
|
8
|
+
/** Maps an upstream Lakebox status to the shared lifecycle state. */
|
|
9
|
+
export function normalizeLakeboxStatus(value: string): Exclude<LakeboxStatus, "missing"> {
|
|
10
|
+
switch (value.trim().toLowerCase()) {
|
|
11
|
+
case "starting":
|
|
12
|
+
case "provisioning":
|
|
13
|
+
case "pending":
|
|
14
|
+
return "starting";
|
|
15
|
+
case "running":
|
|
16
|
+
return "running";
|
|
17
|
+
case "stopping":
|
|
18
|
+
return "stopping";
|
|
19
|
+
case "stopped":
|
|
20
|
+
return "stopped";
|
|
21
|
+
case "failed":
|
|
22
|
+
case "error":
|
|
23
|
+
return "error";
|
|
24
|
+
default:
|
|
25
|
+
return "unknown";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Parses an upstream duration such as `600s` into seconds. */
|
|
30
|
+
export function lakeboxIdleTimeoutSeconds(value: string | undefined): number | undefined {
|
|
31
|
+
if (!value?.endsWith("s")) return undefined;
|
|
32
|
+
const seconds = Number(value.slice(0, -1));
|
|
33
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Starts a Lakebox when needed and waits until it is running.
|
|
38
|
+
*
|
|
39
|
+
* @throws When the Lakebox does not reach running within five minutes.
|
|
40
|
+
*/
|
|
41
|
+
export async function ensureLakeboxRunning(
|
|
42
|
+
api: LakeboxClient,
|
|
43
|
+
current: LakeboxEntry,
|
|
44
|
+
signal?: AbortSignal,
|
|
45
|
+
): Promise<LakeboxEntry> {
|
|
46
|
+
let entry = current;
|
|
47
|
+
const status = normalizeLakeboxStatus(entry.status);
|
|
48
|
+
if (status === "running") return entry;
|
|
49
|
+
if (status === "starting") return waitForLakeboxStatus(api, entry, "running", signal);
|
|
50
|
+
if (status === "stopping") entry = await waitForLakeboxStatus(api, entry, "stopped", signal);
|
|
51
|
+
return waitForLakeboxStatus(api, await api.start(entry.sandboxId, signal), "running", signal);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Stops a Lakebox when needed and waits until it is stopped.
|
|
56
|
+
*
|
|
57
|
+
* @throws When the Lakebox does not reach stopped within five minutes.
|
|
58
|
+
*/
|
|
59
|
+
export async function ensureLakeboxStopped(
|
|
60
|
+
api: LakeboxClient,
|
|
61
|
+
current: LakeboxEntry,
|
|
62
|
+
signal?: AbortSignal,
|
|
63
|
+
): Promise<LakeboxEntry> {
|
|
64
|
+
let entry = current;
|
|
65
|
+
const status = normalizeLakeboxStatus(entry.status);
|
|
66
|
+
if (status === "stopped") return entry;
|
|
67
|
+
if (status === "stopping") return waitForLakeboxStatus(api, entry, "stopped", signal);
|
|
68
|
+
if (status === "starting") entry = await waitForLakeboxStatus(api, entry, "running", signal);
|
|
69
|
+
return waitForLakeboxStatus(api, await api.stop(entry.sandboxId, signal), "stopped", signal);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function waitForLakeboxStatus(
|
|
73
|
+
api: LakeboxClient,
|
|
74
|
+
current: LakeboxEntry,
|
|
75
|
+
expected: "running" | "stopped",
|
|
76
|
+
signal?: AbortSignal,
|
|
77
|
+
): Promise<LakeboxEntry> {
|
|
78
|
+
const deadline = Date.now() + LIFECYCLE_TIMEOUT_MS;
|
|
79
|
+
let entry = current;
|
|
80
|
+
while (normalizeLakeboxStatus(entry.status) !== expected) {
|
|
81
|
+
if (Date.now() >= deadline) {
|
|
82
|
+
throw new Error(`Timed out waiting for Lakebox ${entry.sandboxId} to become ${expected}`);
|
|
83
|
+
}
|
|
84
|
+
await asyncUtils.sleep(POLL_INTERVAL_MS, signal);
|
|
85
|
+
entry = await api.get(entry.sandboxId, signal);
|
|
86
|
+
}
|
|
87
|
+
return entry;
|
|
88
|
+
}
|