@openkaiden/opnshll-sdk 0.0.110
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/README.md +186 -0
- package/dist/client.d.ts +594 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +930 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +56 -0
- package/dist/errors.js.map +1 -0
- package/dist/gen/datamodel_pb.d.ts +246 -0
- package/dist/gen/datamodel_pb.d.ts.map +1 -0
- package/dist/gen/datamodel_pb.js +58 -0
- package/dist/gen/datamodel_pb.js.map +1 -0
- package/dist/gen/openshell_pb.d.ts +6739 -0
- package/dist/gen/openshell_pb.d.ts.map +1 -0
- package/dist/gen/openshell_pb.js +1180 -0
- package/dist/gen/openshell_pb.js.map +1 -0
- package/dist/gen/options_pb.d.ts +58 -0
- package/dist/gen/options_pb.d.ts.map +1 -0
- package/dist/gen/options_pb.js +24 -0
- package/dist/gen/options_pb.js.map +1 -0
- package/dist/gen/sandbox_pb.d.ts +1069 -0
- package/dist/gen/sandbox_pb.d.ts.map +1 -0
- package/dist/gen/sandbox_pb.js +172 -0
- package/dist/gen/sandbox_pb.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/raw.d.ts +5 -0
- package/dist/raw.d.ts.map +1 -0
- package/dist/raw.js +14 -0
- package/dist/raw.js.map +1 -0
- package/dist/ssh-validate.d.ts +11 -0
- package/dist/ssh-validate.d.ts.map +1 -0
- package/dist/ssh-validate.js +58 -0
- package/dist/ssh-validate.js.map +1 -0
- package/dist/transport.d.ts +29 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +92 -0
- package/dist/transport.js.map +1 -0
- package/package.json +58 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import * as net from 'node:net';
|
|
4
|
+
import { createClient } from '@connectrpc/connect';
|
|
5
|
+
import { errorCode, fromConnect, SdkError } from './errors.js';
|
|
6
|
+
import { OpenShell, SandboxPhase, ServiceStatus, } from './gen/openshell_pb.js';
|
|
7
|
+
import { PolicySource, SettingScope } from './gen/sandbox_pb.js';
|
|
8
|
+
import { validateSshResponse } from './ssh-validate.js';
|
|
9
|
+
import { buildTransport } from './transport.js';
|
|
10
|
+
export { errorCode };
|
|
11
|
+
// ---- enum → lowercase string -----------------------------------------------
|
|
12
|
+
// Exported for the enum-name drift test only; not re-exported from index.ts, so
|
|
13
|
+
// they are not part of the public package API.
|
|
14
|
+
export const PHASE_NAMES = {
|
|
15
|
+
[SandboxPhase.UNSPECIFIED]: 'unspecified',
|
|
16
|
+
[SandboxPhase.PROVISIONING]: 'provisioning',
|
|
17
|
+
[SandboxPhase.READY]: 'ready',
|
|
18
|
+
[SandboxPhase.ERROR]: 'error',
|
|
19
|
+
[SandboxPhase.DELETING]: 'deleting',
|
|
20
|
+
[SandboxPhase.UNKNOWN]: 'unknown',
|
|
21
|
+
[SandboxPhase.STOPPING]: 'stopping',
|
|
22
|
+
[SandboxPhase.STOPPED]: 'stopped',
|
|
23
|
+
[SandboxPhase.STARTING]: 'starting',
|
|
24
|
+
};
|
|
25
|
+
export const STATUS_NAMES = {
|
|
26
|
+
[ServiceStatus.UNSPECIFIED]: 'unspecified',
|
|
27
|
+
[ServiceStatus.HEALTHY]: 'healthy',
|
|
28
|
+
[ServiceStatus.DEGRADED]: 'degraded',
|
|
29
|
+
[ServiceStatus.UNHEALTHY]: 'unhealthy',
|
|
30
|
+
};
|
|
31
|
+
export const SCOPE_NAMES = {
|
|
32
|
+
[SettingScope.UNSPECIFIED]: 'unspecified',
|
|
33
|
+
[SettingScope.SANDBOX]: 'sandbox',
|
|
34
|
+
[SettingScope.GLOBAL]: 'global',
|
|
35
|
+
};
|
|
36
|
+
export const POLICY_SOURCE_NAMES = {
|
|
37
|
+
[PolicySource.UNSPECIFIED]: 'unspecified',
|
|
38
|
+
[PolicySource.SANDBOX]: 'sandbox',
|
|
39
|
+
[PolicySource.GLOBAL]: 'global',
|
|
40
|
+
};
|
|
41
|
+
function phaseName(p) {
|
|
42
|
+
return PHASE_NAMES[p] ?? 'unspecified';
|
|
43
|
+
}
|
|
44
|
+
function statusName(s) {
|
|
45
|
+
return STATUS_NAMES[s] ?? 'unspecified';
|
|
46
|
+
}
|
|
47
|
+
function scopeName(s) {
|
|
48
|
+
return SCOPE_NAMES[s] ?? 'unspecified';
|
|
49
|
+
}
|
|
50
|
+
function policySourceName(s) {
|
|
51
|
+
return POLICY_SOURCE_NAMES[s] ?? 'unspecified';
|
|
52
|
+
}
|
|
53
|
+
function sandboxRef(sandbox) {
|
|
54
|
+
if (!sandbox)
|
|
55
|
+
throw new SdkError('invalid_config', 'sandbox missing from gateway response');
|
|
56
|
+
const meta = sandbox.metadata;
|
|
57
|
+
if (!meta?.id || !meta.name) {
|
|
58
|
+
throw new SdkError('invalid_config', 'sandbox metadata.id and metadata.name are required in gateway responses');
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
id: meta.id,
|
|
62
|
+
name: meta.name,
|
|
63
|
+
phase: phaseName(sandbox.status?.phase ?? SandboxPhase.UNSPECIFIED),
|
|
64
|
+
labels: meta?.labels ?? {},
|
|
65
|
+
resourceVersion: (meta?.resourceVersion ?? 0n).toString(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function providerRef(provider) {
|
|
69
|
+
const meta = provider.metadata;
|
|
70
|
+
return {
|
|
71
|
+
id: meta?.id ?? '',
|
|
72
|
+
name: meta?.name ?? '',
|
|
73
|
+
type: provider.type,
|
|
74
|
+
labels: meta?.labels ?? {},
|
|
75
|
+
resourceVersion: (meta?.resourceVersion ?? 0n).toString(),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function sandboxConfig(resp) {
|
|
79
|
+
const settings = {};
|
|
80
|
+
for (const [key, setting] of Object.entries(resp.settings)) {
|
|
81
|
+
settings[key] = effectiveSetting(setting);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
...(resp.policy ? { policy: resp.policy } : {}),
|
|
85
|
+
version: resp.version,
|
|
86
|
+
policyHash: resp.policyHash,
|
|
87
|
+
settings,
|
|
88
|
+
configRevision: resp.configRevision.toString(),
|
|
89
|
+
policySource: policySourceName(resp.policySource),
|
|
90
|
+
globalPolicyVersion: resp.globalPolicyVersion,
|
|
91
|
+
providerEnvRevision: resp.providerEnvRevision.toString(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function effectiveSetting(setting) {
|
|
95
|
+
return {
|
|
96
|
+
...(setting.value ? { value: setting.value } : {}),
|
|
97
|
+
scope: scopeName(setting.scope),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function updateConfigResult(resp) {
|
|
101
|
+
return {
|
|
102
|
+
version: resp.version,
|
|
103
|
+
policyHash: resp.policyHash,
|
|
104
|
+
settingsRevision: resp.settingsRevision.toString(),
|
|
105
|
+
deleted: resp.deleted,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
// Optimistic-concurrency version pin: absent/empty means 0n (server uses the
|
|
109
|
+
// current version, backward-compatible). A mismatch surfaces as Aborted →
|
|
110
|
+
// SdkError code 'aborted'.
|
|
111
|
+
function versionPin(value) {
|
|
112
|
+
if (!value)
|
|
113
|
+
return 0n;
|
|
114
|
+
let pin;
|
|
115
|
+
try {
|
|
116
|
+
pin = BigInt(value);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// BigInt() throws a raw SyntaxError on non-integer input; keep the SdkError
|
|
120
|
+
// taxonomy intact so callers' errorCode() checks still match.
|
|
121
|
+
throw new SdkError('invalid_config', `expectedResourceVersion is not a u64: '${value}'`);
|
|
122
|
+
}
|
|
123
|
+
if (pin < 0n) {
|
|
124
|
+
throw new SdkError('invalid_config', `expectedResourceVersion is not a u64: '${value}'`);
|
|
125
|
+
}
|
|
126
|
+
return pin;
|
|
127
|
+
}
|
|
128
|
+
const FORWARD_CHUNK = 64 * 1024;
|
|
129
|
+
// Build CallOptions that bound one poll RPC by the remaining wall-clock budget
|
|
130
|
+
// and honor caller cancellation, so a stalled RPC cannot outlive the deadline.
|
|
131
|
+
function deadlineOptions(remainingMs, signal) {
|
|
132
|
+
const timeout = AbortSignal.timeout(Math.max(0, remainingMs));
|
|
133
|
+
return {
|
|
134
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// Translate a poll failure at the wait boundary: caller cancellation and
|
|
138
|
+
// deadline expiry become explicit SdkErrors; anything else propagates.
|
|
139
|
+
function mapWaitError(err, name, deadline, signal) {
|
|
140
|
+
if (signal?.aborted)
|
|
141
|
+
return new SdkError('connect', `wait for sandbox '${name}' aborted`);
|
|
142
|
+
if (Date.now() >= deadline)
|
|
143
|
+
return new SdkError('connect', `timed out waiting for sandbox '${name}'`);
|
|
144
|
+
return err instanceof SdkError ? err : fromConnect(err);
|
|
145
|
+
}
|
|
146
|
+
// Sleep between polls, bounded by the remaining deadline and interruptible by
|
|
147
|
+
// the caller signal so the returned promise stays within its timeout budget.
|
|
148
|
+
function waitSleep(delayMs, deadline, signal) {
|
|
149
|
+
const bounded = Math.min(delayMs, Math.max(0, deadline - Date.now()));
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
const timer = setTimeout(() => {
|
|
152
|
+
signal?.removeEventListener('abort', onAbort);
|
|
153
|
+
resolve();
|
|
154
|
+
}, bounded);
|
|
155
|
+
const onAbort = () => {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
reject(new SdkError('connect', 'wait aborted'));
|
|
158
|
+
};
|
|
159
|
+
if (signal)
|
|
160
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
// Wait for a socket to drain before writing more. Resolves on 'drain', and
|
|
164
|
+
// also on 'close'/'error' so a pending await never leaks when the socket is
|
|
165
|
+
// torn down mid-backpressure; short-circuits if it is already gone.
|
|
166
|
+
function waitForDrain(socket) {
|
|
167
|
+
if (socket.writableEnded || socket.destroyed)
|
|
168
|
+
return Promise.resolve();
|
|
169
|
+
return new Promise((resolve) => {
|
|
170
|
+
const done = () => {
|
|
171
|
+
socket.removeListener('drain', done);
|
|
172
|
+
socket.removeListener('close', done);
|
|
173
|
+
socket.removeListener('error', done);
|
|
174
|
+
resolve();
|
|
175
|
+
};
|
|
176
|
+
socket.once('drain', done);
|
|
177
|
+
socket.once('close', done);
|
|
178
|
+
socket.once('error', done);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// An async-iterable queue for the client-send half of bidi streams. Producers
|
|
182
|
+
// `push()` frames; the connect transport consumes them as it drains the send
|
|
183
|
+
// side. `end()` closes the stream (optionally with an error). `onDrain` fires
|
|
184
|
+
// when the buffered queue empties via consumption, so callers can relieve TCP
|
|
185
|
+
// backpressure.
|
|
186
|
+
export class Pushable {
|
|
187
|
+
queue = [];
|
|
188
|
+
waiting = [];
|
|
189
|
+
ended = false;
|
|
190
|
+
error;
|
|
191
|
+
onDrain;
|
|
192
|
+
get size() {
|
|
193
|
+
return this.queue.length;
|
|
194
|
+
}
|
|
195
|
+
push(value) {
|
|
196
|
+
if (this.ended)
|
|
197
|
+
return;
|
|
198
|
+
const waiter = this.waiting.shift();
|
|
199
|
+
if (waiter) {
|
|
200
|
+
waiter.resolve({ value, done: false });
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
this.queue.push(value);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
end(error) {
|
|
207
|
+
if (this.ended)
|
|
208
|
+
return;
|
|
209
|
+
this.ended = true;
|
|
210
|
+
this.error = error;
|
|
211
|
+
let waiter = this.waiting.shift();
|
|
212
|
+
while (waiter) {
|
|
213
|
+
if (error !== undefined)
|
|
214
|
+
waiter.reject(error);
|
|
215
|
+
else
|
|
216
|
+
waiter.resolve({ value: undefined, done: true });
|
|
217
|
+
waiter = this.waiting.shift();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async *[Symbol.asyncIterator]() {
|
|
221
|
+
for (;;) {
|
|
222
|
+
if (this.queue.length > 0) {
|
|
223
|
+
const value = this.queue.shift();
|
|
224
|
+
if (this.queue.length === 0)
|
|
225
|
+
this.onDrain?.();
|
|
226
|
+
yield value;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (this.ended) {
|
|
230
|
+
if (this.error !== undefined)
|
|
231
|
+
throw this.error;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const next = await new Promise((resolve, reject) => {
|
|
235
|
+
this.waiting.push({ resolve, reject });
|
|
236
|
+
});
|
|
237
|
+
if (next.done) {
|
|
238
|
+
if (this.error !== undefined)
|
|
239
|
+
throw this.error;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
yield next.value;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// ---- sandbox client --------------------------------------------------------
|
|
247
|
+
// Sandbox lifecycle + exec. Usable standalone via `SandboxClient.connect()`,
|
|
248
|
+
// or reached as `client.sandbox` on an OpenShellClient, which shares one
|
|
249
|
+
// transport (one connection) across all of its scoped clients.
|
|
250
|
+
export class SandboxClient {
|
|
251
|
+
grpc;
|
|
252
|
+
/**
|
|
253
|
+
* Advanced escape hatch: a generated client for every gateway RPC, including
|
|
254
|
+
* surface the curated methods do not wrap yet. Request/response types are the
|
|
255
|
+
* generated wire messages (import them from '@nvidia/openshell-sdk/raw').
|
|
256
|
+
*/
|
|
257
|
+
raw;
|
|
258
|
+
/** The shared Connect transport, for building extra clients over the same connection. */
|
|
259
|
+
transport;
|
|
260
|
+
// Takes a transport rather than options so OpenShellClient can compose
|
|
261
|
+
// several scoped clients over a single connection. For standalone use,
|
|
262
|
+
// prefer the SandboxClient.connect() factory below.
|
|
263
|
+
constructor(transport, grpc = createClient(OpenShell, transport)) {
|
|
264
|
+
this.transport = transport;
|
|
265
|
+
this.grpc = grpc;
|
|
266
|
+
this.raw = this.grpc;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Constructs a lazy Connect client. No network request is made until the
|
|
270
|
+
* first RPC; call get() or another operation to verify reachability.
|
|
271
|
+
*/
|
|
272
|
+
static async connect(options) {
|
|
273
|
+
return new SandboxClient(buildTransport(options));
|
|
274
|
+
}
|
|
275
|
+
async create(spec) {
|
|
276
|
+
try {
|
|
277
|
+
// Curated fields build the base spec; rawSpec then shallow-overrides at
|
|
278
|
+
// the top spec level (Object.assign, so any field it sets wins). The
|
|
279
|
+
// runtime assign avoids the generated $typeName upgrading the literal and
|
|
280
|
+
// rejecting the curated `template: { image }` init shorthand.
|
|
281
|
+
const specInit = {
|
|
282
|
+
environment: spec.environment ?? {},
|
|
283
|
+
providers: spec.providers ?? [],
|
|
284
|
+
template: spec.image ? { image: spec.image } : undefined,
|
|
285
|
+
resourceRequirements: spec.gpu ? { gpu: {} } : undefined,
|
|
286
|
+
policy: spec.policy,
|
|
287
|
+
};
|
|
288
|
+
if (spec.rawSpec)
|
|
289
|
+
Object.assign(specInit, spec.rawSpec);
|
|
290
|
+
const resp = await this.grpc.createSandbox({
|
|
291
|
+
name: spec.name ?? '',
|
|
292
|
+
labels: spec.labels ?? {},
|
|
293
|
+
spec: specInit,
|
|
294
|
+
});
|
|
295
|
+
return sandboxRef(resp.sandbox);
|
|
296
|
+
}
|
|
297
|
+
catch (e) {
|
|
298
|
+
throw fromConnect(e);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async get(name, callOptions) {
|
|
302
|
+
try {
|
|
303
|
+
const resp = await this.grpc.getSandbox({ name }, callOptions);
|
|
304
|
+
return sandboxRef(resp.sandbox);
|
|
305
|
+
}
|
|
306
|
+
catch (e) {
|
|
307
|
+
throw fromConnect(e);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async list(options) {
|
|
311
|
+
try {
|
|
312
|
+
const resp = await this.grpc.listSandboxes({
|
|
313
|
+
limit: options?.limit ?? 0,
|
|
314
|
+
offset: options?.offset ?? 0,
|
|
315
|
+
labelSelector: options?.labelSelector ?? '',
|
|
316
|
+
});
|
|
317
|
+
return resp.sandboxes.map((s) => sandboxRef(s));
|
|
318
|
+
}
|
|
319
|
+
catch (e) {
|
|
320
|
+
throw fromConnect(e);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async delete(name) {
|
|
324
|
+
try {
|
|
325
|
+
const resp = await this.grpc.deleteSandbox({ name });
|
|
326
|
+
return resp.deleted;
|
|
327
|
+
}
|
|
328
|
+
catch (e) {
|
|
329
|
+
throw fromConnect(e);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// Poll until the sandbox is ready. The timeout bounds the returned promise,
|
|
333
|
+
// not just the sleep loop: each poll RPC carries the remaining deadline (and
|
|
334
|
+
// any caller signal), so a stalled get() is aborted rather than hanging.
|
|
335
|
+
async waitReady(name, timeoutSecs, options) {
|
|
336
|
+
const deadline = Date.now() + timeoutSecs * 1000;
|
|
337
|
+
const signal = options?.signal;
|
|
338
|
+
let delay = 250;
|
|
339
|
+
for (;;) {
|
|
340
|
+
if (signal?.aborted)
|
|
341
|
+
throw new SdkError('connect', `wait for sandbox '${name}' aborted`);
|
|
342
|
+
if (Date.now() >= deadline)
|
|
343
|
+
throw new SdkError('connect', `timed out waiting for sandbox '${name}'`);
|
|
344
|
+
let ref;
|
|
345
|
+
try {
|
|
346
|
+
ref = await this.get(name, deadlineOptions(deadline - Date.now(), signal));
|
|
347
|
+
}
|
|
348
|
+
catch (e) {
|
|
349
|
+
throw mapWaitError(e, name, deadline, signal);
|
|
350
|
+
}
|
|
351
|
+
if (ref.phase === 'ready')
|
|
352
|
+
return ref;
|
|
353
|
+
if (ref.phase === 'error')
|
|
354
|
+
throw new SdkError('connect', `sandbox '${name}' entered error phase`);
|
|
355
|
+
if (Date.now() >= deadline)
|
|
356
|
+
throw new SdkError('connect', `timed out waiting for sandbox '${name}'`);
|
|
357
|
+
await waitSleep(delay, deadline, signal);
|
|
358
|
+
delay = Math.min(delay * 2, 2000);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// Poll until the sandbox is gone. Timeout and cancellation bound the returned
|
|
362
|
+
// promise the same way as waitReady.
|
|
363
|
+
async waitDeleted(name, timeoutSecs, options) {
|
|
364
|
+
const deadline = Date.now() + timeoutSecs * 1000;
|
|
365
|
+
const signal = options?.signal;
|
|
366
|
+
let delay = 250;
|
|
367
|
+
for (;;) {
|
|
368
|
+
if (signal?.aborted)
|
|
369
|
+
throw new SdkError('connect', `wait for sandbox '${name}' aborted`);
|
|
370
|
+
if (Date.now() >= deadline)
|
|
371
|
+
throw new SdkError('connect', `timed out waiting for sandbox '${name}' to delete`);
|
|
372
|
+
try {
|
|
373
|
+
await this.get(name, deadlineOptions(deadline - Date.now(), signal));
|
|
374
|
+
}
|
|
375
|
+
catch (e) {
|
|
376
|
+
if (e instanceof SdkError && e.code === 'not_found')
|
|
377
|
+
return;
|
|
378
|
+
throw mapWaitError(e, name, deadline, signal);
|
|
379
|
+
}
|
|
380
|
+
if (Date.now() >= deadline)
|
|
381
|
+
throw new SdkError('connect', `timed out waiting for sandbox '${name}' to delete`);
|
|
382
|
+
await waitSleep(delay, deadline, signal);
|
|
383
|
+
delay = Math.min(delay * 2, 2000);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// Stream stdout/stderr as they arrive, then a terminal exit event. The exit
|
|
387
|
+
// is yielded in-band (not returned) so `for await` consumers cannot silently
|
|
388
|
+
// discard it: a failing command is impossible to miss. If the gateway closes
|
|
389
|
+
// the stream without an exit event, this throws. `exec()` drains this same
|
|
390
|
+
// path to reconstruct the buffered result.
|
|
391
|
+
async *execStream(name, command, options) {
|
|
392
|
+
try {
|
|
393
|
+
// Resolve the sandbox id first, exactly like the gateway client.
|
|
394
|
+
const sandbox = await this.get(name, options?.signal ? { signal: options.signal } : undefined);
|
|
395
|
+
const stream = this.grpc.execSandbox({
|
|
396
|
+
sandboxId: sandbox.id,
|
|
397
|
+
command,
|
|
398
|
+
workdir: options?.workdir ?? '',
|
|
399
|
+
environment: options?.environment ?? {},
|
|
400
|
+
timeoutSeconds: options?.timeoutSecs ?? 0,
|
|
401
|
+
stdin: options?.stdin ? new Uint8Array(options.stdin) : new Uint8Array(),
|
|
402
|
+
tty: false,
|
|
403
|
+
}, { signal: options?.signal });
|
|
404
|
+
let sawExit = false;
|
|
405
|
+
for await (const event of stream) {
|
|
406
|
+
switch (event.payload.case) {
|
|
407
|
+
case 'stdout':
|
|
408
|
+
yield {
|
|
409
|
+
stream: 'stdout',
|
|
410
|
+
data: Buffer.from(event.payload.value.data),
|
|
411
|
+
};
|
|
412
|
+
break;
|
|
413
|
+
case 'stderr':
|
|
414
|
+
yield {
|
|
415
|
+
stream: 'stderr',
|
|
416
|
+
data: Buffer.from(event.payload.value.data),
|
|
417
|
+
};
|
|
418
|
+
break;
|
|
419
|
+
case 'exit':
|
|
420
|
+
sawExit = true;
|
|
421
|
+
yield { type: 'exit', exitCode: event.payload.value.exitCode };
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (!sawExit)
|
|
426
|
+
throw new SdkError('rpc', 'ExecSandbox stream ended without an exit event');
|
|
427
|
+
}
|
|
428
|
+
catch (e) {
|
|
429
|
+
throw e instanceof SdkError ? e : fromConnect(e);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
async exec(name, command, options) {
|
|
433
|
+
const stdout = [];
|
|
434
|
+
const stderr = [];
|
|
435
|
+
let exitCode;
|
|
436
|
+
for await (const event of this.execStream(name, command, options)) {
|
|
437
|
+
if ('type' in event) {
|
|
438
|
+
exitCode = event.exitCode;
|
|
439
|
+
}
|
|
440
|
+
else if (event.stream === 'stdout') {
|
|
441
|
+
stdout.push(event.data);
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
stderr.push(event.data);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (exitCode === undefined)
|
|
448
|
+
throw new SdkError('rpc', 'ExecSandbox stream ended without an exit event');
|
|
449
|
+
return {
|
|
450
|
+
exitCode,
|
|
451
|
+
stdout: Buffer.concat(stdout),
|
|
452
|
+
stderr: Buffer.concat(stderr),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
// TTY + stdin transport half of an interactive exec. The first client frame
|
|
456
|
+
// is the `start` variant carrying the exec request; subsequent frames are
|
|
457
|
+
// `stdin`/`resize`. No terminal glue: raw mode, signal forwarding, and
|
|
458
|
+
// SIGWINCH stay with the caller.
|
|
459
|
+
async execInteractive(name, command, options) {
|
|
460
|
+
let sandboxId;
|
|
461
|
+
try {
|
|
462
|
+
sandboxId = (await this.get(name, options?.signal ? { signal: options.signal } : undefined)).id;
|
|
463
|
+
}
|
|
464
|
+
catch (e) {
|
|
465
|
+
throw e instanceof SdkError ? e : fromConnect(e);
|
|
466
|
+
}
|
|
467
|
+
const input = new Pushable();
|
|
468
|
+
input.push({
|
|
469
|
+
payload: {
|
|
470
|
+
case: 'start',
|
|
471
|
+
value: {
|
|
472
|
+
sandboxId,
|
|
473
|
+
command,
|
|
474
|
+
workdir: options?.workdir ?? '',
|
|
475
|
+
environment: options?.environment ?? {},
|
|
476
|
+
timeoutSeconds: options?.timeoutSecs ?? 0,
|
|
477
|
+
stdin: new Uint8Array(),
|
|
478
|
+
tty: options?.tty ?? true,
|
|
479
|
+
cols: options?.cols ?? 0,
|
|
480
|
+
rows: options?.rows ?? 0,
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
const stream = this.grpc.execSandboxInteractive(input, { signal: options?.signal });
|
|
485
|
+
let resolveDone;
|
|
486
|
+
let rejectDone;
|
|
487
|
+
const done = new Promise((resolve, reject) => {
|
|
488
|
+
resolveDone = resolve;
|
|
489
|
+
rejectDone = reject;
|
|
490
|
+
});
|
|
491
|
+
// `done` may settle before (or without) anyone awaiting it. A lone handler
|
|
492
|
+
// keeps an unobserved rejection from surfacing as an unhandledRejection;
|
|
493
|
+
// real awaiters still receive it through their own handler.
|
|
494
|
+
void done.catch(() => { });
|
|
495
|
+
// Settle exactly once. The exit code wins; error/abandonment only apply
|
|
496
|
+
// when no exit was observed.
|
|
497
|
+
let settled = false;
|
|
498
|
+
const settleExit = (code) => {
|
|
499
|
+
if (settled)
|
|
500
|
+
return;
|
|
501
|
+
settled = true;
|
|
502
|
+
resolveDone(code);
|
|
503
|
+
};
|
|
504
|
+
const settleError = (err) => {
|
|
505
|
+
if (settled)
|
|
506
|
+
return;
|
|
507
|
+
settled = true;
|
|
508
|
+
rejectDone(err);
|
|
509
|
+
};
|
|
510
|
+
async function* output() {
|
|
511
|
+
let sawExit = false;
|
|
512
|
+
try {
|
|
513
|
+
for await (const event of stream) {
|
|
514
|
+
switch (event.payload.case) {
|
|
515
|
+
case 'stdout':
|
|
516
|
+
yield {
|
|
517
|
+
stream: 'stdout',
|
|
518
|
+
data: Buffer.from(event.payload.value.data),
|
|
519
|
+
};
|
|
520
|
+
break;
|
|
521
|
+
case 'stderr':
|
|
522
|
+
yield {
|
|
523
|
+
stream: 'stderr',
|
|
524
|
+
data: Buffer.from(event.payload.value.data),
|
|
525
|
+
};
|
|
526
|
+
break;
|
|
527
|
+
case 'exit':
|
|
528
|
+
sawExit = true;
|
|
529
|
+
// Settle `done` before yielding: a consumer that breaks on the
|
|
530
|
+
// exit event abandons the generator at the yield, so anything
|
|
531
|
+
// after it would never run.
|
|
532
|
+
settleExit(event.payload.value.exitCode);
|
|
533
|
+
yield { type: 'exit', exitCode: event.payload.value.exitCode };
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (!sawExit) {
|
|
538
|
+
throw new SdkError('rpc', 'ExecSandboxInteractive stream ended without an exit event');
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
catch (e) {
|
|
542
|
+
const err = e instanceof SdkError ? e : fromConnect(e);
|
|
543
|
+
settleError(err);
|
|
544
|
+
throw err;
|
|
545
|
+
}
|
|
546
|
+
finally {
|
|
547
|
+
input.end();
|
|
548
|
+
// Consumer abandoned the stream before an exit event (early break or
|
|
549
|
+
// return): settle `done` so it can never hang.
|
|
550
|
+
settleError(new SdkError('rpc', 'exec output abandoned before exit'));
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return {
|
|
554
|
+
output: output(),
|
|
555
|
+
write(data) {
|
|
556
|
+
input.push({ payload: { case: 'stdin', value: new Uint8Array(data) } });
|
|
557
|
+
},
|
|
558
|
+
resize(cols, rows) {
|
|
559
|
+
input.push({ payload: { case: 'resize', value: { cols, rows } } });
|
|
560
|
+
},
|
|
561
|
+
close() {
|
|
562
|
+
input.end();
|
|
563
|
+
},
|
|
564
|
+
done,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
// Bind a local TCP listener that tunnels each accepted connection into the
|
|
568
|
+
// sandbox. Mirrors the CLI service forward: READY check, then per socket mint
|
|
569
|
+
// a short-lived SSH session token, open a forwardTcp bidi whose first frame is
|
|
570
|
+
// the `init` (TCP target + token), relay bytes both ways in ~64 KiB chunks,
|
|
571
|
+
// and revoke the token on close. Process-lifetime only.
|
|
572
|
+
async forward(name, opts) {
|
|
573
|
+
const targetHost = opts.targetHost ?? '127.0.0.1';
|
|
574
|
+
const targetPort = opts.targetPort;
|
|
575
|
+
const localHost = opts.localHost ?? '127.0.0.1';
|
|
576
|
+
const localPort = opts.localPort ?? 0;
|
|
577
|
+
let sandboxId;
|
|
578
|
+
try {
|
|
579
|
+
const ref = await this.get(name, opts.signal ? { signal: opts.signal } : undefined);
|
|
580
|
+
if (ref.phase !== 'ready') {
|
|
581
|
+
throw new SdkError('connect', `sandbox '${name}' is not ready (phase: ${ref.phase})`);
|
|
582
|
+
}
|
|
583
|
+
sandboxId = ref.id;
|
|
584
|
+
}
|
|
585
|
+
catch (e) {
|
|
586
|
+
throw e instanceof SdkError ? e : fromConnect(e);
|
|
587
|
+
}
|
|
588
|
+
const sockets = new Set();
|
|
589
|
+
const controllers = new Set();
|
|
590
|
+
const connectionTasks = new Set();
|
|
591
|
+
let closing = false;
|
|
592
|
+
const server = net.createServer((socket) => {
|
|
593
|
+
sockets.add(socket);
|
|
594
|
+
socket.on('close', () => sockets.delete(socket));
|
|
595
|
+
// Guard the window before forwardConnection attaches its own handlers
|
|
596
|
+
// (it first awaits createSshSession). Without a synchronous 'error'
|
|
597
|
+
// listener a peer reset here emits an unhandled 'error' and crashes the
|
|
598
|
+
// process; forwardConnection's catch still tears the socket down.
|
|
599
|
+
socket.on('error', () => { });
|
|
600
|
+
const controller = new AbortController();
|
|
601
|
+
controllers.add(controller);
|
|
602
|
+
const task = this.forwardConnection(socket, sandboxId, name, targetHost, targetPort, controller.signal)
|
|
603
|
+
.catch((error) => {
|
|
604
|
+
if (!closing) {
|
|
605
|
+
try {
|
|
606
|
+
opts.onConnectionError?.(error instanceof SdkError ? error : fromConnect(error));
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// Consumer callbacks must not turn a handled connection failure
|
|
610
|
+
// into an unhandled rejection or prevent forward cleanup.
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
})
|
|
614
|
+
.finally(() => {
|
|
615
|
+
controllers.delete(controller);
|
|
616
|
+
connectionTasks.delete(task);
|
|
617
|
+
});
|
|
618
|
+
connectionTasks.add(task);
|
|
619
|
+
});
|
|
620
|
+
let resolveClosed;
|
|
621
|
+
const closed = new Promise((resolve) => {
|
|
622
|
+
resolveClosed = resolve;
|
|
623
|
+
});
|
|
624
|
+
await new Promise((resolve, reject) => {
|
|
625
|
+
const onError = (err) => {
|
|
626
|
+
reject(new SdkError('io', `failed to bind local forward on ${localHost}:${localPort}: ${err instanceof Error ? err.message : String(err)}`));
|
|
627
|
+
};
|
|
628
|
+
server.once('error', onError);
|
|
629
|
+
server.listen(localPort, localHost, () => {
|
|
630
|
+
server.removeListener('error', onError);
|
|
631
|
+
resolve();
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
let teardownPromise;
|
|
635
|
+
const onAbort = () => {
|
|
636
|
+
void teardown();
|
|
637
|
+
};
|
|
638
|
+
const teardown = () => {
|
|
639
|
+
if (teardownPromise)
|
|
640
|
+
return teardownPromise;
|
|
641
|
+
closing = true;
|
|
642
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
643
|
+
teardownPromise = (async () => {
|
|
644
|
+
for (const controller of controllers)
|
|
645
|
+
controller.abort();
|
|
646
|
+
for (const socket of sockets)
|
|
647
|
+
socket.destroy();
|
|
648
|
+
if (server.listening) {
|
|
649
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
650
|
+
}
|
|
651
|
+
await Promise.allSettled([...connectionTasks]);
|
|
652
|
+
resolveClosed();
|
|
653
|
+
})();
|
|
654
|
+
return teardownPromise;
|
|
655
|
+
};
|
|
656
|
+
// Caller cancellation tears the local listener down the same way close() does.
|
|
657
|
+
if (opts.signal) {
|
|
658
|
+
if (opts.signal.aborted)
|
|
659
|
+
void teardown();
|
|
660
|
+
else
|
|
661
|
+
opts.signal.addEventListener('abort', onAbort, { once: true });
|
|
662
|
+
}
|
|
663
|
+
const addr = server.address();
|
|
664
|
+
return {
|
|
665
|
+
localHost,
|
|
666
|
+
localPort: addr ? addr.port : localPort,
|
|
667
|
+
targetHost,
|
|
668
|
+
targetPort,
|
|
669
|
+
close: teardown,
|
|
670
|
+
closed,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
async forwardConnection(socket, sandboxId, name, targetHost, targetPort, signal) {
|
|
674
|
+
let token;
|
|
675
|
+
const input = new Pushable();
|
|
676
|
+
input.onDrain = () => socket.resume();
|
|
677
|
+
try {
|
|
678
|
+
const session = await this.grpc.createSshSession({ sandboxId }, { signal });
|
|
679
|
+
// Defense-in-depth: the token feeds forwardTcp authorization, so hold it
|
|
680
|
+
// to the same trust-boundary contract as createSshSession. A violation
|
|
681
|
+
// tears down this one socket via the catch below.
|
|
682
|
+
validateSshResponse(session, sandboxId);
|
|
683
|
+
token = session.token;
|
|
684
|
+
input.push({
|
|
685
|
+
payload: {
|
|
686
|
+
case: 'init',
|
|
687
|
+
value: {
|
|
688
|
+
sandboxId,
|
|
689
|
+
serviceId: `service-forward:${name}:${targetHost}:${targetPort}`,
|
|
690
|
+
target: {
|
|
691
|
+
case: 'tcp',
|
|
692
|
+
value: { host: targetHost, port: targetPort },
|
|
693
|
+
},
|
|
694
|
+
authorizationToken: token,
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
});
|
|
698
|
+
socket.on('data', (chunk) => {
|
|
699
|
+
for (let off = 0; off < chunk.length; off += FORWARD_CHUNK) {
|
|
700
|
+
const slice = chunk.subarray(off, Math.min(off + FORWARD_CHUNK, chunk.length));
|
|
701
|
+
input.push({
|
|
702
|
+
payload: { case: 'data', value: new Uint8Array(slice) },
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
if (input.size >= 64)
|
|
706
|
+
socket.pause();
|
|
707
|
+
});
|
|
708
|
+
socket.on('end', () => input.end());
|
|
709
|
+
socket.on('error', (error) => input.end(error));
|
|
710
|
+
socket.on('close', () => input.end());
|
|
711
|
+
const onAbort = () => {
|
|
712
|
+
input.end(new SdkError('canceled', 'forward connection closed'));
|
|
713
|
+
socket.destroy();
|
|
714
|
+
};
|
|
715
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
716
|
+
try {
|
|
717
|
+
for await (const frame of this.grpc.forwardTcp(input, { signal })) {
|
|
718
|
+
if (frame.payload.case !== 'data')
|
|
719
|
+
continue;
|
|
720
|
+
const data = frame.payload.value;
|
|
721
|
+
if (data.length === 0)
|
|
722
|
+
continue;
|
|
723
|
+
// Respect backpressure: if the local socket buffer is full, stop
|
|
724
|
+
// pulling sandbox data until it drains so memory stays bounded.
|
|
725
|
+
if (!socket.write(Buffer.from(data)))
|
|
726
|
+
await waitForDrain(socket);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
finally {
|
|
730
|
+
signal.removeEventListener('abort', onAbort);
|
|
731
|
+
}
|
|
732
|
+
socket.end();
|
|
733
|
+
}
|
|
734
|
+
catch (e) {
|
|
735
|
+
socket.destroy();
|
|
736
|
+
throw e instanceof SdkError ? e : fromConnect(e);
|
|
737
|
+
}
|
|
738
|
+
finally {
|
|
739
|
+
input.end();
|
|
740
|
+
if (token !== undefined) {
|
|
741
|
+
try {
|
|
742
|
+
await this.grpc.revokeSshSession({ token }, { signal });
|
|
743
|
+
}
|
|
744
|
+
catch {
|
|
745
|
+
// Best-effort revoke; the token expires on its own regardless.
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
// Mint a short-lived SSH session token for the sandbox — the input side of
|
|
751
|
+
// ssh-config / ProxyCommand and forwardTcp authorization.
|
|
752
|
+
async createSshSession(name) {
|
|
753
|
+
try {
|
|
754
|
+
const sandbox = await this.get(name);
|
|
755
|
+
const resp = await this.grpc.createSshSession({ sandboxId: sandbox.id });
|
|
756
|
+
// Reject any response outside the proto trust-boundary contract before
|
|
757
|
+
// handing these values to the caller (they feed OpenSSH ProxyCommand).
|
|
758
|
+
validateSshResponse(resp, sandbox.id);
|
|
759
|
+
return {
|
|
760
|
+
sandboxId: resp.sandboxId,
|
|
761
|
+
token: resp.token,
|
|
762
|
+
gatewayHost: resp.gatewayHost,
|
|
763
|
+
gatewayPort: resp.gatewayPort,
|
|
764
|
+
gatewayScheme: resp.gatewayScheme,
|
|
765
|
+
...(resp.hostKeyFingerprint ? { hostKeyFingerprint: resp.hostKeyFingerprint } : {}),
|
|
766
|
+
...(resp.expiresAtMs !== 0n ? { expiresAtMs: resp.expiresAtMs.toString() } : {}),
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
catch (e) {
|
|
770
|
+
throw e instanceof SdkError ? e : fromConnect(e);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
async revokeSshSession(token) {
|
|
774
|
+
try {
|
|
775
|
+
const resp = await this.grpc.revokeSshSession({ token });
|
|
776
|
+
return resp.revoked;
|
|
777
|
+
}
|
|
778
|
+
catch (e) {
|
|
779
|
+
throw fromConnect(e);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
async attachProvider(name, provider, options) {
|
|
783
|
+
try {
|
|
784
|
+
const resp = await this.grpc.attachSandboxProvider({
|
|
785
|
+
sandboxName: name,
|
|
786
|
+
providerName: provider,
|
|
787
|
+
expectedResourceVersion: versionPin(options?.expectedResourceVersion),
|
|
788
|
+
});
|
|
789
|
+
return { sandbox: sandboxRef(resp.sandbox), changed: resp.attached };
|
|
790
|
+
}
|
|
791
|
+
catch (e) {
|
|
792
|
+
throw fromConnect(e);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
async detachProvider(name, provider, options) {
|
|
796
|
+
try {
|
|
797
|
+
const resp = await this.grpc.detachSandboxProvider({
|
|
798
|
+
sandboxName: name,
|
|
799
|
+
providerName: provider,
|
|
800
|
+
expectedResourceVersion: versionPin(options?.expectedResourceVersion),
|
|
801
|
+
});
|
|
802
|
+
return { sandbox: sandboxRef(resp.sandbox), changed: resp.detached };
|
|
803
|
+
}
|
|
804
|
+
catch (e) {
|
|
805
|
+
throw fromConnect(e);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
async listProviders(name) {
|
|
809
|
+
try {
|
|
810
|
+
const resp = await this.grpc.listSandboxProviders({ sandboxName: name });
|
|
811
|
+
return resp.providers.map((p) => providerRef(p));
|
|
812
|
+
}
|
|
813
|
+
catch (e) {
|
|
814
|
+
throw fromConnect(e);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
async getConfig(name, callOptions) {
|
|
818
|
+
try {
|
|
819
|
+
const sandbox = await this.get(name, callOptions);
|
|
820
|
+
const resp = await this.grpc.getSandboxConfig({ sandboxId: sandbox.id }, callOptions);
|
|
821
|
+
return sandboxConfig(resp);
|
|
822
|
+
}
|
|
823
|
+
catch (e) {
|
|
824
|
+
throw e instanceof SdkError ? e : fromConnect(e);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
// Update the sandbox-scoped policy. Sandbox scope (global=false) may only
|
|
828
|
+
// change network_policies; static fields must match the create-time policy or
|
|
829
|
+
// the gateway rejects the update. With `wait`, poll getConfig until the
|
|
830
|
+
// applied policy hash is observed.
|
|
831
|
+
async setPolicy(name, policy, options) {
|
|
832
|
+
try {
|
|
833
|
+
const resp = await this.grpc.updateConfig({
|
|
834
|
+
name,
|
|
835
|
+
policy,
|
|
836
|
+
global: false,
|
|
837
|
+
expectedResourceVersion: versionPin(options?.expectedResourceVersion),
|
|
838
|
+
});
|
|
839
|
+
const result = updateConfigResult(resp);
|
|
840
|
+
if (options?.wait)
|
|
841
|
+
await this.waitForPolicyHash(name, result.policyHash, options.waitTimeoutSecs);
|
|
842
|
+
return result;
|
|
843
|
+
}
|
|
844
|
+
catch (e) {
|
|
845
|
+
throw e instanceof SdkError ? e : fromConnect(e);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
// Upsert a single sandbox-scoped setting. Sandbox-scoped deletes are rejected
|
|
849
|
+
// by the gateway, so there is no sandbox-scoped delete on this surface.
|
|
850
|
+
async setSetting(name, key, value) {
|
|
851
|
+
try {
|
|
852
|
+
const resp = await this.grpc.updateConfig({
|
|
853
|
+
name,
|
|
854
|
+
settingKey: key,
|
|
855
|
+
settingValue: value,
|
|
856
|
+
global: false,
|
|
857
|
+
});
|
|
858
|
+
return updateConfigResult(resp);
|
|
859
|
+
}
|
|
860
|
+
catch (e) {
|
|
861
|
+
throw fromConnect(e);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
// Poll getConfig until the applied policy hash is observed. Each poll RPC is
|
|
865
|
+
// bounded by the remaining deadline (deadlineOptions), so a stalled getConfig
|
|
866
|
+
// cannot make the returned promise outlive timeoutSecs.
|
|
867
|
+
async waitForPolicyHash(name, policyHash, timeoutSecs = 60) {
|
|
868
|
+
const deadline = Date.now() + timeoutSecs * 1000;
|
|
869
|
+
let delay = 100;
|
|
870
|
+
for (;;) {
|
|
871
|
+
let config;
|
|
872
|
+
try {
|
|
873
|
+
config = await this.getConfig(name, deadlineOptions(deadline - Date.now()));
|
|
874
|
+
}
|
|
875
|
+
catch (e) {
|
|
876
|
+
if (Date.now() >= deadline) {
|
|
877
|
+
throw new SdkError('connect', `timed out waiting for policy '${policyHash}' on sandbox '${name}'`);
|
|
878
|
+
}
|
|
879
|
+
throw e instanceof SdkError ? e : fromConnect(e);
|
|
880
|
+
}
|
|
881
|
+
if (config.policyHash === policyHash)
|
|
882
|
+
return;
|
|
883
|
+
if (Date.now() >= deadline) {
|
|
884
|
+
throw new SdkError('connect', `timed out waiting for policy '${policyHash}' on sandbox '${name}'`);
|
|
885
|
+
}
|
|
886
|
+
await waitSleep(delay, deadline);
|
|
887
|
+
delay = Math.min(delay * 2, 2000);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
// ---- The client ------------------------------------------------------------
|
|
892
|
+
export class OpenShellClient {
|
|
893
|
+
/** Sandbox lifecycle + exec: create/get/list/delete, waitReady/waitDeleted, exec. */
|
|
894
|
+
sandbox;
|
|
895
|
+
/**
|
|
896
|
+
* Advanced escape hatch: a generated client for every gateway RPC, including
|
|
897
|
+
* surface the curated sub-clients do not wrap yet (gateway config, provider
|
|
898
|
+
* CRUD, policy status, watch, logs, and the full observed Sandbox). See
|
|
899
|
+
* '@nvidia/openshell-sdk/raw' for the generated request/response types.
|
|
900
|
+
*/
|
|
901
|
+
raw;
|
|
902
|
+
/** The shared Connect transport, for building extra clients over the same connection. */
|
|
903
|
+
transport;
|
|
904
|
+
grpc;
|
|
905
|
+
constructor(transport) {
|
|
906
|
+
// One transport (one connection) shared across every scoped client.
|
|
907
|
+
this.transport = transport;
|
|
908
|
+
this.grpc = createClient(OpenShell, transport);
|
|
909
|
+
this.raw = this.grpc;
|
|
910
|
+
this.sandbox = new SandboxClient(transport, this.grpc);
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Constructs a lazy Connect client. No network request is made until the
|
|
914
|
+
* first RPC; call health() when startup must verify gateway reachability.
|
|
915
|
+
*/
|
|
916
|
+
static async connect(options) {
|
|
917
|
+
return new OpenShellClient(buildTransport(options));
|
|
918
|
+
}
|
|
919
|
+
// Gateway-scoped, so it stays top-level rather than under a namespace.
|
|
920
|
+
async health() {
|
|
921
|
+
try {
|
|
922
|
+
const resp = await this.grpc.health({});
|
|
923
|
+
return { status: statusName(resp.status), version: resp.version };
|
|
924
|
+
}
|
|
925
|
+
catch (e) {
|
|
926
|
+
throw fromConnect(e);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
//# sourceMappingURL=client.js.map
|