@aerol-ai/aerolvm-sdk 0.5.4 → 0.7.0
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/MicroVM.d.ts +91 -5
- package/dist/MicroVM.js +86 -4
- package/dist/index.d.ts +2 -2
- package/dist/internal/client.d.ts +115 -10
- package/dist/internal/client.js +515 -39
- package/dist/types.d.ts +379 -14
- package/package.json +9 -4
package/dist/internal/client.js
CHANGED
|
@@ -1,23 +1,134 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import { PATH_PREFIX as V1_PATH_PREFIX, ingressDNSPath as v1IngressDNSPath, sandboxCustomDomainDNSPath as v1SandboxCustomDomainDNSPath, sandboxCustomDomainPath as v1SandboxCustomDomainPath, sandboxCustomDomainsPath as v1SandboxCustomDomainsPath, } from "./api/v1/paths.js";
|
|
3
3
|
import { Image } from "../Image.js";
|
|
4
|
+
/**
|
|
5
|
+
* The request header that selects which of a credential's teams a request
|
|
6
|
+
* acts for. Keep in sync with pkg/api.HeaderTeam on the server.
|
|
7
|
+
*/
|
|
8
|
+
export const TEAM_HEADER = "X-AerolVM-Team";
|
|
4
9
|
const DEFAULT_API_VERSION = "v1";
|
|
5
10
|
const PATH_PREFIXES = {
|
|
6
11
|
v1: V1_PATH_PREFIX,
|
|
7
12
|
};
|
|
13
|
+
function fromApiWhoAmI(w) {
|
|
14
|
+
const out = {
|
|
15
|
+
principal: { kind: w.principal.kind, id: w.principal.id },
|
|
16
|
+
role: w.role,
|
|
17
|
+
};
|
|
18
|
+
if (w.principal.name)
|
|
19
|
+
out.principal.name = w.principal.name;
|
|
20
|
+
if (w.team)
|
|
21
|
+
out.team = w.team;
|
|
22
|
+
if (w.expires_at)
|
|
23
|
+
out.expiresAt = w.expires_at;
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
function fromApiAPIToken(t) {
|
|
27
|
+
return {
|
|
28
|
+
id: t.id,
|
|
29
|
+
name: t.name,
|
|
30
|
+
role: t.role,
|
|
31
|
+
team: t.team,
|
|
32
|
+
createdBy: t.created_by,
|
|
33
|
+
createdAt: t.created_at,
|
|
34
|
+
expiresAt: t.expires_at,
|
|
35
|
+
expired: t.expired === true,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function fromApiEgressProfile(p) {
|
|
39
|
+
const out = {
|
|
40
|
+
name: p.name,
|
|
41
|
+
allowOut: p.allow_out ?? [],
|
|
42
|
+
generation: p.generation,
|
|
43
|
+
createdAt: p.created_at,
|
|
44
|
+
updatedAt: p.updated_at,
|
|
45
|
+
};
|
|
46
|
+
if (p.description)
|
|
47
|
+
out.description = p.description;
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
function toApiEgressRule({ inject, ...rule }) {
|
|
51
|
+
return inject ? { ...rule, inject: { header: inject.header, secret_ref: inject.secretRef } } : rule;
|
|
52
|
+
}
|
|
53
|
+
function fromApiEgressRule({ inject, ...rule }) {
|
|
54
|
+
return inject ? { ...rule, inject: { header: inject.header, secretRef: inject.secret_ref } } : rule;
|
|
55
|
+
}
|
|
8
56
|
/** HTTP status codes the retry loop considers transient. */
|
|
9
|
-
|
|
57
|
+
// 421 Misdirected Request: an owner answered for a sandbox it doesn't hold
|
|
58
|
+
// (HTTP/2 connection coalescing, or a stale ingress route after failover).
|
|
59
|
+
// The server closes the connection, so the retry reconnects and the ingress
|
|
60
|
+
// re-routes it (plans/ingress-proxy-routing.md, review 2A).
|
|
61
|
+
const RETRYABLE_STATUS_CODES = new Set([421, 429, 502, 503, 504]);
|
|
62
|
+
// The subset a non-idempotent request (exec) may retry: 429 and 503 are the
|
|
63
|
+
// server turning the request away before doing any work. 421, 502 and 504 come
|
|
64
|
+
// from forwarding/proxy hops, and the status alone can't prove that no hop
|
|
65
|
+
// already handed the command to the sandbox, so exec surfaces them. They are
|
|
66
|
+
// also the statuses whose Retry-After paces clients: 429 when this caller is
|
|
67
|
+
// over its share, 503 when the node sheds load. A Retry-After on a 421, 502 or
|
|
68
|
+
// 504 is a hop's, not the server's, so those use the normal backoff.
|
|
69
|
+
const REFUSED_STATUS_CODES = new Set([429, 503]);
|
|
10
70
|
/** Default retry settings when the caller doesn't supply a RetryConfig. */
|
|
11
71
|
const DEFAULT_RETRY = {
|
|
12
72
|
maxRetries: 3,
|
|
13
73
|
baseDelayMs: 200,
|
|
14
74
|
maxDelayMs: 5_000,
|
|
75
|
+
// An overloaded node asks for about a second, so the cap only bites on a
|
|
76
|
+
// long ask (maintenance, a misconfigured proxy), which should not hold a
|
|
77
|
+
// call for minutes per retry.
|
|
78
|
+
maxRetryAfterMs: 30_000,
|
|
15
79
|
};
|
|
80
|
+
// IMF-fixdate, the HTTP-date form servers send ("Sun, 06 Nov 1994 08:49:37
|
|
81
|
+
// GMT"). Date.parse alone would also take "1" or a date with no zone, read in
|
|
82
|
+
// local time.
|
|
83
|
+
const IMF_FIXDATE = /^[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/;
|
|
84
|
+
/**
|
|
85
|
+
* Reads a `Retry-After` value in either form RFC 9110 allows: delta-seconds
|
|
86
|
+
* (`"120"`) or an HTTP date. Returns the wait in milliseconds (0 for a date
|
|
87
|
+
* already past), or `undefined` for an absent or malformed value.
|
|
88
|
+
*/
|
|
89
|
+
export function parseRetryAfter(value, nowMs) {
|
|
90
|
+
const trimmed = (value ?? "").trim();
|
|
91
|
+
if (/^[0-9]+$/.test(trimmed)) {
|
|
92
|
+
return Number(trimmed) * 1000;
|
|
93
|
+
}
|
|
94
|
+
if (!IMF_FIXDATE.test(trimmed)) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
const when = Date.parse(trimmed);
|
|
98
|
+
return Number.isNaN(when) ? undefined : Math.max(when - nowMs, 0);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* How long {@link APIClient.request} waits before re-sending. `attempt`
|
|
102
|
+
* counts from 0, `retryAfter` is a 429/503's `Retry-After` header (null
|
|
103
|
+
* otherwise) and `random` is uniform in [0, 1).
|
|
104
|
+
*
|
|
105
|
+
* Without a usable header it is the exponential backoff ±25%. With one, it
|
|
106
|
+
* never retries sooner than the server asked (nor sooner than the backoff)
|
|
107
|
+
* and adds up to a fifth so clients told the same value spread out.
|
|
108
|
+
* `maxRetryAfterMs` is a hard ceiling: when the server asks for at least
|
|
109
|
+
* that long the client is early anyway, so the spread goes below it instead.
|
|
110
|
+
*/
|
|
111
|
+
export function retryDelayMs(config, attempt, retryAfter, nowMs, random) {
|
|
112
|
+
const backoff = Math.min(config.baseDelayMs * 2 ** attempt, config.maxDelayMs);
|
|
113
|
+
const limit = config.maxRetryAfterMs;
|
|
114
|
+
const hint = limit > 0 ? parseRetryAfter(retryAfter, nowMs) : undefined;
|
|
115
|
+
if (hint === undefined) {
|
|
116
|
+
return backoff * (0.75 + 0.5 * random);
|
|
117
|
+
}
|
|
118
|
+
const target = Math.max(hint, backoff);
|
|
119
|
+
if (target >= limit) {
|
|
120
|
+
return limit - limit * 0.2 * random;
|
|
121
|
+
}
|
|
122
|
+
const upper = Math.min(target * 1.2, limit);
|
|
123
|
+
return target + (upper - target) * random;
|
|
124
|
+
}
|
|
16
125
|
/**
|
|
17
126
|
* Returns `true` when the thrown `error` looks like a transport-level failure
|
|
18
127
|
* that vanishes on retry (connection reset, socket closed by peer, DNS
|
|
19
128
|
* blip). `undici` and Node core use several `cause.code` values; we check
|
|
20
|
-
* the full cause chain.
|
|
129
|
+
* the full cause chain. A reset, EPIPE, read/headers/body timeout or "other
|
|
130
|
+
* side closed" can fire after the server already received the request, so
|
|
131
|
+
* this classification is only safe for idempotent requests.
|
|
21
132
|
*/
|
|
22
133
|
function isTransientTransportError(error) {
|
|
23
134
|
const codes = new Set([
|
|
@@ -47,6 +158,45 @@ function isTransientTransportError(error) {
|
|
|
47
158
|
}
|
|
48
159
|
return false;
|
|
49
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Returns `true` only for transport failures that prove the request never
|
|
163
|
+
* left the client: the connection was refused, did not open before the
|
|
164
|
+
* connect timeout, or the host name did not resolve (transiently; ENOTFOUND
|
|
165
|
+
* is not retried for any request). Any other code in the cause chain means
|
|
166
|
+
* bytes may have reached the server, so the answer is `false`.
|
|
167
|
+
*/
|
|
168
|
+
function isUnsentTransportError(error) {
|
|
169
|
+
let current = error;
|
|
170
|
+
for (let depth = 0; depth < 5 && current instanceof Error; depth++) {
|
|
171
|
+
const err = current;
|
|
172
|
+
switch (err.code) {
|
|
173
|
+
case undefined:
|
|
174
|
+
break;
|
|
175
|
+
case "ECONNREFUSED":
|
|
176
|
+
case "EAI_AGAIN":
|
|
177
|
+
case "UND_ERR_CONNECT_TIMEOUT":
|
|
178
|
+
return true;
|
|
179
|
+
case "ETIMEDOUT":
|
|
180
|
+
// Node uses ETIMEDOUT both for a connect that never completed and for
|
|
181
|
+
// an open socket that stopped answering; only the first is unsent.
|
|
182
|
+
return failedWhileConnecting(err);
|
|
183
|
+
default:
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
current = err.cause;
|
|
187
|
+
}
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
function failedWhileConnecting(err) {
|
|
191
|
+
if (err.syscall === "connect")
|
|
192
|
+
return true;
|
|
193
|
+
// With autoSelectFamily (Node's default), a host with several addresses
|
|
194
|
+
// fails with an AggregateError holding one error per connect attempt and no
|
|
195
|
+
// syscall of its own.
|
|
196
|
+
return (err instanceof AggregateError &&
|
|
197
|
+
err.errors.length > 0 &&
|
|
198
|
+
err.errors.every((inner) => inner?.syscall === "connect"));
|
|
199
|
+
}
|
|
50
200
|
/**
|
|
51
201
|
* Trim trailing slashes from a URL without a backtracking regex. The natural
|
|
52
202
|
* `value.replace(/\/+$/, "")` is an anchored `+` quantifier — a polynomial
|
|
@@ -59,21 +209,37 @@ export function stripTrailingSlashes(value) {
|
|
|
59
209
|
}
|
|
60
210
|
return value.slice(0, end);
|
|
61
211
|
}
|
|
62
|
-
/**
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
|
|
212
|
+
/** Waits `ms`, or rejects with the signal's reason as soon as it aborts. */
|
|
213
|
+
function sleep(ms, signal) {
|
|
214
|
+
return new Promise((resolve, reject) => {
|
|
215
|
+
if (signal?.aborted) {
|
|
216
|
+
reject(signal.reason);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const onAbort = () => {
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
reject(signal?.reason);
|
|
222
|
+
};
|
|
223
|
+
const timer = setTimeout(() => {
|
|
224
|
+
signal?.removeEventListener("abort", onAbort);
|
|
225
|
+
resolve();
|
|
226
|
+
}, ms);
|
|
227
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
228
|
+
});
|
|
66
229
|
}
|
|
67
230
|
export class APIClient {
|
|
68
231
|
baseURL;
|
|
69
232
|
apiVersion;
|
|
70
233
|
patToken;
|
|
234
|
+
team;
|
|
71
235
|
fetchFn;
|
|
72
236
|
versionPrefix;
|
|
73
237
|
retryConfig;
|
|
238
|
+
signal;
|
|
74
239
|
constructor(config) {
|
|
75
240
|
this.baseURL = stripTrailingSlashes(config.baseURL);
|
|
76
241
|
this.patToken = config.patToken ?? "";
|
|
242
|
+
this.team = (config.team ?? "").trim();
|
|
77
243
|
this.fetchFn = config.fetch ?? fetch;
|
|
78
244
|
this.apiVersion = config.apiVersion ?? DEFAULT_API_VERSION;
|
|
79
245
|
this.versionPrefix = PATH_PREFIXES[this.apiVersion];
|
|
@@ -81,7 +247,9 @@ export class APIClient {
|
|
|
81
247
|
maxRetries: config.retry?.maxRetries ?? DEFAULT_RETRY.maxRetries,
|
|
82
248
|
baseDelayMs: config.retry?.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs,
|
|
83
249
|
maxDelayMs: config.retry?.maxDelayMs ?? DEFAULT_RETRY.maxDelayMs,
|
|
250
|
+
maxRetryAfterMs: config.retry?.maxRetryAfterMs ?? DEFAULT_RETRY.maxRetryAfterMs,
|
|
84
251
|
};
|
|
252
|
+
this.signal = config.signal;
|
|
85
253
|
}
|
|
86
254
|
/**
|
|
87
255
|
* Build a versioned API path. Pass the suffix beginning with "/" (e.g.
|
|
@@ -151,12 +319,62 @@ export class APIClient {
|
|
|
151
319
|
return { ...options, image: result.image };
|
|
152
320
|
}
|
|
153
321
|
async list(options) {
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
322
|
+
const items = [];
|
|
323
|
+
for await (const page of this.listPages(options)) {
|
|
324
|
+
items.push(...page);
|
|
325
|
+
}
|
|
326
|
+
return items;
|
|
327
|
+
}
|
|
328
|
+
/** Streams bounded cluster-list pages and never accumulates the full fleet. */
|
|
329
|
+
async *listPages(options) {
|
|
330
|
+
const basePath = this.versioned("/sandboxes") + buildSandboxListQuery(options);
|
|
331
|
+
let pageToken = "";
|
|
332
|
+
for (let page = 0; page < 100000; page++) {
|
|
333
|
+
const path = appendQueryParam(basePath, "page_token", pageToken);
|
|
334
|
+
const response = await this.request("GET", path);
|
|
335
|
+
if (!response.ok) {
|
|
336
|
+
throw await decodeError(response);
|
|
337
|
+
}
|
|
338
|
+
const partial = response.headers.get("X-Cluster-List-Partial");
|
|
339
|
+
const placementReady = response.headers.get("X-Cluster-List-Placement-Ready");
|
|
340
|
+
if (partial === "true" || placementReady === "false") {
|
|
341
|
+
throw new Error("incomplete cluster list");
|
|
342
|
+
}
|
|
343
|
+
const body = (await response.json());
|
|
344
|
+
yield (body ?? []).map((item) => this.wrap(item));
|
|
345
|
+
pageToken = (response.headers.get("X-Cluster-List-Next-Page-Token") ?? "").trim();
|
|
346
|
+
if (pageToken === "") {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
throw new Error("incomplete cluster list: exceeded max pages");
|
|
157
351
|
}
|
|
158
|
-
|
|
159
|
-
|
|
352
|
+
/**
|
|
353
|
+
* Looks up the caller's sandbox by name with one request. The reply is only
|
|
354
|
+
* trusted when it holds at most one sandbox and that sandbox carries the
|
|
355
|
+
* requested name: a server that predates `?name=` ignores the filter and
|
|
356
|
+
* returns an ordinary list page, and acting on its first row would target
|
|
357
|
+
* the wrong sandbox.
|
|
358
|
+
*/
|
|
359
|
+
async getByName(name, options) {
|
|
360
|
+
const trimmed = name.trim();
|
|
361
|
+
if (trimmed === "") {
|
|
362
|
+
throw new Error("sandbox name is required");
|
|
363
|
+
}
|
|
364
|
+
const path = this.versioned("/sandboxes") + buildSandboxListQuery({ name: trimmed, includeEnv: options?.includeEnv });
|
|
365
|
+
const body = await this.doJSON("GET", path);
|
|
366
|
+
const items = body ?? [];
|
|
367
|
+
if (items.length === 0) {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
if (items.length > 1 || items[0].name !== trimmed) {
|
|
371
|
+
throw new Error(`${this.baseURL} does not support sandbox name lookup; use the sandbox ID or upgrade the server`);
|
|
372
|
+
}
|
|
373
|
+
return this.wrap(items[0]);
|
|
374
|
+
}
|
|
375
|
+
async get(id, options) {
|
|
376
|
+
const q = buildIncludeEnvQuery(options?.includeEnv);
|
|
377
|
+
const response = await this.doJSON("GET", `${this.versionPrefix}/sandboxes/${id}${q}`);
|
|
160
378
|
return this.wrap(response);
|
|
161
379
|
}
|
|
162
380
|
async start(id) {
|
|
@@ -200,6 +418,25 @@ export class APIClient {
|
|
|
200
418
|
dockerfileContent: image.dockerfile,
|
|
201
419
|
});
|
|
202
420
|
}
|
|
421
|
+
async getSnapshot(name) {
|
|
422
|
+
return fromApiSandboxSnapshot(await this.doJSON("GET", this.versioned(`/snapshots/${encodeURIComponent(name)}`)));
|
|
423
|
+
}
|
|
424
|
+
async listSnapshots(options = {}) {
|
|
425
|
+
const params = new URLSearchParams();
|
|
426
|
+
if (options.limit)
|
|
427
|
+
params.set("limit", String(options.limit));
|
|
428
|
+
if (options.startingAfter)
|
|
429
|
+
params.set("starting_after", options.startingAfter);
|
|
430
|
+
const query = params.toString();
|
|
431
|
+
const response = await this.doJSON("GET", this.versioned(`/snapshots${query ? `?${query}` : ""}`));
|
|
432
|
+
return {
|
|
433
|
+
snapshots: (response.snapshots ?? []).map(fromApiSandboxSnapshot),
|
|
434
|
+
hasMore: response.has_more ?? false,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
async deleteSnapshot(name) {
|
|
438
|
+
await this.doJSON("DELETE", this.versioned(`/snapshots/${encodeURIComponent(name)}`));
|
|
439
|
+
}
|
|
203
440
|
async destroy(id) {
|
|
204
441
|
await this.doJSON("DELETE", `${this.versionPrefix}/sandboxes/${id}`);
|
|
205
442
|
}
|
|
@@ -212,7 +449,8 @@ export class APIClient {
|
|
|
212
449
|
return this.wrap(response);
|
|
213
450
|
}
|
|
214
451
|
async exec(id, request) {
|
|
215
|
-
|
|
452
|
+
// Not idempotent: a re-sent exec runs the caller's command a second time.
|
|
453
|
+
const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/toolbox/process/execute`, toApiExecRequest(request), { idempotent: false });
|
|
216
454
|
return fromApiExecResult(response);
|
|
217
455
|
}
|
|
218
456
|
execStream(id, options) {
|
|
@@ -328,6 +566,22 @@ export class APIClient {
|
|
|
328
566
|
const response = await this.doJSON("GET", "/health");
|
|
329
567
|
return fromApiHealthStatus(response);
|
|
330
568
|
}
|
|
569
|
+
async checkNetworkPolicy(options) {
|
|
570
|
+
const response = await this.doJSON("POST", this.versioned("/network/policy/check"), {
|
|
571
|
+
network_block_all: options.networkBlockAll,
|
|
572
|
+
network_allow_out: options.networkAllowOut,
|
|
573
|
+
network_deny_out: options.networkDenyOut,
|
|
574
|
+
destination: options.destination,
|
|
575
|
+
});
|
|
576
|
+
const result = {
|
|
577
|
+
allowed: response.allowed,
|
|
578
|
+
matchedRule: response.matched_rule ?? "",
|
|
579
|
+
defaultVerdict: response.default_verdict,
|
|
580
|
+
};
|
|
581
|
+
if (response.outside_ceiling)
|
|
582
|
+
result.outsideCeiling = response.outside_ceiling;
|
|
583
|
+
return result;
|
|
584
|
+
}
|
|
331
585
|
async mounts(id) {
|
|
332
586
|
const response = await this.doJSON("GET", `${this.versionPrefix}/sandboxes/${id}/mounts`);
|
|
333
587
|
return response.mounts.map(fromApiMountSpecRedacted);
|
|
@@ -340,10 +594,86 @@ export class APIClient {
|
|
|
340
594
|
const response = await this.doJSON("GET", `${this.versionPrefix}/sandboxes/${id}/network/usage`);
|
|
341
595
|
return fromApiNetworkUsage(response);
|
|
342
596
|
}
|
|
597
|
+
async getAudit(id, options = {}) {
|
|
598
|
+
const params = new URLSearchParams();
|
|
599
|
+
if (options.kind)
|
|
600
|
+
params.set("kind", options.kind);
|
|
601
|
+
if (options.limit !== undefined)
|
|
602
|
+
params.set("limit", String(options.limit));
|
|
603
|
+
if (options.cursor)
|
|
604
|
+
params.set("cursor", options.cursor);
|
|
605
|
+
if (options.incarnationID)
|
|
606
|
+
params.set("incarnation_id", options.incarnationID);
|
|
607
|
+
const query = params.toString();
|
|
608
|
+
const response = await this.doJSON("GET", `${this.versionPrefix}/sandboxes/${id}/audit${query ? `?${query}` : ""}`);
|
|
609
|
+
return fromApiAuditPage(response);
|
|
610
|
+
}
|
|
343
611
|
async setNetworkLimits(id, options) {
|
|
344
612
|
const response = await this.doJSON("PATCH", `${this.versionPrefix}/sandboxes/${id}/network/limits`, toApiSetNetworkLimitsOptions(options));
|
|
345
613
|
return fromApiNetworkUsage(response);
|
|
346
614
|
}
|
|
615
|
+
async setNetworkPolicy(id, options) {
|
|
616
|
+
const response = await this.doJSON("PUT", `${this.versionPrefix}/sandboxes/${id}/network/policy`, {
|
|
617
|
+
network_block_all: options.networkBlockAll ?? false,
|
|
618
|
+
network_allow_out: options.networkAllowOut ?? [],
|
|
619
|
+
network_deny_out: options.networkDenyOut ?? [],
|
|
620
|
+
egress_profiles: options.egressProfiles ?? [],
|
|
621
|
+
network_egress_mode: options.networkEgressMode,
|
|
622
|
+
network_egress_rules: options.networkEgressRules?.map(toApiEgressRule),
|
|
623
|
+
});
|
|
624
|
+
const policy = {
|
|
625
|
+
networkBlockAll: response.network_block_all,
|
|
626
|
+
networkAllowOut: response.network_allow_out ?? [],
|
|
627
|
+
networkDenyOut: response.network_deny_out ?? [],
|
|
628
|
+
egressProfiles: response.egress_profiles ?? [],
|
|
629
|
+
networkEgressMode: response.network_egress_mode ?? "enforce",
|
|
630
|
+
networkEgressRules: (response.network_egress_rules ?? []).map(fromApiEgressRule),
|
|
631
|
+
effectiveHostnameCount: response.effective_hostname_count ?? 0,
|
|
632
|
+
};
|
|
633
|
+
if (response.egress_status)
|
|
634
|
+
policy.egressStatus = response.egress_status;
|
|
635
|
+
return policy;
|
|
636
|
+
}
|
|
637
|
+
async getNetworkLearned(id) {
|
|
638
|
+
const r = await this.doJSON("GET", `${this.versionPrefix}/sandboxes/${id}/network/learned`);
|
|
639
|
+
const learned = {
|
|
640
|
+
mode: r.mode,
|
|
641
|
+
truncated: r.truncated,
|
|
642
|
+
entries: (r.entries ?? []).map((e) => ({ host: e.host, ports: e.ports ?? [], firstSeen: e.first_seen, lastSeen: e.last_seen, hits: e.hits })),
|
|
643
|
+
cidrs: r.cidrs ?? [],
|
|
644
|
+
suggestedAllowOut: r.suggested_allow_out ?? [],
|
|
645
|
+
};
|
|
646
|
+
if (r.suggested_profile) {
|
|
647
|
+
learned.suggestedProfile = { allowOut: r.suggested_profile.allow_out ?? [], description: r.suggested_profile.description };
|
|
648
|
+
}
|
|
649
|
+
return learned;
|
|
650
|
+
}
|
|
651
|
+
async putEgressProfile(name, options) {
|
|
652
|
+
const response = await this.doJSON("PUT", this.versioned(`/egress-profiles/${encodeURIComponent(name)}`), {
|
|
653
|
+
allow_out: options.allowOut,
|
|
654
|
+
description: options.description,
|
|
655
|
+
});
|
|
656
|
+
return fromApiEgressProfile(response);
|
|
657
|
+
}
|
|
658
|
+
async getEgressProfile(name) {
|
|
659
|
+
return fromApiEgressProfile(await this.doJSON("GET", this.versioned(`/egress-profiles/${encodeURIComponent(name)}`)));
|
|
660
|
+
}
|
|
661
|
+
async listEgressProfiles(options = {}) {
|
|
662
|
+
const params = new URLSearchParams();
|
|
663
|
+
if (options.cursor)
|
|
664
|
+
params.set("cursor", options.cursor);
|
|
665
|
+
if (options.limit)
|
|
666
|
+
params.set("limit", String(options.limit));
|
|
667
|
+
const query = params.toString();
|
|
668
|
+
const response = await this.doJSON("GET", this.versioned(`/egress-profiles${query ? `?${query}` : ""}`));
|
|
669
|
+
const list = { profiles: (response.profiles ?? []).map(fromApiEgressProfile) };
|
|
670
|
+
if (response.next_cursor)
|
|
671
|
+
list.nextCursor = response.next_cursor;
|
|
672
|
+
return list;
|
|
673
|
+
}
|
|
674
|
+
async deleteEgressProfile(name) {
|
|
675
|
+
await this.doJSON("DELETE", this.versioned(`/egress-profiles/${encodeURIComponent(name)}`));
|
|
676
|
+
}
|
|
347
677
|
async createTemplate(options) {
|
|
348
678
|
const response = await this.doJSON("POST", this.versioned("/templates"), {
|
|
349
679
|
id: options.id,
|
|
@@ -407,10 +737,33 @@ export class APIClient {
|
|
|
407
737
|
const response = await this.doJSON("POST", `${this.versionPrefix}/templates/${id}/rebuild`);
|
|
408
738
|
return fromApiTemplate(response);
|
|
409
739
|
}
|
|
740
|
+
async whoami() {
|
|
741
|
+
return fromApiWhoAmI(await this.doJSON("GET", this.versioned("/whoami")));
|
|
742
|
+
}
|
|
743
|
+
async createAPIToken(options) {
|
|
744
|
+
const response = await this.doJSON("POST", this.versioned("/tokens"), {
|
|
745
|
+
name: options.name,
|
|
746
|
+
role: options.role,
|
|
747
|
+
team: options.team,
|
|
748
|
+
expires_in: options.expiresIn,
|
|
749
|
+
});
|
|
750
|
+
return { ...fromApiAPIToken(response), token: response.token };
|
|
751
|
+
}
|
|
752
|
+
async listAPITokens() {
|
|
753
|
+
const response = await this.doJSON("GET", this.versioned("/tokens"));
|
|
754
|
+
return { tokens: (response.tokens ?? []).map(fromApiAPIToken) };
|
|
755
|
+
}
|
|
756
|
+
async revokeAPIToken(id) {
|
|
757
|
+
const trimmed = id.trim();
|
|
758
|
+
if (trimmed === "") {
|
|
759
|
+
throw new Error("token id is required");
|
|
760
|
+
}
|
|
761
|
+
await this.doJSON("DELETE", this.versioned(`/tokens/${encodeURIComponent(trimmed)}`));
|
|
762
|
+
}
|
|
410
763
|
wrap(sandbox) {
|
|
411
764
|
return new SandboxResource(this, fromApiSandbox(sandbox));
|
|
412
765
|
}
|
|
413
|
-
async doJSON(method, path, body) {
|
|
766
|
+
async doJSON(method, path, body, options) {
|
|
414
767
|
const init = {};
|
|
415
768
|
if (body !== undefined) {
|
|
416
769
|
init.body = JSON.stringify(body);
|
|
@@ -418,7 +771,7 @@ export class APIClient {
|
|
|
418
771
|
"Content-Type": "application/json",
|
|
419
772
|
};
|
|
420
773
|
}
|
|
421
|
-
const response = await this.request(method, path, init);
|
|
774
|
+
const response = await this.request(method, path, init, options);
|
|
422
775
|
if (!response.ok) {
|
|
423
776
|
throw await decodeError(response);
|
|
424
777
|
}
|
|
@@ -437,46 +790,59 @@ export class APIClient {
|
|
|
437
790
|
/**
|
|
438
791
|
* Low-level HTTP request with automatic retry for transient failures.
|
|
439
792
|
*
|
|
440
|
-
* Transport-level errors (socket closed, connection reset, DNS
|
|
441
|
-
*
|
|
442
|
-
*
|
|
793
|
+
* Transport-level errors (socket closed, connection reset, timeouts, DNS
|
|
794
|
+
* blip) and HTTP 421/429/502/503/504 are retried regardless of HTTP method.
|
|
795
|
+
* Several of those failures can happen after the server received the
|
|
796
|
+
* request, so a retry may deliver it twice; that is safe only because every
|
|
797
|
+
* mutating endpoint in the daemon is designed for idempotent retry (e.g.
|
|
798
|
+
* INSERT OR IGNORE + disambiguation).
|
|
799
|
+
*
|
|
800
|
+
* A request passed `{ idempotent: false }` (exec) retries only transport
|
|
801
|
+
* failures that prove the request never left the client and HTTP 429/503;
|
|
802
|
+
* everything else is surfaced to the caller rather than risk running the
|
|
803
|
+
* command twice.
|
|
443
804
|
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
805
|
+
* A 429 or 503 waits as long as its `Retry-After` asks (see
|
|
806
|
+
* {@link retryDelayMs}). The client's `signal` ends the request and any
|
|
807
|
+
* wait.
|
|
447
808
|
*/
|
|
448
|
-
async request(method, path, init = {}) {
|
|
809
|
+
async request(method, path, init = {}, options = {}) {
|
|
449
810
|
const headers = new Headers(init.headers);
|
|
450
811
|
if (this.patToken !== "") {
|
|
451
812
|
headers.set("Authorization", `Bearer ${this.patToken}`);
|
|
452
813
|
}
|
|
814
|
+
if (this.team !== "") {
|
|
815
|
+
headers.set(TEAM_HEADER, this.team);
|
|
816
|
+
}
|
|
453
817
|
const url = `${this.baseURL}${path}`;
|
|
454
|
-
const
|
|
455
|
-
const {
|
|
456
|
-
|
|
457
|
-
|
|
818
|
+
const signal = init.signal ?? this.signal;
|
|
819
|
+
const requestInit = { ...init, method, headers, ...(signal ? { signal } : {}) };
|
|
820
|
+
const { maxRetries } = this.retryConfig;
|
|
821
|
+
const idempotent = options.idempotent ?? true;
|
|
822
|
+
const retryableStatusCodes = idempotent ? RETRYABLE_STATUS_CODES : REFUSED_STATUS_CODES;
|
|
823
|
+
const isRetryableError = idempotent ? isTransientTransportError : isUnsentTransportError;
|
|
824
|
+
for (let attempt = 0;; attempt++) {
|
|
825
|
+
let retryAfter = null;
|
|
458
826
|
try {
|
|
459
827
|
const response = await this.fetchFn(url, requestInit);
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
828
|
+
if (!retryableStatusCodes.has(response.status) || attempt >= maxRetries) {
|
|
829
|
+
return response;
|
|
830
|
+
}
|
|
831
|
+
if (REFUSED_STATUS_CODES.has(response.status)) {
|
|
832
|
+
retryAfter = response.headers.get("retry-after");
|
|
465
833
|
}
|
|
466
|
-
|
|
834
|
+
// Free the connection instead of holding it through the wait.
|
|
835
|
+
response.body?.cancel().catch(() => undefined);
|
|
467
836
|
}
|
|
468
837
|
catch (error) {
|
|
469
|
-
|
|
470
|
-
//
|
|
471
|
-
if (!
|
|
838
|
+
// Only retry transport-level failures; anything else propagates. An
|
|
839
|
+
// aborted signal surfaces whatever fetch threw for it.
|
|
840
|
+
if (signal?.aborted || !isRetryableError(error) || attempt >= maxRetries) {
|
|
472
841
|
throw error;
|
|
473
842
|
}
|
|
474
|
-
const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
475
|
-
await jitteredDelay(delay);
|
|
476
843
|
}
|
|
844
|
+
await sleep(retryDelayMs(this.retryConfig, attempt, retryAfter, Date.now(), Math.random()), signal);
|
|
477
845
|
}
|
|
478
|
-
// Should be unreachable — the loop always throws or returns.
|
|
479
|
-
throw lastError;
|
|
480
846
|
}
|
|
481
847
|
}
|
|
482
848
|
export class SandboxResource {
|
|
@@ -594,6 +960,36 @@ export class SandboxResource {
|
|
|
594
960
|
async setNetworkLimits(options) {
|
|
595
961
|
return this.client.setNetworkLimits(this.id, options);
|
|
596
962
|
}
|
|
963
|
+
/**
|
|
964
|
+
* Replaces this sandbox's egress policy while it runs. The call returns
|
|
965
|
+
* once the new policy is enforced; sending the same policy again is a
|
|
966
|
+
* no-op, so it is safe to retry.
|
|
967
|
+
*/
|
|
968
|
+
async setNetworkPolicy(options) {
|
|
969
|
+
const policy = await this.client.setNetworkPolicy(this.id, options);
|
|
970
|
+
this.networkBlockAll = policy.networkBlockAll;
|
|
971
|
+
this.egressStatus = policy.egressStatus;
|
|
972
|
+
this.egressProfiles = policy.egressProfiles.length ? policy.egressProfiles : undefined;
|
|
973
|
+
this.networkEgressMode = policy.networkEgressMode === "learn" ? "learn" : undefined;
|
|
974
|
+
this.networkEgressRules = policy.networkEgressRules.length ? policy.networkEgressRules : undefined;
|
|
975
|
+
return policy;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Reads what this sandbox reached in learn mode and the allow list that
|
|
979
|
+
* would have allowed it. A recording stays readable after the sandbox
|
|
980
|
+
* switches to enforce, until it is destroyed.
|
|
981
|
+
*/
|
|
982
|
+
async learned() {
|
|
983
|
+
return this.client.getNetworkLearned(this.id);
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Reads this sandbox's audit log: outbound connections and egress denials
|
|
987
|
+
* (`kind: "egress"`), and secret reads. Denials carry `result: "failure"`
|
|
988
|
+
* and the policy `reason`.
|
|
989
|
+
*/
|
|
990
|
+
async audit(options) {
|
|
991
|
+
return this.client.getAudit(this.id, options);
|
|
992
|
+
}
|
|
597
993
|
toJSON() {
|
|
598
994
|
return cloneSandbox(this);
|
|
599
995
|
}
|
|
@@ -603,6 +999,8 @@ export class SandboxResource {
|
|
|
603
999
|
}
|
|
604
1000
|
function toApiCreateOptions(options) {
|
|
605
1001
|
return {
|
|
1002
|
+
name: options.name,
|
|
1003
|
+
tags: options.tags,
|
|
606
1004
|
image: options.image,
|
|
607
1005
|
cpu: options.cpu,
|
|
608
1006
|
memory_mb: options.memoryMB,
|
|
@@ -612,6 +1010,9 @@ function toApiCreateOptions(options) {
|
|
|
612
1010
|
network_block_all: options.networkBlockAll,
|
|
613
1011
|
network_allow_out: options.networkAllowOut,
|
|
614
1012
|
network_deny_out: options.networkDenyOut,
|
|
1013
|
+
egress_profiles: options.egressProfiles,
|
|
1014
|
+
network_egress_mode: options.networkEgressMode,
|
|
1015
|
+
network_egress_rules: options.networkEgressRules?.map(toApiEgressRule),
|
|
615
1016
|
allow_public_traffic: options.allowPublicTraffic,
|
|
616
1017
|
mask_request_host: options.maskRequestHost,
|
|
617
1018
|
network_bytes_in_limit: options.networkBytesInLimit,
|
|
@@ -623,7 +1024,19 @@ function toApiCreateOptions(options) {
|
|
|
623
1024
|
lifecycle: options.lifecycle ? toApiLifecycle(options.lifecycle) : undefined,
|
|
624
1025
|
failover: options.failover ? toApiFailover(options.failover) : undefined,
|
|
625
1026
|
runtime: options.runtime,
|
|
1027
|
+
durability: options.durability,
|
|
1028
|
+
module_ref: options.moduleRef,
|
|
1029
|
+
tenant_id: options.tenantId,
|
|
626
1030
|
custom_domains: options.customDomains,
|
|
1031
|
+
gpus: options.gpus ? toApiGPUOptions(options.gpus) : undefined,
|
|
1032
|
+
template_id: options.templateId,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
function toApiGPUOptions(gpus) {
|
|
1036
|
+
return {
|
|
1037
|
+
vendor: gpus.vendor,
|
|
1038
|
+
count: gpus.count,
|
|
1039
|
+
device_ids: gpus.deviceIDs,
|
|
627
1040
|
};
|
|
628
1041
|
}
|
|
629
1042
|
function toApiRegisterSnapshotOptions(options) {
|
|
@@ -684,6 +1097,8 @@ function toApiFailover(failover) {
|
|
|
684
1097
|
function fromApiSandbox(sandbox) {
|
|
685
1098
|
return {
|
|
686
1099
|
id: sandbox.id,
|
|
1100
|
+
name: sandbox.name || undefined,
|
|
1101
|
+
tags: sandbox.tags,
|
|
687
1102
|
image: sandbox.image,
|
|
688
1103
|
status: sandbox.status,
|
|
689
1104
|
publicURL: sandbox.public_url,
|
|
@@ -695,6 +1110,11 @@ function fromApiSandbox(sandbox) {
|
|
|
695
1110
|
osUser: sandbox.os_user,
|
|
696
1111
|
env: sandbox.env,
|
|
697
1112
|
networkBlockAll: sandbox.network_block_all,
|
|
1113
|
+
egressStatus: sandbox.egress_status || undefined,
|
|
1114
|
+
egressProfiles: sandbox.egress_profiles?.length ? sandbox.egress_profiles : undefined,
|
|
1115
|
+
egressProfilesApplied: sandbox.egress_profiles_applied?.length ? sandbox.egress_profiles_applied : undefined,
|
|
1116
|
+
networkEgressMode: sandbox.network_egress_mode || undefined,
|
|
1117
|
+
networkEgressRules: sandbox.network_egress_rules?.length ? sandbox.network_egress_rules.map(fromApiEgressRule) : undefined,
|
|
698
1118
|
toolboxEnabled: sandbox.toolbox_enabled,
|
|
699
1119
|
sshPublicKey: sandbox.ssh_public_key,
|
|
700
1120
|
exposedPorts: sandbox.exposed_ports?.map(fromApiExposedPort),
|
|
@@ -706,13 +1126,22 @@ function fromApiSandbox(sandbox) {
|
|
|
706
1126
|
lifecycle: fromApiLifecycle(sandbox.lifecycle),
|
|
707
1127
|
failover: fromApiFailover(sandbox.failover),
|
|
708
1128
|
runtime: normalizeRuntime(sandbox.runtime),
|
|
1129
|
+
durability: sandbox.durability,
|
|
1130
|
+
moduleRef: sandbox.module_ref,
|
|
1131
|
+
moduleDigest: sandbox.module_digest,
|
|
1132
|
+
tenantId: sandbox.tenant_id,
|
|
709
1133
|
};
|
|
710
1134
|
}
|
|
711
1135
|
// normalizeRuntime narrows the wire-level string to the union we expose, while
|
|
712
1136
|
// tolerating older sandboxd versions that don't send the field at all (treat
|
|
713
1137
|
// as "" — i.e. host default at start time).
|
|
714
1138
|
function normalizeRuntime(value) {
|
|
715
|
-
if (value === "docker" ||
|
|
1139
|
+
if (value === "docker" ||
|
|
1140
|
+
value === "gvisor" ||
|
|
1141
|
+
value === "kata" ||
|
|
1142
|
+
value === "firecracker" ||
|
|
1143
|
+
value === "wasm" ||
|
|
1144
|
+
value === "isolate") {
|
|
716
1145
|
return value;
|
|
717
1146
|
}
|
|
718
1147
|
return "";
|
|
@@ -874,6 +1303,31 @@ function fromApiMountSpecRedacted(mount) {
|
|
|
874
1303
|
hasCredentials: mount.has_credentials,
|
|
875
1304
|
};
|
|
876
1305
|
}
|
|
1306
|
+
function fromApiAuditPage(page) {
|
|
1307
|
+
const out = {
|
|
1308
|
+
events: (page.events ?? []).map((e) => ({
|
|
1309
|
+
time: e.time,
|
|
1310
|
+
kind: e.kind,
|
|
1311
|
+
result: e.result,
|
|
1312
|
+
reason: e.reason,
|
|
1313
|
+
destination: e.destination,
|
|
1314
|
+
network: e.network,
|
|
1315
|
+
actor: e.actor,
|
|
1316
|
+
ref: e.ref,
|
|
1317
|
+
eventID: e.event_id,
|
|
1318
|
+
incarnationID: e.incarnation_id,
|
|
1319
|
+
dropped: e.dropped,
|
|
1320
|
+
})),
|
|
1321
|
+
coverage: {
|
|
1322
|
+
answered: page.coverage?.answered ?? [],
|
|
1323
|
+
missing: page.coverage?.missing ?? [],
|
|
1324
|
+
partial: page.coverage?.partial ?? false,
|
|
1325
|
+
},
|
|
1326
|
+
};
|
|
1327
|
+
if (page.next_cursor)
|
|
1328
|
+
out.nextCursor = page.next_cursor;
|
|
1329
|
+
return out;
|
|
1330
|
+
}
|
|
877
1331
|
function fromApiNetworkUsage(usage) {
|
|
878
1332
|
return {
|
|
879
1333
|
sandboxID: usage.sandbox_id,
|
|
@@ -933,10 +1387,32 @@ function buildTagQuery(tags) {
|
|
|
933
1387
|
const parts = entries.map(([key, value]) => `tag.${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
|
934
1388
|
return "?" + parts.join("&");
|
|
935
1389
|
}
|
|
1390
|
+
function buildIncludeEnvQuery(includeEnv) {
|
|
1391
|
+
return includeEnv ? "?include_env=true" : "";
|
|
1392
|
+
}
|
|
1393
|
+
function buildSandboxListQuery(options) {
|
|
1394
|
+
let query = buildTagQuery(options?.tags);
|
|
1395
|
+
if (options?.includeEnv) {
|
|
1396
|
+
query = appendQueryParam(query, "include_env", "true");
|
|
1397
|
+
}
|
|
1398
|
+
const name = options?.name?.trim();
|
|
1399
|
+
if (name) {
|
|
1400
|
+
query = appendQueryParam(query, "name", name);
|
|
1401
|
+
}
|
|
1402
|
+
return query;
|
|
1403
|
+
}
|
|
1404
|
+
function appendQueryParam(path, key, value) {
|
|
1405
|
+
if (!value) {
|
|
1406
|
+
return path;
|
|
1407
|
+
}
|
|
1408
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
1409
|
+
return `${path}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
|
1410
|
+
}
|
|
936
1411
|
function cloneSandbox(sandbox) {
|
|
937
1412
|
return {
|
|
938
1413
|
...sandbox,
|
|
939
1414
|
env: sandbox.env ? { ...sandbox.env } : undefined,
|
|
1415
|
+
tags: sandbox.tags ? { ...sandbox.tags } : undefined,
|
|
940
1416
|
exposedPorts: sandbox.exposedPorts?.map((port) => ({ ...port })),
|
|
941
1417
|
containerCommand: sandbox.containerCommand ? [...sandbox.containerCommand] : undefined,
|
|
942
1418
|
lifecycle: { ...sandbox.lifecycle },
|