@aerol-ai/aerolvm-sdk 0.6.4 → 0.8.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 +62 -3
- package/dist/MicroVM.js +40 -2
- package/dist/errors.d.ts +28 -0
- package/dist/errors.js +31 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -0
- package/dist/internal/client.d.ts +78 -9
- package/dist/internal/client.js +272 -40
- package/dist/types.d.ts +97 -6
- package/package.json +2 -2
package/dist/MicroVM.d.ts
CHANGED
|
@@ -1,11 +1,40 @@
|
|
|
1
1
|
import { type APIVersion, type RetryConfig } from "./internal/client.js";
|
|
2
2
|
import { Sandbox } from "./Sandbox.js";
|
|
3
3
|
import { Image } from "./Image.js";
|
|
4
|
-
import type { BuildImageOptions, BuildImageResult, CloneGeneration, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, PushWasmModuleOptions, PushWasmModuleResult, ExecStreamHandle, ExecStreamOptions, HealthStatus, EgressProfile, EgressProfileList, EgressProfileOptions, ListEgressProfilesOptions, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, IngressTarget, Lifecycle, GetOptions, ListOptions, MountSpecRedacted, RegisterSnapshotOptions, ResizeOptions, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions, Template, WasmModule } from "./types.js";
|
|
4
|
+
import type { APITokenList, BuildImageOptions, BuildImageResult, CloneGeneration, CreateAPITokenOptions, CreatedAPIToken, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, PushWasmModuleOptions, PushWasmModuleResult, ExecStreamHandle, ExecStreamOptions, HealthStatus, EgressProfile, EgressProfileList, EgressProfileOptions, ListEgressProfilesOptions, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, IngressTarget, Lifecycle, GetOptions, ListOptions, MountSpecRedacted, RegisterSnapshotOptions, ListSnapshotsOptions, SnapshotList, ResizeOptions, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions, Template, WasmModule, WhoAmI } from "./types.js";
|
|
5
5
|
type FetchLike = typeof fetch;
|
|
6
|
+
/** Scoped API token calls, reached as `microvm.tokens`. */
|
|
7
|
+
export interface Tokens {
|
|
8
|
+
/**
|
|
9
|
+
* Mint a token (admin only). The result's `token` is the secret, returned
|
|
10
|
+
* this once and never again. A 409 means the name is taken, possibly by an
|
|
11
|
+
* earlier attempt whose reply was lost; that secret cannot be recovered,
|
|
12
|
+
* so revoke the token and create it again.
|
|
13
|
+
*/
|
|
14
|
+
create(options: CreateAPITokenOptions): Promise<CreatedAPIToken>;
|
|
15
|
+
/** List every token (auditor or admin); expired ones stay listed for 30 days. */
|
|
16
|
+
list(): Promise<APITokenList>;
|
|
17
|
+
/** Revoke a token by id (admin only). Revoking one already gone succeeds. */
|
|
18
|
+
revoke(id: string): Promise<void>;
|
|
19
|
+
}
|
|
6
20
|
export interface MicroVMConfig {
|
|
21
|
+
/**
|
|
22
|
+
* The bearer every request carries: the operator PAT, an `avm_` API token
|
|
23
|
+
* or an OIDC ID token. Defaults to `SB_PAT_TOKEN`, then its alias
|
|
24
|
+
* `SB_API_TOKEN`; when both are set, `SB_PAT_TOKEN` wins.
|
|
25
|
+
*/
|
|
7
26
|
patToken?: string;
|
|
8
27
|
apiUrl?: string;
|
|
28
|
+
/**
|
|
29
|
+
* The team requests act for, sent as the `X-AerolVM-Team` header on every
|
|
30
|
+
* HTTP request. Defaults to `SB_TEAM`; with neither, no header is sent and
|
|
31
|
+
* the server uses the credential's first matching binding. The header only
|
|
32
|
+
* chooses among teams the credential is already bound to (a team it does
|
|
33
|
+
* not hold is refused with 403), and the operator PAT ignores it.
|
|
34
|
+
* WebSocket streams (`execStream`, `attachSession`) cannot carry custom
|
|
35
|
+
* headers from a browser or Node, so they go without it.
|
|
36
|
+
*/
|
|
37
|
+
team?: string;
|
|
9
38
|
fetch?: FetchLike;
|
|
10
39
|
/**
|
|
11
40
|
* Wire version of the sandbox daemon API to call. Defaults to the SDK's
|
|
@@ -17,14 +46,27 @@ export interface MicroVMConfig {
|
|
|
17
46
|
/**
|
|
18
47
|
* Retry policy for transient transport errors (socket closed, connection
|
|
19
48
|
* reset) and retryable HTTP status codes (421, 429, 502, 503, 504). The SDK
|
|
20
|
-
* retries up to 3 times with exponential backoff by default.
|
|
21
|
-
*
|
|
49
|
+
* retries up to 3 times with exponential backoff by default. `exec` retries
|
|
50
|
+
* only failures that prove the command never reached the server
|
|
51
|
+
* (connection refused, connect timeout, DNS) and HTTP 429/503, so a command
|
|
52
|
+
* never runs twice. Pass `{ maxRetries: 0 }` to disable. A 429 or 503
|
|
53
|
+
* waits as long as the server's `Retry-After` asks, up to
|
|
54
|
+
* `retry.maxRetryAfterMs` (30 s by default).
|
|
22
55
|
*/
|
|
23
56
|
retry?: RetryConfig;
|
|
57
|
+
/**
|
|
58
|
+
* Cancels the client's HTTP requests: aborting it ends the request in
|
|
59
|
+
* flight and any retry wait, so a call never sits out a long
|
|
60
|
+
* `Retry-After` once aborted. Pass `AbortSignal.timeout(ms)` to bound
|
|
61
|
+
* how long the client may run. WebSocket streams are not affected.
|
|
62
|
+
*/
|
|
63
|
+
signal?: AbortSignal;
|
|
24
64
|
}
|
|
25
65
|
export declare class MicroVM {
|
|
26
66
|
readonly apiUrl: string;
|
|
27
67
|
readonly patToken: string;
|
|
68
|
+
/** The team sent as `X-AerolVM-Team`, or `""` when none is. */
|
|
69
|
+
readonly team: string;
|
|
28
70
|
private readonly client;
|
|
29
71
|
constructor(config?: MicroVMConfig);
|
|
30
72
|
create(options: CreateOptions): Promise<Sandbox>;
|
|
@@ -46,6 +88,12 @@ export declare class MicroVM {
|
|
|
46
88
|
createSnapshot(id: string, name: string): Promise<SandboxSnapshot>;
|
|
47
89
|
registerSnapshot(options: RegisterSnapshotOptions): Promise<SandboxSnapshot>;
|
|
48
90
|
registerSnapshotFromImage(name: string, image: Image, options?: Omit<RegisterSnapshotOptions, "name" | "image" | "dockerfileContent">): Promise<SandboxSnapshot>;
|
|
91
|
+
/** Look a snapshot up by name. In a cluster the request is routed to the node holding the image. */
|
|
92
|
+
getSnapshot(name: string): Promise<SandboxSnapshot>;
|
|
93
|
+
/** List the caller's snapshots, one name-ordered page at a time. */
|
|
94
|
+
listSnapshots(options?: ListSnapshotsOptions): Promise<SnapshotList>;
|
|
95
|
+
/** Delete a snapshot by name (idempotent 404 if it does not exist). */
|
|
96
|
+
deleteSnapshot(name: string): Promise<void>;
|
|
49
97
|
destroy(id: string): Promise<void>;
|
|
50
98
|
resize(id: string, options: ResizeOptions): Promise<Sandbox>;
|
|
51
99
|
updateLifecycle(id: string, lifecycle: Lifecycle): Promise<Sandbox>;
|
|
@@ -143,6 +191,17 @@ export declare class MicroVM {
|
|
|
143
191
|
get dns(): {
|
|
144
192
|
target(): Promise<IngressTarget>;
|
|
145
193
|
};
|
|
194
|
+
/**
|
|
195
|
+
* Who the server says the caller is: principal, role, team and when the
|
|
196
|
+
* credential expires. Every valid credential may ask.
|
|
197
|
+
*/
|
|
198
|
+
whoami(): Promise<WhoAmI>;
|
|
199
|
+
/**
|
|
200
|
+
* Scoped API tokens (`avm_…`) for services and CI, as
|
|
201
|
+
* `microvm.tokens.create/list/revoke`. Same namespaced shape as
|
|
202
|
+
* {@link MicroVM.dns}.
|
|
203
|
+
*/
|
|
204
|
+
get tokens(): Tokens;
|
|
146
205
|
private wrap;
|
|
147
206
|
}
|
|
148
207
|
export {};
|
package/dist/MicroVM.js
CHANGED
|
@@ -1,25 +1,31 @@
|
|
|
1
1
|
import { APIClient, stripTrailingSlashes } from "./internal/client.js";
|
|
2
2
|
import { Sandbox } from "./Sandbox.js";
|
|
3
3
|
const defaultAPIURL = "http://127.0.0.1:21212";
|
|
4
|
-
const authRequiredErrorMessage = "PAT token is required. Set patToken or
|
|
4
|
+
const authRequiredErrorMessage = "PAT token is required. Set patToken, SB_PAT_TOKEN or SB_API_TOKEN.";
|
|
5
5
|
export class MicroVM {
|
|
6
6
|
apiUrl;
|
|
7
7
|
patToken;
|
|
8
|
+
/** The team sent as `X-AerolVM-Team`, or `""` when none is. */
|
|
9
|
+
team;
|
|
8
10
|
client;
|
|
9
11
|
constructor(config = {}) {
|
|
10
|
-
const patToken = config.patToken ?? readEnv("SB_PAT_TOKEN") ?? "";
|
|
12
|
+
const patToken = config.patToken ?? readEnv("SB_PAT_TOKEN") ?? readEnv("SB_API_TOKEN") ?? "";
|
|
11
13
|
const apiUrl = normalizeURL(config.apiUrl ?? readEnv("SB_API_URL") ?? defaultAPIURL);
|
|
14
|
+
const team = (config.team ?? readEnv("SB_TEAM") ?? "").trim();
|
|
12
15
|
if (patToken === "") {
|
|
13
16
|
throw new Error(authRequiredErrorMessage);
|
|
14
17
|
}
|
|
15
18
|
this.apiUrl = apiUrl;
|
|
16
19
|
this.patToken = patToken;
|
|
20
|
+
this.team = team;
|
|
17
21
|
this.client = new APIClient({
|
|
18
22
|
baseURL: apiUrl,
|
|
19
23
|
patToken,
|
|
24
|
+
team,
|
|
20
25
|
fetch: config.fetch,
|
|
21
26
|
apiVersion: config.apiVersion,
|
|
22
27
|
retry: config.retry,
|
|
28
|
+
signal: config.signal,
|
|
23
29
|
});
|
|
24
30
|
}
|
|
25
31
|
async create(options) {
|
|
@@ -69,6 +75,18 @@ export class MicroVM {
|
|
|
69
75
|
async registerSnapshotFromImage(name, image, options = {}) {
|
|
70
76
|
return this.client.registerSnapshotFromImage(name, image, options);
|
|
71
77
|
}
|
|
78
|
+
/** Look a snapshot up by name. In a cluster the request is routed to the node holding the image. */
|
|
79
|
+
async getSnapshot(name) {
|
|
80
|
+
return this.client.getSnapshot(name);
|
|
81
|
+
}
|
|
82
|
+
/** List the caller's snapshots, one name-ordered page at a time. */
|
|
83
|
+
async listSnapshots(options = {}) {
|
|
84
|
+
return this.client.listSnapshots(options);
|
|
85
|
+
}
|
|
86
|
+
/** Delete a snapshot by name (idempotent 404 if it does not exist). */
|
|
87
|
+
async deleteSnapshot(name) {
|
|
88
|
+
return this.client.deleteSnapshot(name);
|
|
89
|
+
}
|
|
72
90
|
async destroy(id) {
|
|
73
91
|
await this.client.destroy(id);
|
|
74
92
|
}
|
|
@@ -237,6 +255,26 @@ export class MicroVM {
|
|
|
237
255
|
target: () => client.ingressDNS(),
|
|
238
256
|
};
|
|
239
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* Who the server says the caller is: principal, role, team and when the
|
|
260
|
+
* credential expires. Every valid credential may ask.
|
|
261
|
+
*/
|
|
262
|
+
async whoami() {
|
|
263
|
+
return this.client.whoami();
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Scoped API tokens (`avm_…`) for services and CI, as
|
|
267
|
+
* `microvm.tokens.create/list/revoke`. Same namespaced shape as
|
|
268
|
+
* {@link MicroVM.dns}.
|
|
269
|
+
*/
|
|
270
|
+
get tokens() {
|
|
271
|
+
const client = this.client;
|
|
272
|
+
return {
|
|
273
|
+
create: (options) => client.createAPIToken(options),
|
|
274
|
+
list: () => client.listAPITokens(),
|
|
275
|
+
revoke: (id) => client.revokeAPIToken(id),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
240
278
|
wrap(sandbox) {
|
|
241
279
|
return new Sandbox(this.client, sandbox);
|
|
242
280
|
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An HTTP error response from the AerolVM server. `message` is the server's
|
|
3
|
+
* message, or `request failed with status N` when the body carries none (a
|
|
4
|
+
* proxy's HTML error page, say): the same text the SDK threw before this
|
|
5
|
+
* class existed, so code that printed or matched it sees no change.
|
|
6
|
+
*
|
|
7
|
+
* Branch on `code`, not on `message`: the code is a stable identifier, the
|
|
8
|
+
* message is wording for a person.
|
|
9
|
+
*/
|
|
10
|
+
export declare class MicroVMApiError extends Error {
|
|
11
|
+
/** The HTTP status of the final attempt, such as 422 or 503. */
|
|
12
|
+
readonly status: number;
|
|
13
|
+
/**
|
|
14
|
+
* The server's stable error code, such as `"idempotency_key_reused"` or
|
|
15
|
+
* `"overloaded"`. Undefined when the response has none: many errors are
|
|
16
|
+
* only ever shown to a person, and a proxy's error page has no code.
|
|
17
|
+
*/
|
|
18
|
+
readonly code: string | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* How long the response's `Retry-After` header asked the caller to wait,
|
|
21
|
+
* in milliseconds. Undefined without a usable header.
|
|
22
|
+
*/
|
|
23
|
+
readonly retryAfterMs: number | undefined;
|
|
24
|
+
constructor(status: number, message: string, options?: {
|
|
25
|
+
code?: string;
|
|
26
|
+
retryAfterMs?: number;
|
|
27
|
+
});
|
|
28
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An HTTP error response from the AerolVM server. `message` is the server's
|
|
3
|
+
* message, or `request failed with status N` when the body carries none (a
|
|
4
|
+
* proxy's HTML error page, say): the same text the SDK threw before this
|
|
5
|
+
* class existed, so code that printed or matched it sees no change.
|
|
6
|
+
*
|
|
7
|
+
* Branch on `code`, not on `message`: the code is a stable identifier, the
|
|
8
|
+
* message is wording for a person.
|
|
9
|
+
*/
|
|
10
|
+
export class MicroVMApiError extends Error {
|
|
11
|
+
/** The HTTP status of the final attempt, such as 422 or 503. */
|
|
12
|
+
status;
|
|
13
|
+
/**
|
|
14
|
+
* The server's stable error code, such as `"idempotency_key_reused"` or
|
|
15
|
+
* `"overloaded"`. Undefined when the response has none: many errors are
|
|
16
|
+
* only ever shown to a person, and a proxy's error page has no code.
|
|
17
|
+
*/
|
|
18
|
+
code;
|
|
19
|
+
/**
|
|
20
|
+
* How long the response's `Retry-After` header asked the caller to wait,
|
|
21
|
+
* in milliseconds. Undefined without a usable header.
|
|
22
|
+
*/
|
|
23
|
+
retryAfterMs;
|
|
24
|
+
constructor(status, message, options = {}) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "MicroVMApiError";
|
|
27
|
+
this.status = status;
|
|
28
|
+
this.code = options.code;
|
|
29
|
+
this.retryAfterMs = options.retryAfterMs;
|
|
30
|
+
}
|
|
31
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { MicroVM } from "./MicroVM.js";
|
|
2
2
|
export { Sandbox } from "./Sandbox.js";
|
|
3
3
|
export { Image } from "./Image.js";
|
|
4
|
-
export
|
|
4
|
+
export { MicroVMApiError } from "./errors.js";
|
|
5
|
+
export type { MicroVMConfig, Tokens } from "./MicroVM.js";
|
|
5
6
|
export type { RetryConfig } from "./internal/client.js";
|
|
6
|
-
export type { AddCustomDomainOptions, AuditEvent, AuditOptions, AuditPage, EgressProfile, EgressProfileList, EgressProfileOptions, EgressProfileRef, EgressInject, EgressRule, ListEgressProfilesOptions, NetworkLearned, NetworkLearnedEntry, NetworkPolicy, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, NetworkPolicyOptions, BinaryLike, BuildImageOptions, BuildImagePushOptions, BuildImageResult, CloneGeneration, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, CustomDomain, CustomDomainDNSRecords, CustomDomainStatus, DNSRecord, DNSRecordType, ExecRequest, ExecResult, ExecExitInfo, ExposedPort, ExposePortOptions, ExposeProtocol, ExposeResult, Failover, FailoverPolicy, GetOptions, HealthStatus, IngressTarget, IngressTargetSource, Lifecycle, ListOptions, MountSpec, MountSpecRedacted, MountType, PlatformVolumeMount, RegisterSnapshotOptions, RegistryAuth, ResizeOptions, Sandbox as SandboxData, SandboxSnapshot, SandboxStatus, Session, SessionAttachHandle, SessionAttachOptions, SessionStatus, Template, TemplatePushState, TemplateStatus, WasmModule, WasmModuleStatus, PushWasmModuleOptions, PushWasmModuleResult, UpdateLifecycleOptions, } from "./types.js";
|
|
7
|
+
export type { AddCustomDomainOptions, APIToken, APITokenList, CreateAPITokenOptions, CreatedAPIToken, WhoAmI, WhoAmIPrincipal, AuditEvent, AuditOptions, AuditPage, EgressProfile, EgressProfileList, EgressProfileOptions, EgressProfileRef, EgressInject, EgressRule, ListEgressProfilesOptions, NetworkLearned, NetworkLearnedEntry, NetworkPolicy, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, NetworkPolicyOptions, BinaryLike, BuildImageOptions, BuildImagePushOptions, BuildImageResult, CloneGeneration, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, CustomDomain, CustomDomainDNSRecords, CustomDomainStatus, DNSRecord, DNSRecordType, ExecRequest, ExecResult, ExecExitInfo, ExposedPort, ExposePortOptions, ExposeProtocol, ExposeResult, Failover, FailoverPolicy, GetOptions, HealthStatus, IngressTarget, IngressTargetSource, Lifecycle, ListOptions, MountSpec, MountSpecRedacted, MountType, PlatformVolumeMount, RegisterSnapshotOptions, ListSnapshotsOptions, SnapshotList, RegistryAuth, ResizeOptions, Sandbox as SandboxData, SandboxSnapshot, SandboxStatus, Session, SessionAttachHandle, SessionAttachOptions, SessionStatus, Template, TemplatePushState, TemplateStatus, WasmModule, WasmModuleStatus, PushWasmModuleOptions, PushWasmModuleResult, UpdateLifecycleOptions, } from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { Image } from "../Image.js";
|
|
2
|
-
import type { AddCustomDomainOptions, BinaryLike, BuildImageOptions, BuildImageResult, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, PushWasmModuleOptions, PushWasmModuleResult, CustomDomain, CloneGeneration, CustomDomainDNSRecords, ExecRequest, ExecResult, ExecStreamHandle, ExecStreamOptions, ExposedPort, ExposePortOptions, ExposeResult, Failover, HealthStatus, IngressTarget, Lifecycle, GetOptions, ListOptions, MountSpecRedacted, NetworkUsage, AuditOptions, AuditPage, EgressProfile, EgressProfileList, EgressProfileOptions, EgressProfileRef, EgressRule, ListEgressProfilesOptions, NetworkLearned, NetworkPolicy, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, NetworkPolicyOptions, RegisterSnapshotOptions, SetNetworkLimitsOptions, ResizeOptions, Sandbox, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions, Template, WasmModule } from "../types.js";
|
|
2
|
+
import type { AddCustomDomainOptions, APITokenList, BinaryLike, BuildImageOptions, BuildImageResult, CreateAPITokenOptions, CreatedAPIToken, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, PushWasmModuleOptions, PushWasmModuleResult, CustomDomain, CloneGeneration, CustomDomainDNSRecords, ExecRequest, ExecResult, ExecStreamHandle, ExecStreamOptions, ExposedPort, ExposePortOptions, ExposeResult, Failover, HealthStatus, IngressTarget, Lifecycle, GetOptions, ListOptions, MountSpecRedacted, NetworkUsage, AuditOptions, AuditPage, EgressProfile, EgressProfileList, EgressProfileOptions, EgressProfileRef, EgressRule, ListEgressProfilesOptions, NetworkLearned, NetworkPolicy, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, NetworkPolicyOptions, ListSnapshotsOptions, RegisterSnapshotOptions, SnapshotList, SetNetworkLimitsOptions, ResizeOptions, Sandbox, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions, Template, WasmModule, WhoAmI } from "../types.js";
|
|
3
3
|
type FetchLike = typeof fetch;
|
|
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 declare const TEAM_HEADER = "X-AerolVM-Team";
|
|
4
9
|
/**
|
|
5
10
|
* Wire version of the v1-style sandbox endpoints (`/v1/sandboxes`, etc.) this
|
|
6
11
|
* client speaks. The SDK and the server version independently — bumping the
|
|
@@ -36,10 +41,24 @@ export interface RetryConfig {
|
|
|
36
41
|
* waits when `maxRetries` is high. Defaults to `5000`.
|
|
37
42
|
*/
|
|
38
43
|
maxDelayMs?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Cap in milliseconds on the wait before re-sending a 429 or 503. The SDK
|
|
46
|
+
* waits as long as the server's `Retry-After` header asks (seconds or an
|
|
47
|
+
* HTTP date), never less than its own backoff, plus up to a fifth more so
|
|
48
|
+
* clients told the same value spread out; a longer ask is cut to this. A
|
|
49
|
+
* missing or malformed header uses the backoff above. Defaults to
|
|
50
|
+
* `30000`; `0` ignores `Retry-After`.
|
|
51
|
+
*/
|
|
52
|
+
maxRetryAfterMs?: number;
|
|
39
53
|
}
|
|
40
54
|
export interface APIClientConfig {
|
|
41
55
|
baseURL: string;
|
|
42
56
|
patToken?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Sent as the {@link TEAM_HEADER} header on every HTTP request. Empty or
|
|
59
|
+
* absent sends no header.
|
|
60
|
+
*/
|
|
61
|
+
team?: string;
|
|
43
62
|
fetch?: FetchLike;
|
|
44
63
|
/**
|
|
45
64
|
* Wire version to call. Defaults to "v1". Pin this if you need a specific
|
|
@@ -49,11 +68,44 @@ export interface APIClientConfig {
|
|
|
49
68
|
apiVersion?: APIVersion;
|
|
50
69
|
/**
|
|
51
70
|
* Retry policy for transient transport errors (socket closed, connection
|
|
52
|
-
* reset) and retryable HTTP status codes (421, 429, 502, 503, 504).
|
|
53
|
-
*
|
|
71
|
+
* reset) and retryable HTTP status codes (421, 429, 502, 503, 504). `exec`
|
|
72
|
+
* is narrower: it retries only failures that prove the command never
|
|
73
|
+
* reached the server (connection refused, connect timeout, DNS) and HTTP
|
|
74
|
+
* 429/503. Pass `{ maxRetries: 0 }` to disable retry entirely.
|
|
54
75
|
*/
|
|
55
76
|
retry?: RetryConfig;
|
|
77
|
+
/**
|
|
78
|
+
* Cancels the client's HTTP requests: aborting it ends the request in
|
|
79
|
+
* flight and any retry wait, so a call never sits out a long
|
|
80
|
+
* `Retry-After` once aborted. WebSocket streams are not affected.
|
|
81
|
+
*/
|
|
82
|
+
signal?: AbortSignal;
|
|
56
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* The header a sandbox create carries so the server can recognise a retry of
|
|
86
|
+
* it. Keep in sync with clustercreate.HeaderIdempotencyKey on the server.
|
|
87
|
+
*/
|
|
88
|
+
export declare const IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
|
|
89
|
+
/** A fresh create key: 128 random bits as 32 lowercase hex characters. */
|
|
90
|
+
export declare function newIdempotencyKey(): string;
|
|
91
|
+
/**
|
|
92
|
+
* Reads a `Retry-After` value in either form RFC 9110 allows: delta-seconds
|
|
93
|
+
* (`"120"`) or an HTTP date. Returns the wait in milliseconds (0 for a date
|
|
94
|
+
* already past), or `undefined` for an absent or malformed value.
|
|
95
|
+
*/
|
|
96
|
+
export declare function parseRetryAfter(value: string | null | undefined, nowMs: number): number | undefined;
|
|
97
|
+
/**
|
|
98
|
+
* How long {@link APIClient.request} waits before re-sending. `attempt`
|
|
99
|
+
* counts from 0, `retryAfter` is a 429/503's `Retry-After` header (null
|
|
100
|
+
* otherwise) and `random` is uniform in [0, 1).
|
|
101
|
+
*
|
|
102
|
+
* Without a usable header it is the exponential backoff ±25%. With one, it
|
|
103
|
+
* never retries sooner than the server asked (nor sooner than the backoff)
|
|
104
|
+
* and adds up to a fifth so clients told the same value spread out.
|
|
105
|
+
* `maxRetryAfterMs` is a hard ceiling: when the server asks for at least
|
|
106
|
+
* that long the client is early anyway, so the spread goes below it instead.
|
|
107
|
+
*/
|
|
108
|
+
export declare function retryDelayMs(config: Required<RetryConfig>, attempt: number, retryAfter: string | null, nowMs: number, random: number): number;
|
|
57
109
|
/**
|
|
58
110
|
* Trim trailing slashes from a URL without a backtracking regex. The natural
|
|
59
111
|
* `value.replace(/\/+$/, "")` is an anchored `+` quantifier — a polynomial
|
|
@@ -64,9 +116,11 @@ export declare class APIClient {
|
|
|
64
116
|
readonly baseURL: string;
|
|
65
117
|
readonly apiVersion: APIVersion;
|
|
66
118
|
private readonly patToken;
|
|
119
|
+
private readonly team;
|
|
67
120
|
private readonly fetchFn;
|
|
68
121
|
private readonly versionPrefix;
|
|
69
122
|
private readonly retryConfig;
|
|
123
|
+
private readonly signal;
|
|
70
124
|
constructor(config: APIClientConfig);
|
|
71
125
|
/**
|
|
72
126
|
* Build a versioned API path. Pass the suffix beginning with "/" (e.g.
|
|
@@ -106,6 +160,9 @@ export declare class APIClient {
|
|
|
106
160
|
createSnapshot(id: string, name: string): Promise<SandboxSnapshot>;
|
|
107
161
|
registerSnapshot(options: RegisterSnapshotOptions): Promise<SandboxSnapshot>;
|
|
108
162
|
registerSnapshotFromImage(name: string, image: Image, options?: Omit<RegisterSnapshotOptions, "name" | "image" | "dockerfileContent">): Promise<SandboxSnapshot>;
|
|
163
|
+
getSnapshot(name: string): Promise<SandboxSnapshot>;
|
|
164
|
+
listSnapshots(options?: ListSnapshotsOptions): Promise<SnapshotList>;
|
|
165
|
+
deleteSnapshot(name: string): Promise<void>;
|
|
109
166
|
destroy(id: string): Promise<void>;
|
|
110
167
|
resize(id: string, options: ResizeOptions): Promise<SandboxResource>;
|
|
111
168
|
updateLifecycle(id: string, lifecycle: Lifecycle): Promise<SandboxResource>;
|
|
@@ -181,19 +238,31 @@ export declare class APIClient {
|
|
|
181
238
|
deleteWasmModule(id: string): Promise<void>;
|
|
182
239
|
pushWasmModule(options: PushWasmModuleOptions): Promise<PushWasmModuleResult>;
|
|
183
240
|
rebuildTemplate(id: string): Promise<Template>;
|
|
241
|
+
whoami(): Promise<WhoAmI>;
|
|
242
|
+
createAPIToken(options: CreateAPITokenOptions): Promise<CreatedAPIToken>;
|
|
243
|
+
listAPITokens(): Promise<APITokenList>;
|
|
244
|
+
revokeAPIToken(id: string): Promise<void>;
|
|
184
245
|
private wrap;
|
|
185
246
|
private doJSON;
|
|
186
247
|
private doBytes;
|
|
187
248
|
/**
|
|
188
249
|
* Low-level HTTP request with automatic retry for transient failures.
|
|
189
250
|
*
|
|
190
|
-
* Transport-level errors (socket closed, connection reset, DNS
|
|
191
|
-
*
|
|
192
|
-
*
|
|
251
|
+
* Transport-level errors (socket closed, connection reset, timeouts, DNS
|
|
252
|
+
* blip) and HTTP 421/429/502/503/504 are retried regardless of HTTP method.
|
|
253
|
+
* Several of those failures can happen after the server received the
|
|
254
|
+
* request, so a retry may deliver it twice; that is safe only because every
|
|
255
|
+
* mutating endpoint in the daemon is designed for idempotent retry (e.g.
|
|
256
|
+
* INSERT OR IGNORE + disambiguation).
|
|
257
|
+
*
|
|
258
|
+
* A request passed `{ idempotent: false }` (exec) retries only transport
|
|
259
|
+
* failures that prove the request never left the client and HTTP 429/503;
|
|
260
|
+
* everything else is surfaced to the caller rather than risk running the
|
|
261
|
+
* command twice.
|
|
193
262
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
263
|
+
* A 429 or 503 waits as long as its `Retry-After` asks (see
|
|
264
|
+
* {@link retryDelayMs}). The client's `signal` ends the request and any
|
|
265
|
+
* wait.
|
|
197
266
|
*/
|
|
198
267
|
private request;
|
|
199
268
|
}
|
package/dist/internal/client.js
CHANGED
|
@@ -1,10 +1,41 @@
|
|
|
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
|
+
import { MicroVMApiError } from "../errors.js";
|
|
3
4
|
import { Image } from "../Image.js";
|
|
5
|
+
/**
|
|
6
|
+
* The request header that selects which of a credential's teams a request
|
|
7
|
+
* acts for. Keep in sync with pkg/api.HeaderTeam on the server.
|
|
8
|
+
*/
|
|
9
|
+
export const TEAM_HEADER = "X-AerolVM-Team";
|
|
4
10
|
const DEFAULT_API_VERSION = "v1";
|
|
5
11
|
const PATH_PREFIXES = {
|
|
6
12
|
v1: V1_PATH_PREFIX,
|
|
7
13
|
};
|
|
14
|
+
function fromApiWhoAmI(w) {
|
|
15
|
+
const out = {
|
|
16
|
+
principal: { kind: w.principal.kind, id: w.principal.id },
|
|
17
|
+
role: w.role,
|
|
18
|
+
};
|
|
19
|
+
if (w.principal.name)
|
|
20
|
+
out.principal.name = w.principal.name;
|
|
21
|
+
if (w.team)
|
|
22
|
+
out.team = w.team;
|
|
23
|
+
if (w.expires_at)
|
|
24
|
+
out.expiresAt = w.expires_at;
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
function fromApiAPIToken(t) {
|
|
28
|
+
return {
|
|
29
|
+
id: t.id,
|
|
30
|
+
name: t.name,
|
|
31
|
+
role: t.role,
|
|
32
|
+
team: t.team,
|
|
33
|
+
createdBy: t.created_by,
|
|
34
|
+
createdAt: t.created_at,
|
|
35
|
+
expiresAt: t.expires_at,
|
|
36
|
+
expired: t.expired === true,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
8
39
|
function fromApiEgressProfile(p) {
|
|
9
40
|
const out = {
|
|
10
41
|
name: p.name,
|
|
@@ -29,17 +60,89 @@ function fromApiEgressRule({ inject, ...rule }) {
|
|
|
29
60
|
// The server closes the connection, so the retry reconnects and the ingress
|
|
30
61
|
// re-routes it (plans/ingress-proxy-routing.md, review 2A).
|
|
31
62
|
const RETRYABLE_STATUS_CODES = new Set([421, 429, 502, 503, 504]);
|
|
63
|
+
// The subset a non-idempotent request (exec) may retry: 429 and 503 are the
|
|
64
|
+
// server turning the request away before doing any work. 421, 502 and 504 come
|
|
65
|
+
// from forwarding/proxy hops, and the status alone can't prove that no hop
|
|
66
|
+
// already handed the command to the sandbox, so exec surfaces them. They are
|
|
67
|
+
// also the statuses whose Retry-After paces clients: 429 when this caller is
|
|
68
|
+
// over its share, 503 when the node sheds load. A Retry-After on a 421, 502 or
|
|
69
|
+
// 504 is a hop's, not the server's, so those use the normal backoff.
|
|
70
|
+
const REFUSED_STATUS_CODES = new Set([429, 503]);
|
|
71
|
+
/**
|
|
72
|
+
* The header a sandbox create carries so the server can recognise a retry of
|
|
73
|
+
* it. Keep in sync with clustercreate.HeaderIdempotencyKey on the server.
|
|
74
|
+
*/
|
|
75
|
+
export const IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
|
|
76
|
+
/** A fresh create key: 128 random bits as 32 lowercase hex characters. */
|
|
77
|
+
export function newIdempotencyKey() {
|
|
78
|
+
// getRandomValues, unlike randomUUID, also works on a page served over
|
|
79
|
+
// plain http.
|
|
80
|
+
const bytes = new Uint8Array(16);
|
|
81
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
82
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
83
|
+
}
|
|
32
84
|
/** Default retry settings when the caller doesn't supply a RetryConfig. */
|
|
33
85
|
const DEFAULT_RETRY = {
|
|
34
86
|
maxRetries: 3,
|
|
35
87
|
baseDelayMs: 200,
|
|
36
88
|
maxDelayMs: 5_000,
|
|
89
|
+
// An overloaded node asks for about a second, so the cap only bites on a
|
|
90
|
+
// long ask (maintenance, a misconfigured proxy), which should not hold a
|
|
91
|
+
// call for minutes per retry.
|
|
92
|
+
maxRetryAfterMs: 30_000,
|
|
37
93
|
};
|
|
94
|
+
// IMF-fixdate, the HTTP-date form servers send ("Sun, 06 Nov 1994 08:49:37
|
|
95
|
+
// GMT"). Date.parse alone would also take "1" or a date with no zone, read in
|
|
96
|
+
// local time.
|
|
97
|
+
const IMF_FIXDATE = /^[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/;
|
|
98
|
+
/**
|
|
99
|
+
* Reads a `Retry-After` value in either form RFC 9110 allows: delta-seconds
|
|
100
|
+
* (`"120"`) or an HTTP date. Returns the wait in milliseconds (0 for a date
|
|
101
|
+
* already past), or `undefined` for an absent or malformed value.
|
|
102
|
+
*/
|
|
103
|
+
export function parseRetryAfter(value, nowMs) {
|
|
104
|
+
const trimmed = (value ?? "").trim();
|
|
105
|
+
if (/^[0-9]+$/.test(trimmed)) {
|
|
106
|
+
return Number(trimmed) * 1000;
|
|
107
|
+
}
|
|
108
|
+
if (!IMF_FIXDATE.test(trimmed)) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
const when = Date.parse(trimmed);
|
|
112
|
+
return Number.isNaN(when) ? undefined : Math.max(when - nowMs, 0);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* How long {@link APIClient.request} waits before re-sending. `attempt`
|
|
116
|
+
* counts from 0, `retryAfter` is a 429/503's `Retry-After` header (null
|
|
117
|
+
* otherwise) and `random` is uniform in [0, 1).
|
|
118
|
+
*
|
|
119
|
+
* Without a usable header it is the exponential backoff ±25%. With one, it
|
|
120
|
+
* never retries sooner than the server asked (nor sooner than the backoff)
|
|
121
|
+
* and adds up to a fifth so clients told the same value spread out.
|
|
122
|
+
* `maxRetryAfterMs` is a hard ceiling: when the server asks for at least
|
|
123
|
+
* that long the client is early anyway, so the spread goes below it instead.
|
|
124
|
+
*/
|
|
125
|
+
export function retryDelayMs(config, attempt, retryAfter, nowMs, random) {
|
|
126
|
+
const backoff = Math.min(config.baseDelayMs * 2 ** attempt, config.maxDelayMs);
|
|
127
|
+
const limit = config.maxRetryAfterMs;
|
|
128
|
+
const hint = limit > 0 ? parseRetryAfter(retryAfter, nowMs) : undefined;
|
|
129
|
+
if (hint === undefined) {
|
|
130
|
+
return backoff * (0.75 + 0.5 * random);
|
|
131
|
+
}
|
|
132
|
+
const target = Math.max(hint, backoff);
|
|
133
|
+
if (target >= limit) {
|
|
134
|
+
return limit - limit * 0.2 * random;
|
|
135
|
+
}
|
|
136
|
+
const upper = Math.min(target * 1.2, limit);
|
|
137
|
+
return target + (upper - target) * random;
|
|
138
|
+
}
|
|
38
139
|
/**
|
|
39
140
|
* Returns `true` when the thrown `error` looks like a transport-level failure
|
|
40
141
|
* that vanishes on retry (connection reset, socket closed by peer, DNS
|
|
41
142
|
* blip). `undici` and Node core use several `cause.code` values; we check
|
|
42
|
-
* the full cause chain.
|
|
143
|
+
* the full cause chain. A reset, EPIPE, read/headers/body timeout or "other
|
|
144
|
+
* side closed" can fire after the server already received the request, so
|
|
145
|
+
* this classification is only safe for idempotent requests.
|
|
43
146
|
*/
|
|
44
147
|
function isTransientTransportError(error) {
|
|
45
148
|
const codes = new Set([
|
|
@@ -69,6 +172,45 @@ function isTransientTransportError(error) {
|
|
|
69
172
|
}
|
|
70
173
|
return false;
|
|
71
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Returns `true` only for transport failures that prove the request never
|
|
177
|
+
* left the client: the connection was refused, did not open before the
|
|
178
|
+
* connect timeout, or the host name did not resolve (transiently; ENOTFOUND
|
|
179
|
+
* is not retried for any request). Any other code in the cause chain means
|
|
180
|
+
* bytes may have reached the server, so the answer is `false`.
|
|
181
|
+
*/
|
|
182
|
+
function isUnsentTransportError(error) {
|
|
183
|
+
let current = error;
|
|
184
|
+
for (let depth = 0; depth < 5 && current instanceof Error; depth++) {
|
|
185
|
+
const err = current;
|
|
186
|
+
switch (err.code) {
|
|
187
|
+
case undefined:
|
|
188
|
+
break;
|
|
189
|
+
case "ECONNREFUSED":
|
|
190
|
+
case "EAI_AGAIN":
|
|
191
|
+
case "UND_ERR_CONNECT_TIMEOUT":
|
|
192
|
+
return true;
|
|
193
|
+
case "ETIMEDOUT":
|
|
194
|
+
// Node uses ETIMEDOUT both for a connect that never completed and for
|
|
195
|
+
// an open socket that stopped answering; only the first is unsent.
|
|
196
|
+
return failedWhileConnecting(err);
|
|
197
|
+
default:
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
current = err.cause;
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
function failedWhileConnecting(err) {
|
|
205
|
+
if (err.syscall === "connect")
|
|
206
|
+
return true;
|
|
207
|
+
// With autoSelectFamily (Node's default), a host with several addresses
|
|
208
|
+
// fails with an AggregateError holding one error per connect attempt and no
|
|
209
|
+
// syscall of its own.
|
|
210
|
+
return (err instanceof AggregateError &&
|
|
211
|
+
err.errors.length > 0 &&
|
|
212
|
+
err.errors.every((inner) => inner?.syscall === "connect"));
|
|
213
|
+
}
|
|
72
214
|
/**
|
|
73
215
|
* Trim trailing slashes from a URL without a backtracking regex. The natural
|
|
74
216
|
* `value.replace(/\/+$/, "")` is an anchored `+` quantifier — a polynomial
|
|
@@ -81,21 +223,37 @@ export function stripTrailingSlashes(value) {
|
|
|
81
223
|
}
|
|
82
224
|
return value.slice(0, end);
|
|
83
225
|
}
|
|
84
|
-
/**
|
|
85
|
-
function
|
|
86
|
-
|
|
87
|
-
|
|
226
|
+
/** Waits `ms`, or rejects with the signal's reason as soon as it aborts. */
|
|
227
|
+
function sleep(ms, signal) {
|
|
228
|
+
return new Promise((resolve, reject) => {
|
|
229
|
+
if (signal?.aborted) {
|
|
230
|
+
reject(signal.reason);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const onAbort = () => {
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
reject(signal?.reason);
|
|
236
|
+
};
|
|
237
|
+
const timer = setTimeout(() => {
|
|
238
|
+
signal?.removeEventListener("abort", onAbort);
|
|
239
|
+
resolve();
|
|
240
|
+
}, ms);
|
|
241
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
242
|
+
});
|
|
88
243
|
}
|
|
89
244
|
export class APIClient {
|
|
90
245
|
baseURL;
|
|
91
246
|
apiVersion;
|
|
92
247
|
patToken;
|
|
248
|
+
team;
|
|
93
249
|
fetchFn;
|
|
94
250
|
versionPrefix;
|
|
95
251
|
retryConfig;
|
|
252
|
+
signal;
|
|
96
253
|
constructor(config) {
|
|
97
254
|
this.baseURL = stripTrailingSlashes(config.baseURL);
|
|
98
255
|
this.patToken = config.patToken ?? "";
|
|
256
|
+
this.team = (config.team ?? "").trim();
|
|
99
257
|
this.fetchFn = config.fetch ?? fetch;
|
|
100
258
|
this.apiVersion = config.apiVersion ?? DEFAULT_API_VERSION;
|
|
101
259
|
this.versionPrefix = PATH_PREFIXES[this.apiVersion];
|
|
@@ -103,7 +261,9 @@ export class APIClient {
|
|
|
103
261
|
maxRetries: config.retry?.maxRetries ?? DEFAULT_RETRY.maxRetries,
|
|
104
262
|
baseDelayMs: config.retry?.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs,
|
|
105
263
|
maxDelayMs: config.retry?.maxDelayMs ?? DEFAULT_RETRY.maxDelayMs,
|
|
264
|
+
maxRetryAfterMs: config.retry?.maxRetryAfterMs ?? DEFAULT_RETRY.maxRetryAfterMs,
|
|
106
265
|
};
|
|
266
|
+
this.signal = config.signal;
|
|
107
267
|
}
|
|
108
268
|
/**
|
|
109
269
|
* Build a versioned API path. Pass the suffix beginning with "/" (e.g.
|
|
@@ -116,7 +276,13 @@ export class APIClient {
|
|
|
116
276
|
}
|
|
117
277
|
async create(options) {
|
|
118
278
|
const resolved = await this.resolveImage(options);
|
|
119
|
-
|
|
279
|
+
// One key per call, sent unchanged on each of its automatic retries: a
|
|
280
|
+
// create retried after a timeout gets back the sandbox the first attempt
|
|
281
|
+
// made instead of making a second one.
|
|
282
|
+
const key = options.idempotencyKey?.trim() || newIdempotencyKey();
|
|
283
|
+
const response = await this.doJSON("POST", this.versioned("/sandboxes"), toApiCreateOptions(resolved), {
|
|
284
|
+
headers: { [IDEMPOTENCY_KEY_HEADER]: key },
|
|
285
|
+
});
|
|
120
286
|
return new SandboxResource(this, fromApiCreateSandboxResponse(response));
|
|
121
287
|
}
|
|
122
288
|
/**
|
|
@@ -272,6 +438,25 @@ export class APIClient {
|
|
|
272
438
|
dockerfileContent: image.dockerfile,
|
|
273
439
|
});
|
|
274
440
|
}
|
|
441
|
+
async getSnapshot(name) {
|
|
442
|
+
return fromApiSandboxSnapshot(await this.doJSON("GET", this.versioned(`/snapshots/${encodeURIComponent(name)}`)));
|
|
443
|
+
}
|
|
444
|
+
async listSnapshots(options = {}) {
|
|
445
|
+
const params = new URLSearchParams();
|
|
446
|
+
if (options.limit)
|
|
447
|
+
params.set("limit", String(options.limit));
|
|
448
|
+
if (options.startingAfter)
|
|
449
|
+
params.set("starting_after", options.startingAfter);
|
|
450
|
+
const query = params.toString();
|
|
451
|
+
const response = await this.doJSON("GET", this.versioned(`/snapshots${query ? `?${query}` : ""}`));
|
|
452
|
+
return {
|
|
453
|
+
snapshots: (response.snapshots ?? []).map(fromApiSandboxSnapshot),
|
|
454
|
+
hasMore: response.has_more ?? false,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
async deleteSnapshot(name) {
|
|
458
|
+
await this.doJSON("DELETE", this.versioned(`/snapshots/${encodeURIComponent(name)}`));
|
|
459
|
+
}
|
|
275
460
|
async destroy(id) {
|
|
276
461
|
await this.doJSON("DELETE", `${this.versionPrefix}/sandboxes/${id}`);
|
|
277
462
|
}
|
|
@@ -284,7 +469,8 @@ export class APIClient {
|
|
|
284
469
|
return this.wrap(response);
|
|
285
470
|
}
|
|
286
471
|
async exec(id, request) {
|
|
287
|
-
|
|
472
|
+
// Not idempotent: a re-sent exec runs the caller's command a second time.
|
|
473
|
+
const response = await this.doJSON("POST", `${this.versionPrefix}/sandboxes/${id}/toolbox/process/execute`, toApiExecRequest(request), { idempotent: false });
|
|
288
474
|
return fromApiExecResult(response);
|
|
289
475
|
}
|
|
290
476
|
execStream(id, options) {
|
|
@@ -571,18 +757,39 @@ export class APIClient {
|
|
|
571
757
|
const response = await this.doJSON("POST", `${this.versionPrefix}/templates/${id}/rebuild`);
|
|
572
758
|
return fromApiTemplate(response);
|
|
573
759
|
}
|
|
760
|
+
async whoami() {
|
|
761
|
+
return fromApiWhoAmI(await this.doJSON("GET", this.versioned("/whoami")));
|
|
762
|
+
}
|
|
763
|
+
async createAPIToken(options) {
|
|
764
|
+
const response = await this.doJSON("POST", this.versioned("/tokens"), {
|
|
765
|
+
name: options.name,
|
|
766
|
+
role: options.role,
|
|
767
|
+
team: options.team,
|
|
768
|
+
expires_in: options.expiresIn,
|
|
769
|
+
});
|
|
770
|
+
return { ...fromApiAPIToken(response), token: response.token };
|
|
771
|
+
}
|
|
772
|
+
async listAPITokens() {
|
|
773
|
+
const response = await this.doJSON("GET", this.versioned("/tokens"));
|
|
774
|
+
return { tokens: (response.tokens ?? []).map(fromApiAPIToken) };
|
|
775
|
+
}
|
|
776
|
+
async revokeAPIToken(id) {
|
|
777
|
+
const trimmed = id.trim();
|
|
778
|
+
if (trimmed === "") {
|
|
779
|
+
throw new Error("token id is required");
|
|
780
|
+
}
|
|
781
|
+
await this.doJSON("DELETE", this.versioned(`/tokens/${encodeURIComponent(trimmed)}`));
|
|
782
|
+
}
|
|
574
783
|
wrap(sandbox) {
|
|
575
784
|
return new SandboxResource(this, fromApiSandbox(sandbox));
|
|
576
785
|
}
|
|
577
|
-
async doJSON(method, path, body) {
|
|
578
|
-
const init = {};
|
|
786
|
+
async doJSON(method, path, body, options) {
|
|
787
|
+
const init = { headers: { ...options?.headers } };
|
|
579
788
|
if (body !== undefined) {
|
|
580
789
|
init.body = JSON.stringify(body);
|
|
581
|
-
init.headers = {
|
|
582
|
-
"Content-Type": "application/json",
|
|
583
|
-
};
|
|
790
|
+
init.headers = { ...init.headers, "Content-Type": "application/json" };
|
|
584
791
|
}
|
|
585
|
-
const response = await this.request(method, path, init);
|
|
792
|
+
const response = await this.request(method, path, init, options);
|
|
586
793
|
if (!response.ok) {
|
|
587
794
|
throw await decodeError(response);
|
|
588
795
|
}
|
|
@@ -601,46 +808,59 @@ export class APIClient {
|
|
|
601
808
|
/**
|
|
602
809
|
* Low-level HTTP request with automatic retry for transient failures.
|
|
603
810
|
*
|
|
604
|
-
* Transport-level errors (socket closed, connection reset, DNS
|
|
605
|
-
*
|
|
606
|
-
*
|
|
811
|
+
* Transport-level errors (socket closed, connection reset, timeouts, DNS
|
|
812
|
+
* blip) and HTTP 421/429/502/503/504 are retried regardless of HTTP method.
|
|
813
|
+
* Several of those failures can happen after the server received the
|
|
814
|
+
* request, so a retry may deliver it twice; that is safe only because every
|
|
815
|
+
* mutating endpoint in the daemon is designed for idempotent retry (e.g.
|
|
816
|
+
* INSERT OR IGNORE + disambiguation).
|
|
817
|
+
*
|
|
818
|
+
* A request passed `{ idempotent: false }` (exec) retries only transport
|
|
819
|
+
* failures that prove the request never left the client and HTTP 429/503;
|
|
820
|
+
* everything else is surfaced to the caller rather than risk running the
|
|
821
|
+
* command twice.
|
|
607
822
|
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
823
|
+
* A 429 or 503 waits as long as its `Retry-After` asks (see
|
|
824
|
+
* {@link retryDelayMs}). The client's `signal` ends the request and any
|
|
825
|
+
* wait.
|
|
611
826
|
*/
|
|
612
|
-
async request(method, path, init = {}) {
|
|
827
|
+
async request(method, path, init = {}, options = {}) {
|
|
613
828
|
const headers = new Headers(init.headers);
|
|
614
829
|
if (this.patToken !== "") {
|
|
615
830
|
headers.set("Authorization", `Bearer ${this.patToken}`);
|
|
616
831
|
}
|
|
832
|
+
if (this.team !== "") {
|
|
833
|
+
headers.set(TEAM_HEADER, this.team);
|
|
834
|
+
}
|
|
617
835
|
const url = `${this.baseURL}${path}`;
|
|
618
|
-
const
|
|
619
|
-
const {
|
|
620
|
-
|
|
621
|
-
|
|
836
|
+
const signal = init.signal ?? this.signal;
|
|
837
|
+
const requestInit = { ...init, method, headers, ...(signal ? { signal } : {}) };
|
|
838
|
+
const { maxRetries } = this.retryConfig;
|
|
839
|
+
const idempotent = options.idempotent ?? true;
|
|
840
|
+
const retryableStatusCodes = idempotent ? RETRYABLE_STATUS_CODES : REFUSED_STATUS_CODES;
|
|
841
|
+
const isRetryableError = idempotent ? isTransientTransportError : isUnsentTransportError;
|
|
842
|
+
for (let attempt = 0;; attempt++) {
|
|
843
|
+
let retryAfter = null;
|
|
622
844
|
try {
|
|
623
845
|
const response = await this.fetchFn(url, requestInit);
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
846
|
+
if (!retryableStatusCodes.has(response.status) || attempt >= maxRetries) {
|
|
847
|
+
return response;
|
|
848
|
+
}
|
|
849
|
+
if (REFUSED_STATUS_CODES.has(response.status)) {
|
|
850
|
+
retryAfter = response.headers.get("retry-after");
|
|
629
851
|
}
|
|
630
|
-
|
|
852
|
+
// Free the connection instead of holding it through the wait.
|
|
853
|
+
response.body?.cancel().catch(() => undefined);
|
|
631
854
|
}
|
|
632
855
|
catch (error) {
|
|
633
|
-
|
|
634
|
-
//
|
|
635
|
-
if (!
|
|
856
|
+
// Only retry transport-level failures; anything else propagates. An
|
|
857
|
+
// aborted signal surfaces whatever fetch threw for it.
|
|
858
|
+
if (signal?.aborted || !isRetryableError(error) || attempt >= maxRetries) {
|
|
636
859
|
throw error;
|
|
637
860
|
}
|
|
638
|
-
const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
639
|
-
await jitteredDelay(delay);
|
|
640
861
|
}
|
|
862
|
+
await sleep(retryDelayMs(this.retryConfig, attempt, retryAfter, Date.now(), Math.random()), signal);
|
|
641
863
|
}
|
|
642
|
-
// Should be unreachable — the loop always throws or returns.
|
|
643
|
-
throw lastError;
|
|
644
864
|
}
|
|
645
865
|
}
|
|
646
866
|
export class SandboxResource {
|
|
@@ -826,6 +1046,15 @@ function toApiCreateOptions(options) {
|
|
|
826
1046
|
module_ref: options.moduleRef,
|
|
827
1047
|
tenant_id: options.tenantId,
|
|
828
1048
|
custom_domains: options.customDomains,
|
|
1049
|
+
gpus: options.gpus ? toApiGPUOptions(options.gpus) : undefined,
|
|
1050
|
+
template_id: options.templateId,
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
function toApiGPUOptions(gpus) {
|
|
1054
|
+
return {
|
|
1055
|
+
vendor: gpus.vendor,
|
|
1056
|
+
count: gpus.count,
|
|
1057
|
+
device_ids: gpus.deviceIDs,
|
|
829
1058
|
};
|
|
830
1059
|
}
|
|
831
1060
|
function toApiRegisterSnapshotOptions(options) {
|
|
@@ -1221,16 +1450,19 @@ function toBlob(data) {
|
|
|
1221
1450
|
return new Blob([Uint8Array.from(data)]);
|
|
1222
1451
|
}
|
|
1223
1452
|
async function decodeError(response) {
|
|
1453
|
+
const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"), Date.now());
|
|
1224
1454
|
try {
|
|
1225
1455
|
const payload = (await response.json());
|
|
1226
1456
|
if (typeof payload.error === "string" && payload.error !== "") {
|
|
1227
|
-
|
|
1457
|
+
const code = typeof payload.code === "string" && payload.code !== "" ? payload.code : undefined;
|
|
1458
|
+
return new MicroVMApiError(response.status, payload.error, { code, retryAfterMs });
|
|
1228
1459
|
}
|
|
1229
1460
|
}
|
|
1230
1461
|
catch {
|
|
1231
|
-
//
|
|
1462
|
+
// Not JSON (a proxy's error page) or no body: fall through to the
|
|
1463
|
+
// status-based message, with no code.
|
|
1232
1464
|
}
|
|
1233
|
-
return new
|
|
1465
|
+
return new MicroVMApiError(response.status, `request failed with status ${response.status}`, { retryAfterMs });
|
|
1234
1466
|
}
|
|
1235
1467
|
const STREAM_PREFIX_STDOUT = 0x01;
|
|
1236
1468
|
const STREAM_PREFIX_STDERR = 0x02;
|
package/dist/types.d.ts
CHANGED
|
@@ -135,6 +135,19 @@ export interface GPUOptions {
|
|
|
135
135
|
}
|
|
136
136
|
import type { Image } from "./Image.js";
|
|
137
137
|
export interface CreateOptions {
|
|
138
|
+
/**
|
|
139
|
+
* Sent as the create's `Idempotency-Key` header, never in the body. A create
|
|
140
|
+
* sent again with the same key and the same options returns the sandbox the
|
|
141
|
+
* first one made instead of making a second; the same key with different
|
|
142
|
+
* options fails with a 422 `MicroVMApiError` whose `code` is
|
|
143
|
+
* `"idempotency_key_reused"`. Keys are scoped to the caller's account.
|
|
144
|
+
*
|
|
145
|
+
* Leave it unset and the SDK makes a random key for each `create` call and
|
|
146
|
+
* reuses it on that call's automatic retries, so a retry after a timeout
|
|
147
|
+
* cannot make a second sandbox. Set your own to make your own retries of
|
|
148
|
+
* the whole call safe too, such as after your process restarts.
|
|
149
|
+
*/
|
|
150
|
+
idempotencyKey?: string;
|
|
138
151
|
/**
|
|
139
152
|
* Optional name, unique among the caller's sandboxes (another account may
|
|
140
153
|
* use the same name). A repeat create with a name the caller already holds
|
|
@@ -194,9 +207,10 @@ export interface CreateOptions {
|
|
|
194
207
|
*/
|
|
195
208
|
networkEgressRules?: EgressRule[];
|
|
196
209
|
/**
|
|
197
|
-
* Whether the sandbox
|
|
198
|
-
*
|
|
199
|
-
*
|
|
210
|
+
* Whether the sandbox is public from boot. Omitted or `false` creates it
|
|
211
|
+
* private: no public URL and no ingress route. `true` makes it public from
|
|
212
|
+
* boot. Either way the first `exposePort` call makes the sandbox public, so
|
|
213
|
+
* `false` does not stop a later `exposePort`.
|
|
200
214
|
*/
|
|
201
215
|
allowPublicTraffic?: boolean;
|
|
202
216
|
/**
|
|
@@ -269,6 +283,13 @@ export interface CreateOptions {
|
|
|
269
283
|
* `sandbox.customDomains.add(host)` for each entry after create.
|
|
270
284
|
*/
|
|
271
285
|
customDomains?: string[];
|
|
286
|
+
/**
|
|
287
|
+
* Firecracker template id (from `createTemplate`); the template must be
|
|
288
|
+
* ready. Requires `runtime: "firecracker"` and `image`: pass the image the
|
|
289
|
+
* template was built from. The sandbox boots from the template's prepared
|
|
290
|
+
* rootfs instead of building one from `image` on every create.
|
|
291
|
+
*/
|
|
292
|
+
templateId?: string;
|
|
272
293
|
}
|
|
273
294
|
export interface ResizeOptions {
|
|
274
295
|
/** Number of CPU cores. Fractional values supported (e.g. 0.5). */
|
|
@@ -445,6 +466,19 @@ export interface SandboxSnapshot {
|
|
|
445
466
|
memoryMB?: number;
|
|
446
467
|
diskGB?: number;
|
|
447
468
|
}
|
|
469
|
+
/** Options for {@link MicroVM.listSnapshots}: one name-ordered page. */
|
|
470
|
+
export interface ListSnapshotsOptions {
|
|
471
|
+
/** Maximum snapshots to return in this page. */
|
|
472
|
+
limit?: number;
|
|
473
|
+
/** Exclusive name cursor — pass the last name of the previous page. */
|
|
474
|
+
startingAfter?: string;
|
|
475
|
+
}
|
|
476
|
+
/** One page of snapshots from {@link MicroVM.listSnapshots}. */
|
|
477
|
+
export interface SnapshotList {
|
|
478
|
+
snapshots: SandboxSnapshot[];
|
|
479
|
+
/** True when more snapshots remain after this page. */
|
|
480
|
+
hasMore: boolean;
|
|
481
|
+
}
|
|
448
482
|
/**
|
|
449
483
|
* Discriminated result from `exposePort`. Branch on `protocol` to access the
|
|
450
484
|
* fields that are meaningful for the chosen wire protocol — only the raw-TCP
|
|
@@ -482,8 +516,9 @@ export interface Sandbox {
|
|
|
482
516
|
networkBlockAll: boolean;
|
|
483
517
|
/**
|
|
484
518
|
* Hostname-egress state on `get` for a sandbox whose allow list names
|
|
485
|
-
* hosts (container runtimes): "active", "held" or "unavailable"
|
|
486
|
-
*
|
|
519
|
+
* hosts (container runtimes): "active", "held" or "unavailable";
|
|
520
|
+
* "unenforced" when the server's firewall is off and nothing filters the
|
|
521
|
+
* sandbox. Absent otherwise, and on list results.
|
|
487
522
|
*/
|
|
488
523
|
egressStatus?: string;
|
|
489
524
|
/** Egress profiles this sandbox references. */
|
|
@@ -629,7 +664,10 @@ export interface NetworkPolicy {
|
|
|
629
664
|
networkEgressRules: EgressRule[];
|
|
630
665
|
/** Hostname entries in force: inline plus every profile's (at most 1024). */
|
|
631
666
|
effectiveHostnameCount: number;
|
|
632
|
-
/**
|
|
667
|
+
/**
|
|
668
|
+
* "active", "held" or "unavailable" for hostname rules on a container;
|
|
669
|
+
* "unenforced" when the server's firewall is off.
|
|
670
|
+
*/
|
|
633
671
|
egressStatus?: string;
|
|
634
672
|
}
|
|
635
673
|
/**
|
|
@@ -722,6 +760,59 @@ export interface EgressProfileList {
|
|
|
722
760
|
/** Pass back as `cursor` for the next page; absent on the last one. */
|
|
723
761
|
nextCursor?: string;
|
|
724
762
|
}
|
|
763
|
+
/** Who the caller is, as `microvm.whoami()` reports it. */
|
|
764
|
+
export interface WhoAmIPrincipal {
|
|
765
|
+
/** `"operator"`, `"token"`, `"oidc"`, `"controlplane"`, ... */
|
|
766
|
+
kind: string;
|
|
767
|
+
/** Stable id: `"operator"`, `"tok_…"`, `"oidc:<issuer>:<sub>"`, ... */
|
|
768
|
+
id: string;
|
|
769
|
+
/** Display name; absent when the server has none. */
|
|
770
|
+
name?: string;
|
|
771
|
+
}
|
|
772
|
+
/** The body of `microvm.whoami()`: the caller's own resolved identity. */
|
|
773
|
+
export interface WhoAmI {
|
|
774
|
+
principal: WhoAmIPrincipal;
|
|
775
|
+
/** `"viewer"`, `"runner"`, `"auditor"` or `"admin"`. */
|
|
776
|
+
role: string;
|
|
777
|
+
/** `"*"` for every team, a team name, or absent for a managed account. */
|
|
778
|
+
team?: string;
|
|
779
|
+
/** When the credential stops working (RFC 3339); absent if it never does. */
|
|
780
|
+
expiresAt?: string;
|
|
781
|
+
}
|
|
782
|
+
/** One scoped API token as `microvm.tokens.list()` returns it. Never the secret. */
|
|
783
|
+
export interface APIToken {
|
|
784
|
+
/** Public id (`tok_…`): what `tokens.revoke` takes and audit records name. */
|
|
785
|
+
id: string;
|
|
786
|
+
name: string;
|
|
787
|
+
role: string;
|
|
788
|
+
/** A team name, or `"*"` for every team. */
|
|
789
|
+
team: string;
|
|
790
|
+
createdBy: string;
|
|
791
|
+
createdAt: string;
|
|
792
|
+
expiresAt: string;
|
|
793
|
+
/** Expired tokens no longer work; they stay listed for 30 days. */
|
|
794
|
+
expired: boolean;
|
|
795
|
+
}
|
|
796
|
+
/** `microvm.tokens.create()`'s result: the token plus its secret. */
|
|
797
|
+
export interface CreatedAPIToken extends APIToken {
|
|
798
|
+
/** The secret (`avm_…`), returned this once and never stored by the server. */
|
|
799
|
+
token: string;
|
|
800
|
+
}
|
|
801
|
+
/** The body of `microvm.tokens.create()`. */
|
|
802
|
+
export interface CreateAPITokenOptions {
|
|
803
|
+
/** 1 to 64 of `A-Z a-z 0-9 . _ -`, unique among the team's tokens. */
|
|
804
|
+
name: string;
|
|
805
|
+
/** `"viewer"`, `"runner"`, `"auditor"` or `"admin"`. */
|
|
806
|
+
role: string;
|
|
807
|
+
/** A team name, or `"*"` for every team. `auditor` and `admin` require `"*"`. */
|
|
808
|
+
team: string;
|
|
809
|
+
/** Required lifetime: `"90d"`, `"720h"` or any Go duration. */
|
|
810
|
+
expiresIn: string;
|
|
811
|
+
}
|
|
812
|
+
/** `microvm.tokens.list()`'s result. */
|
|
813
|
+
export interface APITokenList {
|
|
814
|
+
tokens: APIToken[];
|
|
815
|
+
}
|
|
725
816
|
/** One destination a learn-mode sandbox reached. */
|
|
726
817
|
export interface NetworkLearnedEntry {
|
|
727
818
|
host: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aerol-ai/aerolvm-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"test": "tsc -p tsconfig.test.json --outDir dist-test && node --test dist-test/*.test.js dist-test/internal/*.test.js"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@types/node": "^26.6.
|
|
29
|
+
"@types/node": "^26.6.6",
|
|
30
30
|
"typescript": "^7.0.2",
|
|
31
31
|
"tsx": "^4.23.15"
|
|
32
32
|
}
|