@aerol-ai/aerolvm-sdk 0.3.1 → 0.4.1
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 +45 -2
- package/dist/MicroVM.js +50 -0
- package/dist/index.d.ts +2 -1
- package/dist/internal/api/v1/paths.d.ts +12 -0
- package/dist/internal/api/v1/paths.js +16 -0
- package/dist/internal/client.d.ts +59 -3
- package/dist/internal/client.js +161 -10
- package/dist/types.d.ts +126 -2
- package/package.json +2 -2
package/dist/MicroVM.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { type APIVersion } from "./internal/client.js";
|
|
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, CreateOptions, CreateSessionOptions, ExecStreamHandle, ExecStreamOptions, HealthStatus, Lifecycle, ListOptions, MountSpecRedacted, RegisterSnapshotOptions, ResizeOptions, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions } from "./types.js";
|
|
4
|
+
import type { BuildImageOptions, BuildImageResult, CreateOptions, CreateSessionOptions, CreateTemplateOptions, ExecStreamHandle, ExecStreamOptions, HealthStatus, IngressTarget, Lifecycle, ListOptions, MountSpecRedacted, RegisterSnapshotOptions, ResizeOptions, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions, Template } from "./types.js";
|
|
5
5
|
type FetchLike = typeof fetch;
|
|
6
6
|
export interface MicroVMConfig {
|
|
7
7
|
patToken?: string;
|
|
@@ -14,6 +14,13 @@ export interface MicroVMConfig {
|
|
|
14
14
|
* other.
|
|
15
15
|
*/
|
|
16
16
|
apiVersion?: APIVersion;
|
|
17
|
+
/**
|
|
18
|
+
* Retry policy for transient transport errors (socket closed, connection
|
|
19
|
+
* reset) and retryable HTTP status codes (429, 502, 503, 504). The SDK
|
|
20
|
+
* retries up to 3 times with exponential backoff by default. Pass
|
|
21
|
+
* `{ maxRetries: 0 }` to disable.
|
|
22
|
+
*/
|
|
23
|
+
retry?: RetryConfig;
|
|
17
24
|
}
|
|
18
25
|
export declare class MicroVM {
|
|
19
26
|
readonly apiUrl: string;
|
|
@@ -51,6 +58,42 @@ export declare class MicroVM {
|
|
|
51
58
|
sessionLog(sandboxID: string, sessionID: string): Promise<Uint8Array>;
|
|
52
59
|
sessionRecording(sandboxID: string, sessionID: string): Promise<Uint8Array>;
|
|
53
60
|
attachSession(sandboxID: string, sessionID: string, options?: SessionAttachOptions): SessionAttachHandle;
|
|
61
|
+
/**
|
|
62
|
+
* Register a Firecracker rootfs template. Returns immediately with a
|
|
63
|
+
* `status: "pending"` row; poll {@link MicroVM.getTemplate} until the
|
|
64
|
+
* status reaches `"ready"` (fast-boot available) or `"ready_no_snapshot"`
|
|
65
|
+
* (cold boot only — see {@link Template.snapshotError}).
|
|
66
|
+
*
|
|
67
|
+
* Idempotent when {@link CreateTemplateOptions.id} is supplied: a
|
|
68
|
+
* duplicate ID returns 409 so a retried CI step does not create two
|
|
69
|
+
* rows for the same logical template.
|
|
70
|
+
*/
|
|
71
|
+
createTemplate(options: CreateTemplateOptions): Promise<Template>;
|
|
72
|
+
listTemplates(): Promise<Template[]>;
|
|
73
|
+
getTemplate(id: string): Promise<Template>;
|
|
74
|
+
deleteTemplate(id: string): Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Re-run the snapshot phase against an existing template. Idempotent
|
|
77
|
+
* under concurrent retry: the daemon's CAS collapses N parallel calls
|
|
78
|
+
* for the same ready template into one rebuild kick. Returns the row in
|
|
79
|
+
* its post-transition state (typically `unhealthy`) — poll
|
|
80
|
+
* {@link MicroVM.getTemplate} to observe the transition back to `ready`.
|
|
81
|
+
*
|
|
82
|
+
* Returns 412 (raised as an error) when the template is in a state
|
|
83
|
+
* where rebuild is not safe (build in flight) or not supported
|
|
84
|
+
* (`ready_no_snapshot` / `failed` — those need delete+recreate today).
|
|
85
|
+
*/
|
|
86
|
+
rebuildTemplate(id: string): Promise<Template>;
|
|
87
|
+
/**
|
|
88
|
+
* Cluster-level DNS helpers. Exposed as a namespaced accessor (rather
|
|
89
|
+
* than a flat `ingressDNS()` method) so call sites read like
|
|
90
|
+
* `microvm.dns.target()`, mirroring the per-sandbox `sandbox.customDomains`
|
|
91
|
+
* pattern. A fresh object is returned per access so the closures always
|
|
92
|
+
* see the current client even if MicroVM is later extended.
|
|
93
|
+
*/
|
|
94
|
+
get dns(): {
|
|
95
|
+
target(): Promise<IngressTarget>;
|
|
96
|
+
};
|
|
54
97
|
private wrap;
|
|
55
98
|
}
|
|
56
99
|
export {};
|
package/dist/MicroVM.js
CHANGED
|
@@ -19,6 +19,7 @@ export class MicroVM {
|
|
|
19
19
|
patToken,
|
|
20
20
|
fetch: config.fetch,
|
|
21
21
|
apiVersion: config.apiVersion,
|
|
22
|
+
retry: config.retry,
|
|
22
23
|
});
|
|
23
24
|
}
|
|
24
25
|
async create(options) {
|
|
@@ -109,6 +110,55 @@ export class MicroVM {
|
|
|
109
110
|
attachSession(sandboxID, sessionID, options = {}) {
|
|
110
111
|
return this.client.attachSession(sandboxID, sessionID, options);
|
|
111
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Register a Firecracker rootfs template. Returns immediately with a
|
|
115
|
+
* `status: "pending"` row; poll {@link MicroVM.getTemplate} until the
|
|
116
|
+
* status reaches `"ready"` (fast-boot available) or `"ready_no_snapshot"`
|
|
117
|
+
* (cold boot only — see {@link Template.snapshotError}).
|
|
118
|
+
*
|
|
119
|
+
* Idempotent when {@link CreateTemplateOptions.id} is supplied: a
|
|
120
|
+
* duplicate ID returns 409 so a retried CI step does not create two
|
|
121
|
+
* rows for the same logical template.
|
|
122
|
+
*/
|
|
123
|
+
async createTemplate(options) {
|
|
124
|
+
return this.client.createTemplate(options);
|
|
125
|
+
}
|
|
126
|
+
async listTemplates() {
|
|
127
|
+
return this.client.listTemplates();
|
|
128
|
+
}
|
|
129
|
+
async getTemplate(id) {
|
|
130
|
+
return this.client.getTemplate(id);
|
|
131
|
+
}
|
|
132
|
+
async deleteTemplate(id) {
|
|
133
|
+
await this.client.deleteTemplate(id);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Re-run the snapshot phase against an existing template. Idempotent
|
|
137
|
+
* under concurrent retry: the daemon's CAS collapses N parallel calls
|
|
138
|
+
* for the same ready template into one rebuild kick. Returns the row in
|
|
139
|
+
* its post-transition state (typically `unhealthy`) — poll
|
|
140
|
+
* {@link MicroVM.getTemplate} to observe the transition back to `ready`.
|
|
141
|
+
*
|
|
142
|
+
* Returns 412 (raised as an error) when the template is in a state
|
|
143
|
+
* where rebuild is not safe (build in flight) or not supported
|
|
144
|
+
* (`ready_no_snapshot` / `failed` — those need delete+recreate today).
|
|
145
|
+
*/
|
|
146
|
+
async rebuildTemplate(id) {
|
|
147
|
+
return this.client.rebuildTemplate(id);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Cluster-level DNS helpers. Exposed as a namespaced accessor (rather
|
|
151
|
+
* than a flat `ingressDNS()` method) so call sites read like
|
|
152
|
+
* `microvm.dns.target()`, mirroring the per-sandbox `sandbox.customDomains`
|
|
153
|
+
* pattern. A fresh object is returned per access so the closures always
|
|
154
|
+
* see the current client even if MicroVM is later extended.
|
|
155
|
+
*/
|
|
156
|
+
get dns() {
|
|
157
|
+
const client = this.client;
|
|
158
|
+
return {
|
|
159
|
+
target: () => client.ingressDNS(),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
112
162
|
wrap(sandbox) {
|
|
113
163
|
return new Sandbox(this.client, sandbox);
|
|
114
164
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,4 +2,5 @@ export { MicroVM } from "./MicroVM.js";
|
|
|
2
2
|
export { Sandbox } from "./Sandbox.js";
|
|
3
3
|
export { Image } from "./Image.js";
|
|
4
4
|
export type { MicroVMConfig } from "./MicroVM.js";
|
|
5
|
-
export type {
|
|
5
|
+
export type { RetryConfig } from "./internal/client.js";
|
|
6
|
+
export type { AddCustomDomainOptions, BinaryLike, BuildImageOptions, BuildImagePushOptions, BuildImageResult, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CustomDomain, CustomDomainDNSRecords, CustomDomainStatus, DNSRecord, DNSRecordType, ExecRequest, ExecResult, ExecExitInfo, ExposedPort, ExposePortOptions, ExposeProtocol, ExposeResult, Failover, FailoverPolicy, HealthStatus, IngressTarget, IngressTargetSource, Lifecycle, MountSpec, MountSpecRedacted, MountType, RegisterSnapshotOptions, RegistryAuth, ResizeOptions, Sandbox as SandboxData, SandboxSnapshot, SandboxStatus, Session, SessionAttachHandle, SessionAttachOptions, SessionStatus, Template, TemplatePushState, TemplateStatus, UpdateLifecycleOptions, } from "./types.js";
|
|
@@ -14,3 +14,15 @@ export declare function sandboxCustomDomainsPath(prefix: string, id: string): st
|
|
|
14
14
|
* `DELETE /v1/sandboxes/{id}/custom-domains/{hostname}`.
|
|
15
15
|
*/
|
|
16
16
|
export declare function sandboxCustomDomainPath(prefix: string, id: string, hostname: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* URL for the cluster's published ingress address(es), used by
|
|
19
|
+
* `microvm.dns.target()` to render the CNAME/A target a user must point
|
|
20
|
+
* custom-domain DNS at.
|
|
21
|
+
*/
|
|
22
|
+
export declare function ingressDNSPath(prefix: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* URL for the ready-to-paste DNS records of one sandbox's custom-domain
|
|
25
|
+
* bindings. Sibling of {@link sandboxCustomDomainsPath} — same `id` rules
|
|
26
|
+
* apply.
|
|
27
|
+
*/
|
|
28
|
+
export declare function sandboxCustomDomainDNSPath(prefix: string, id: string): string;
|
|
@@ -24,3 +24,19 @@ export function sandboxCustomDomainsPath(prefix, id) {
|
|
|
24
24
|
export function sandboxCustomDomainPath(prefix, id, hostname) {
|
|
25
25
|
return `${prefix}/sandboxes/${id}/custom-domains/${encodeURIComponent(hostname)}`;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* URL for the cluster's published ingress address(es), used by
|
|
29
|
+
* `microvm.dns.target()` to render the CNAME/A target a user must point
|
|
30
|
+
* custom-domain DNS at.
|
|
31
|
+
*/
|
|
32
|
+
export function ingressDNSPath(prefix) {
|
|
33
|
+
return `${prefix}/ingress/dns`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* URL for the ready-to-paste DNS records of one sandbox's custom-domain
|
|
37
|
+
* bindings. Sibling of {@link sandboxCustomDomainsPath} — same `id` rules
|
|
38
|
+
* apply.
|
|
39
|
+
*/
|
|
40
|
+
export function sandboxCustomDomainDNSPath(prefix, id) {
|
|
41
|
+
return `${prefix}/sandboxes/${id}/custom-domains/dns`;
|
|
42
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Image } from "../Image.js";
|
|
2
|
-
import type { BinaryLike, BuildImageOptions, BuildImageResult, CreateOptions, CreateSessionOptions, CustomDomain, ExecRequest, ExecResult, ExecStreamHandle, ExecStreamOptions, ExposedPort, ExposePortOptions, ExposeResult, Failover, HealthStatus, Lifecycle, ListOptions, MountSpecRedacted, NetworkUsage, RegisterSnapshotOptions, SetNetworkLimitsOptions, ResizeOptions, Sandbox, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions } from "../types.js";
|
|
2
|
+
import type { AddCustomDomainOptions, BinaryLike, BuildImageOptions, BuildImageResult, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CustomDomain, CustomDomainDNSRecords, ExecRequest, ExecResult, ExecStreamHandle, ExecStreamOptions, ExposedPort, ExposePortOptions, ExposeResult, Failover, HealthStatus, IngressTarget, Lifecycle, ListOptions, MountSpecRedacted, NetworkUsage, RegisterSnapshotOptions, SetNetworkLimitsOptions, ResizeOptions, Sandbox, SandboxSnapshot, Session, SessionAttachHandle, SessionAttachOptions, Template } from "../types.js";
|
|
3
3
|
type FetchLike = typeof fetch;
|
|
4
4
|
/**
|
|
5
5
|
* Wire version of the v1-style sandbox endpoints (`/v1/sandboxes`, etc.) this
|
|
@@ -16,6 +16,27 @@ type FetchLike = typeof fetch;
|
|
|
16
16
|
* string image.
|
|
17
17
|
*/
|
|
18
18
|
export type APIVersion = "v1";
|
|
19
|
+
/**
|
|
20
|
+
* Retry configuration for transient transport and server errors.
|
|
21
|
+
* All fields are optional — the SDK ships sensible defaults.
|
|
22
|
+
*/
|
|
23
|
+
export interface RetryConfig {
|
|
24
|
+
/**
|
|
25
|
+
* Maximum number of retry attempts after the initial request fails.
|
|
26
|
+
* Set to `0` to disable retry entirely. Defaults to `3`.
|
|
27
|
+
*/
|
|
28
|
+
maxRetries?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Initial backoff delay in milliseconds. Subsequent retries double this
|
|
31
|
+
* value (exponential backoff). Defaults to `200`.
|
|
32
|
+
*/
|
|
33
|
+
baseDelayMs?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Hard ceiling on the computed delay in milliseconds. Prevents unbounded
|
|
36
|
+
* waits when `maxRetries` is high. Defaults to `5000`.
|
|
37
|
+
*/
|
|
38
|
+
maxDelayMs?: number;
|
|
39
|
+
}
|
|
19
40
|
export interface APIClientConfig {
|
|
20
41
|
baseURL: string;
|
|
21
42
|
patToken?: string;
|
|
@@ -26,6 +47,12 @@ export interface APIClientConfig {
|
|
|
26
47
|
* a future major SDK release.
|
|
27
48
|
*/
|
|
28
49
|
apiVersion?: APIVersion;
|
|
50
|
+
/**
|
|
51
|
+
* Retry policy for transient transport errors (socket closed, connection
|
|
52
|
+
* reset) and retryable HTTP status codes (429, 502, 503, 504). Pass
|
|
53
|
+
* `{ maxRetries: 0 }` to disable retry entirely.
|
|
54
|
+
*/
|
|
55
|
+
retry?: RetryConfig;
|
|
29
56
|
}
|
|
30
57
|
export declare class APIClient {
|
|
31
58
|
readonly baseURL: string;
|
|
@@ -33,6 +60,7 @@ export declare class APIClient {
|
|
|
33
60
|
private readonly patToken;
|
|
34
61
|
private readonly fetchFn;
|
|
35
62
|
private readonly versionPrefix;
|
|
63
|
+
private readonly retryConfig;
|
|
36
64
|
constructor(config: APIClientConfig);
|
|
37
65
|
/**
|
|
38
66
|
* Build a versioned API path. Pass the suffix beginning with "/" (e.g.
|
|
@@ -99,17 +127,44 @@ export declare class APIClient {
|
|
|
99
127
|
* with an already-registered hostname is idempotent and returns the
|
|
100
128
|
* existing list.
|
|
101
129
|
*/
|
|
102
|
-
addCustomDomain(id: string, hostname: string): Promise<CustomDomain[]>;
|
|
130
|
+
addCustomDomain(id: string, hostname: string, options?: AddCustomDomainOptions): Promise<CustomDomain[]>;
|
|
103
131
|
listCustomDomains(id: string): Promise<CustomDomain[]>;
|
|
104
132
|
removeCustomDomain(id: string, hostname: string): Promise<void>;
|
|
133
|
+
/**
|
|
134
|
+
* Fetch the cluster's published ingress address(es) — the CNAME/A target
|
|
135
|
+
* a user must point custom-domain DNS at. Field names already match the
|
|
136
|
+
* SDK's camelCase shape on the wire, so no translation is needed.
|
|
137
|
+
*/
|
|
138
|
+
ingressDNS(): Promise<IngressTarget>;
|
|
139
|
+
/**
|
|
140
|
+
* Fetch the ready-to-paste DNS records for one sandbox's custom-domain
|
|
141
|
+
* bindings, along with the {@link IngressTarget} they were composed from.
|
|
142
|
+
*/
|
|
143
|
+
customDomainDNS(id: string): Promise<CustomDomainDNSRecords>;
|
|
105
144
|
reconcile(): Promise<void>;
|
|
106
145
|
health(): Promise<HealthStatus>;
|
|
107
146
|
mounts(id: string): Promise<MountSpecRedacted[]>;
|
|
108
147
|
getNetworkUsage(id: string): Promise<NetworkUsage>;
|
|
109
148
|
setNetworkLimits(id: string, options: SetNetworkLimitsOptions): Promise<NetworkUsage>;
|
|
149
|
+
createTemplate(options: CreateTemplateOptions): Promise<Template>;
|
|
150
|
+
listTemplates(): Promise<Template[]>;
|
|
151
|
+
getTemplate(id: string): Promise<Template>;
|
|
152
|
+
deleteTemplate(id: string): Promise<void>;
|
|
153
|
+
rebuildTemplate(id: string): Promise<Template>;
|
|
110
154
|
private wrap;
|
|
111
155
|
private doJSON;
|
|
112
156
|
private doBytes;
|
|
157
|
+
/**
|
|
158
|
+
* Low-level HTTP request with automatic retry for transient failures.
|
|
159
|
+
*
|
|
160
|
+
* Transport-level errors (socket closed, connection reset, DNS blip) are
|
|
161
|
+
* always retried regardless of HTTP method — the request never reached the
|
|
162
|
+
* server so there is no idempotency concern.
|
|
163
|
+
*
|
|
164
|
+
* HTTP-level retryable codes (429, 502, 503, 504) are retried for ALL
|
|
165
|
+
* methods because every mutating endpoint in the daemon is already
|
|
166
|
+
* designed for idempotent retry (e.g. INSERT OR IGNORE + disambiguation).
|
|
167
|
+
*/
|
|
113
168
|
private request;
|
|
114
169
|
}
|
|
115
170
|
export declare class SandboxResource implements Sandbox {
|
|
@@ -164,9 +219,10 @@ export declare class SandboxResource implements Sandbox {
|
|
|
164
219
|
* so the closures always see the current `id` even after `refresh()`.
|
|
165
220
|
*/
|
|
166
221
|
get customDomains(): {
|
|
167
|
-
add(hostname: string): Promise<CustomDomain[]>;
|
|
222
|
+
add(hostname: string, options?: AddCustomDomainOptions): Promise<CustomDomain[]>;
|
|
168
223
|
remove(hostname: string): Promise<void>;
|
|
169
224
|
list(): Promise<CustomDomain[]>;
|
|
225
|
+
dns(): Promise<CustomDomainDNSRecords>;
|
|
170
226
|
};
|
|
171
227
|
createSnapshot(name: string): Promise<SandboxSnapshot>;
|
|
172
228
|
start(): Promise<this>;
|
package/dist/internal/client.js
CHANGED
|
@@ -1,22 +1,75 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
-
import { PATH_PREFIX as V1_PATH_PREFIX, sandboxCustomDomainPath as v1SandboxCustomDomainPath, sandboxCustomDomainsPath as v1SandboxCustomDomainsPath, } from "./api/v1/paths.js";
|
|
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
4
|
const DEFAULT_API_VERSION = "v1";
|
|
5
5
|
const PATH_PREFIXES = {
|
|
6
6
|
v1: V1_PATH_PREFIX,
|
|
7
7
|
};
|
|
8
|
+
/** HTTP status codes the retry loop considers transient. */
|
|
9
|
+
const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]);
|
|
10
|
+
/** Default retry settings when the caller doesn't supply a RetryConfig. */
|
|
11
|
+
const DEFAULT_RETRY = {
|
|
12
|
+
maxRetries: 3,
|
|
13
|
+
baseDelayMs: 200,
|
|
14
|
+
maxDelayMs: 5_000,
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Returns `true` when the thrown `error` looks like a transport-level failure
|
|
18
|
+
* that vanishes on retry (connection reset, socket closed by peer, DNS
|
|
19
|
+
* blip). `undici` and Node core use several `cause.code` values; we check
|
|
20
|
+
* the full cause chain.
|
|
21
|
+
*/
|
|
22
|
+
function isTransientTransportError(error) {
|
|
23
|
+
const codes = new Set([
|
|
24
|
+
"UND_ERR_SOCKET",
|
|
25
|
+
"ECONNRESET",
|
|
26
|
+
"ECONNREFUSED",
|
|
27
|
+
"ETIMEDOUT",
|
|
28
|
+
"EPIPE",
|
|
29
|
+
"EAI_AGAIN",
|
|
30
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
31
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
32
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
33
|
+
]);
|
|
34
|
+
let current = error;
|
|
35
|
+
for (let depth = 0; depth < 5 && current != null; depth++) {
|
|
36
|
+
if (current instanceof Error) {
|
|
37
|
+
const code = current.code;
|
|
38
|
+
if (code && codes.has(code))
|
|
39
|
+
return true;
|
|
40
|
+
if (current.message && /socket hang up|other side closed/i.test(current.message))
|
|
41
|
+
return true;
|
|
42
|
+
current = current.cause;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
/** Sleep with ±25% jitter so concurrent callers don't thundering-herd. */
|
|
51
|
+
function jitteredDelay(baseMs) {
|
|
52
|
+
const jitter = 1 + (Math.random() - 0.5) * 0.5; // 0.75 – 1.25
|
|
53
|
+
return new Promise((resolve) => setTimeout(resolve, Math.round(baseMs * jitter)));
|
|
54
|
+
}
|
|
8
55
|
export class APIClient {
|
|
9
56
|
baseURL;
|
|
10
57
|
apiVersion;
|
|
11
58
|
patToken;
|
|
12
59
|
fetchFn;
|
|
13
60
|
versionPrefix;
|
|
61
|
+
retryConfig;
|
|
14
62
|
constructor(config) {
|
|
15
63
|
this.baseURL = config.baseURL.replace(/\/+$/, "");
|
|
16
64
|
this.patToken = config.patToken ?? "";
|
|
17
65
|
this.fetchFn = config.fetch ?? fetch;
|
|
18
66
|
this.apiVersion = config.apiVersion ?? DEFAULT_API_VERSION;
|
|
19
67
|
this.versionPrefix = PATH_PREFIXES[this.apiVersion];
|
|
68
|
+
this.retryConfig = {
|
|
69
|
+
maxRetries: config.retry?.maxRetries ?? DEFAULT_RETRY.maxRetries,
|
|
70
|
+
baseDelayMs: config.retry?.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs,
|
|
71
|
+
maxDelayMs: config.retry?.maxDelayMs ?? DEFAULT_RETRY.maxDelayMs,
|
|
72
|
+
};
|
|
20
73
|
}
|
|
21
74
|
/**
|
|
22
75
|
* Build a versioned API path. Pass the suffix beginning with "/" (e.g.
|
|
@@ -226,8 +279,12 @@ export class APIClient {
|
|
|
226
279
|
* with an already-registered hostname is idempotent and returns the
|
|
227
280
|
* existing list.
|
|
228
281
|
*/
|
|
229
|
-
async addCustomDomain(id, hostname) {
|
|
230
|
-
const
|
|
282
|
+
async addCustomDomain(id, hostname, options) {
|
|
283
|
+
const body = { hostname };
|
|
284
|
+
if (options?.port !== undefined && options.port !== 0) {
|
|
285
|
+
body.target_port = options.port;
|
|
286
|
+
}
|
|
287
|
+
const response = await this.doJSON("POST", v1SandboxCustomDomainsPath(this.versionPrefix, id), body);
|
|
231
288
|
return response.custom_domains.map(fromApiCustomDomain);
|
|
232
289
|
}
|
|
233
290
|
async listCustomDomains(id) {
|
|
@@ -237,6 +294,21 @@ export class APIClient {
|
|
|
237
294
|
async removeCustomDomain(id, hostname) {
|
|
238
295
|
await this.doJSON("DELETE", v1SandboxCustomDomainPath(this.versionPrefix, id, hostname));
|
|
239
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* Fetch the cluster's published ingress address(es) — the CNAME/A target
|
|
299
|
+
* a user must point custom-domain DNS at. Field names already match the
|
|
300
|
+
* SDK's camelCase shape on the wire, so no translation is needed.
|
|
301
|
+
*/
|
|
302
|
+
async ingressDNS() {
|
|
303
|
+
return this.doJSON("GET", v1IngressDNSPath(this.versionPrefix));
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Fetch the ready-to-paste DNS records for one sandbox's custom-domain
|
|
307
|
+
* bindings, along with the {@link IngressTarget} they were composed from.
|
|
308
|
+
*/
|
|
309
|
+
async customDomainDNS(id) {
|
|
310
|
+
return this.doJSON("GET", v1SandboxCustomDomainDNSPath(this.versionPrefix, id));
|
|
311
|
+
}
|
|
240
312
|
async reconcile() {
|
|
241
313
|
await this.doJSON("POST", this.versioned("/admin/reconcile"));
|
|
242
314
|
}
|
|
@@ -256,6 +328,31 @@ export class APIClient {
|
|
|
256
328
|
const response = await this.doJSON("PATCH", `${this.versionPrefix}/sandboxes/${id}/network/limits`, toApiSetNetworkLimitsOptions(options));
|
|
257
329
|
return fromApiNetworkUsage(response);
|
|
258
330
|
}
|
|
331
|
+
async createTemplate(options) {
|
|
332
|
+
const response = await this.doJSON("POST", this.versioned("/templates"), {
|
|
333
|
+
id: options.id,
|
|
334
|
+
image: options.image,
|
|
335
|
+
min_size_mib: options.minSizeMiB,
|
|
336
|
+
});
|
|
337
|
+
return fromApiTemplate(response);
|
|
338
|
+
}
|
|
339
|
+
async listTemplates() {
|
|
340
|
+
const response = await this.doJSON("GET", this.versioned("/templates"));
|
|
341
|
+
// The daemon returns `null` rather than `[]` when the table is empty;
|
|
342
|
+
// normalize so callers always get an array.
|
|
343
|
+
return (response ?? []).map(fromApiTemplate);
|
|
344
|
+
}
|
|
345
|
+
async getTemplate(id) {
|
|
346
|
+
const response = await this.doJSON("GET", `${this.versionPrefix}/templates/${id}`);
|
|
347
|
+
return fromApiTemplate(response);
|
|
348
|
+
}
|
|
349
|
+
async deleteTemplate(id) {
|
|
350
|
+
await this.doJSON("DELETE", `${this.versionPrefix}/templates/${id}`);
|
|
351
|
+
}
|
|
352
|
+
async rebuildTemplate(id) {
|
|
353
|
+
const response = await this.doJSON("POST", `${this.versionPrefix}/templates/${id}/rebuild`);
|
|
354
|
+
return fromApiTemplate(response);
|
|
355
|
+
}
|
|
259
356
|
wrap(sandbox) {
|
|
260
357
|
return new SandboxResource(this, fromApiSandbox(sandbox));
|
|
261
358
|
}
|
|
@@ -283,16 +380,49 @@ export class APIClient {
|
|
|
283
380
|
}
|
|
284
381
|
return new Uint8Array(await response.arrayBuffer());
|
|
285
382
|
}
|
|
286
|
-
|
|
383
|
+
/**
|
|
384
|
+
* Low-level HTTP request with automatic retry for transient failures.
|
|
385
|
+
*
|
|
386
|
+
* Transport-level errors (socket closed, connection reset, DNS blip) are
|
|
387
|
+
* always retried regardless of HTTP method — the request never reached the
|
|
388
|
+
* server so there is no idempotency concern.
|
|
389
|
+
*
|
|
390
|
+
* HTTP-level retryable codes (429, 502, 503, 504) are retried for ALL
|
|
391
|
+
* methods because every mutating endpoint in the daemon is already
|
|
392
|
+
* designed for idempotent retry (e.g. INSERT OR IGNORE + disambiguation).
|
|
393
|
+
*/
|
|
394
|
+
async request(method, path, init = {}) {
|
|
287
395
|
const headers = new Headers(init.headers);
|
|
288
396
|
if (this.patToken !== "") {
|
|
289
397
|
headers.set("Authorization", `Bearer ${this.patToken}`);
|
|
290
398
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
399
|
+
const url = `${this.baseURL}${path}`;
|
|
400
|
+
const requestInit = { ...init, method, headers };
|
|
401
|
+
const { maxRetries, baseDelayMs, maxDelayMs } = this.retryConfig;
|
|
402
|
+
let lastError;
|
|
403
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
404
|
+
try {
|
|
405
|
+
const response = await this.fetchFn(url, requestInit);
|
|
406
|
+
// Retry on transient HTTP status codes.
|
|
407
|
+
if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < maxRetries) {
|
|
408
|
+
const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
409
|
+
await jitteredDelay(delay);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
return response;
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
lastError = error;
|
|
416
|
+
// Only retry transport-level failures; anything else propagates.
|
|
417
|
+
if (!isTransientTransportError(error) || attempt >= maxRetries) {
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
421
|
+
await jitteredDelay(delay);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// Should be unreachable — the loop always throws or returns.
|
|
425
|
+
throw lastError;
|
|
296
426
|
}
|
|
297
427
|
}
|
|
298
428
|
export class SandboxResource {
|
|
@@ -363,9 +493,10 @@ export class SandboxResource {
|
|
|
363
493
|
const client = this.client;
|
|
364
494
|
const id = this.id;
|
|
365
495
|
return {
|
|
366
|
-
add: (hostname) => client.addCustomDomain(id, hostname),
|
|
496
|
+
add: (hostname, options) => client.addCustomDomain(id, hostname, options),
|
|
367
497
|
remove: (hostname) => client.removeCustomDomain(id, hostname),
|
|
368
498
|
list: () => client.listCustomDomains(id),
|
|
499
|
+
dns: () => client.customDomainDNS(id),
|
|
369
500
|
};
|
|
370
501
|
}
|
|
371
502
|
async createSnapshot(name) {
|
|
@@ -524,6 +655,25 @@ function fromApiCreateSandboxResponse(response) {
|
|
|
524
655
|
sshPrivateKey: response.ssh_private_key,
|
|
525
656
|
};
|
|
526
657
|
}
|
|
658
|
+
function fromApiTemplate(template) {
|
|
659
|
+
return {
|
|
660
|
+
id: template.id,
|
|
661
|
+
image: template.image,
|
|
662
|
+
status: template.status,
|
|
663
|
+
rootfsSizeBytes: template.rootfs_size_bytes,
|
|
664
|
+
minSizeMiB: template.min_size_mib,
|
|
665
|
+
lastError: template.last_error,
|
|
666
|
+
createdAt: template.created_at,
|
|
667
|
+
updatedAt: template.updated_at,
|
|
668
|
+
readyAt: template.ready_at,
|
|
669
|
+
snapshotSizeBytes: template.snapshot_size_bytes,
|
|
670
|
+
snapshotError: template.snapshot_error,
|
|
671
|
+
hasSnapshot: template.has_snapshot,
|
|
672
|
+
hasOverlay: template.has_overlay,
|
|
673
|
+
pushState: template.push_state,
|
|
674
|
+
pushError: template.push_error,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
527
677
|
function fromApiSandboxSnapshot(snapshot) {
|
|
528
678
|
return {
|
|
529
679
|
name: snapshot.name,
|
|
@@ -564,6 +714,7 @@ function fromApiCustomDomain(domain) {
|
|
|
564
714
|
lastError: domain.last_error,
|
|
565
715
|
createdAt: domain.created_at,
|
|
566
716
|
updatedAt: domain.updated_at,
|
|
717
|
+
targetPort: domain.target_port,
|
|
567
718
|
};
|
|
568
719
|
}
|
|
569
720
|
function fromApiExposedPort(port) {
|
package/dist/types.d.ts
CHANGED
|
@@ -164,7 +164,7 @@ export interface CreateOptions {
|
|
|
164
164
|
*
|
|
165
165
|
* GPU access is not supported with `"gvisor"`.
|
|
166
166
|
*/
|
|
167
|
-
runtime?: "docker" | "gvisor" | "kata";
|
|
167
|
+
runtime?: "docker" | "gvisor" | "kata" | "firecracker";
|
|
168
168
|
/**
|
|
169
169
|
* Attach GPU resources to the sandbox. Omit for CPU-only workloads.
|
|
170
170
|
* Not compatible with runtime="gvisor".
|
|
@@ -240,6 +240,70 @@ export interface CustomDomain {
|
|
|
240
240
|
lastError?: string;
|
|
241
241
|
createdAt: string;
|
|
242
242
|
updatedAt: string;
|
|
243
|
+
/**
|
|
244
|
+
* Target container port traffic to this hostname dials. `0` (or omitted)
|
|
245
|
+
* means the sandbox's toolbox port — the default that preserves the
|
|
246
|
+
* pre-target-port behavior. Set once at attach time; to change it, remove
|
|
247
|
+
* the binding and re-add it with the new port.
|
|
248
|
+
*/
|
|
249
|
+
targetPort?: number;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Options for `sandbox.customDomains.add()`. `port` pins the container port
|
|
253
|
+
* traffic to this hostname dials; omit it to route to the sandbox's toolbox
|
|
254
|
+
* port (the default). Re-adding the same hostname with a different `port`
|
|
255
|
+
* returns 409 — detach first.
|
|
256
|
+
*/
|
|
257
|
+
export interface AddCustomDomainOptions {
|
|
258
|
+
port?: number;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Where the SDK derived the ingress address(es) from. Reflects how the
|
|
262
|
+
* operator deployed the cluster:
|
|
263
|
+
* - "hostname": the cluster gossips a stable hostname (CNAME target).
|
|
264
|
+
* - "ips": one or more raw ingress IPs (one A/AAAA record per IP).
|
|
265
|
+
* - "mixed": some nodes gossip a hostname, others raw IPs — prefer
|
|
266
|
+
* the hostname but render both so apex domains still work.
|
|
267
|
+
* - "unknown": no ingress node has gossiped a public address yet; the
|
|
268
|
+
* target is unusable and callers should surface that, not
|
|
269
|
+
* guess.
|
|
270
|
+
*/
|
|
271
|
+
export type IngressTargetSource = "hostname" | "ips" | "mixed" | "unknown";
|
|
272
|
+
/**
|
|
273
|
+
* The cluster's published ingress address(es) — the value that DNS for a
|
|
274
|
+
* custom domain must ultimately resolve to. Returned by `microvm.dns.target()`
|
|
275
|
+
* and embedded in {@link CustomDomainDNSRecords}.
|
|
276
|
+
*/
|
|
277
|
+
export interface IngressTarget {
|
|
278
|
+
hostname?: string;
|
|
279
|
+
ips?: string[];
|
|
280
|
+
source: IngressTargetSource;
|
|
281
|
+
}
|
|
282
|
+
/** DNS record types the daemon emits for custom-domain wiring. */
|
|
283
|
+
export type DNSRecordType = "CNAME" | "A" | "AAAA";
|
|
284
|
+
/**
|
|
285
|
+
* One ready-to-paste DNS record a user adds at their DNS provider to make a
|
|
286
|
+
* custom domain reach the cluster. `name` is the leftmost label (or "@" for
|
|
287
|
+
* apex) the way DNS UIs accept it. `notes` carries provider-specific gotchas
|
|
288
|
+
* the daemon pre-renders (e.g. Cloudflare "DNS only, gray cloud") when
|
|
289
|
+
* applicable.
|
|
290
|
+
*/
|
|
291
|
+
export interface DNSRecord {
|
|
292
|
+
hostname: string;
|
|
293
|
+
type: DNSRecordType;
|
|
294
|
+
name: string;
|
|
295
|
+
value: string;
|
|
296
|
+
notes?: string;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Response from `sandbox.customDomains.dns()`. `records` is the flat
|
|
300
|
+
* ready-to-paste list (one row per custom domain × per ingress address);
|
|
301
|
+
* `target` is the raw aggregation the records were composed from, included
|
|
302
|
+
* so callers can render their own UI without a second round trip.
|
|
303
|
+
*/
|
|
304
|
+
export interface CustomDomainDNSRecords {
|
|
305
|
+
records: DNSRecord[];
|
|
306
|
+
target: IngressTarget;
|
|
243
307
|
}
|
|
244
308
|
/**
|
|
245
309
|
* Wire protocol an exposure publishes through:
|
|
@@ -311,7 +375,7 @@ export interface Sandbox {
|
|
|
311
375
|
* Container runtime this sandbox is running under. Empty string indicates
|
|
312
376
|
* a pre-migration row that resolves to the host default at start time.
|
|
313
377
|
*/
|
|
314
|
-
runtime: "" | "docker" | "gvisor" | "kata";
|
|
378
|
+
runtime: "" | "docker" | "gvisor" | "kata" | "firecracker";
|
|
315
379
|
/** GPU configuration this sandbox was created with. Absent means no GPU. */
|
|
316
380
|
gpus?: GPUOptions;
|
|
317
381
|
}
|
|
@@ -406,3 +470,63 @@ export interface HealthStatus {
|
|
|
406
470
|
version: string;
|
|
407
471
|
}
|
|
408
472
|
export type BinaryLike = Uint8Array | ArrayBuffer | Blob | string;
|
|
473
|
+
/**
|
|
474
|
+
* Lifecycle states reported by a Firecracker rootfs template. See
|
|
475
|
+
* `plans/snapshot-clone-fast-boot.md` for the state machine.
|
|
476
|
+
*
|
|
477
|
+
* - `pending`, `building_rootfs`, `snapshotting`: the build pipeline is in
|
|
478
|
+
* flight; no sandbox creation against this template will succeed yet.
|
|
479
|
+
* - `ready`: cold boot + fast snapshot clone both available.
|
|
480
|
+
* - `ready_no_snapshot`: cold boot works; snapshot phase failed and
|
|
481
|
+
* sandboxes will not get the fast-boot path until a rebuild succeeds.
|
|
482
|
+
* - `unhealthy`: snapshot was detected as corrupt; an async rebuild is in
|
|
483
|
+
* flight (or pending the next daemon restart).
|
|
484
|
+
* - `failed`: terminal state from a failed initial build; the row must be
|
|
485
|
+
* deleted and recreated.
|
|
486
|
+
*/
|
|
487
|
+
export type TemplateStatus = "pending" | "building_rootfs" | "snapshotting" | "ready" | "ready_no_snapshot" | "failed" | "unhealthy";
|
|
488
|
+
/**
|
|
489
|
+
* Background-push state for the template's artifacts (rootfs.ext4 +
|
|
490
|
+
* snapshot.*). "active" means push succeeded or push is disabled; "pending"
|
|
491
|
+
* / "pushing" are in-flight states; "error" carries `pushError` for the
|
|
492
|
+
* last failure.
|
|
493
|
+
*/
|
|
494
|
+
export type TemplatePushState = "active" | "pending" | "pushing" | "error";
|
|
495
|
+
/**
|
|
496
|
+
* A Firecracker rootfs template. Created once from an OCI image and
|
|
497
|
+
* boot-shared across many sandboxes. Use {@link MicroVM.createTemplate} to
|
|
498
|
+
* register one, then pass its {@link Template.id} on subsequent sandbox
|
|
499
|
+
* `create` calls (when the sandbox runtime is `firecracker`).
|
|
500
|
+
*/
|
|
501
|
+
export interface Template {
|
|
502
|
+
id: string;
|
|
503
|
+
image: string;
|
|
504
|
+
status: TemplateStatus;
|
|
505
|
+
rootfsSizeBytes?: number;
|
|
506
|
+
/** Optional floor for the ext4 image size, in MiB. */
|
|
507
|
+
minSizeMiB?: number;
|
|
508
|
+
/** Last build-pipeline error, populated when `status` is `failed`. */
|
|
509
|
+
lastError?: string;
|
|
510
|
+
createdAt: string;
|
|
511
|
+
updatedAt: string;
|
|
512
|
+
/** First time the template reached `ready` / `ready_no_snapshot`. */
|
|
513
|
+
readyAt?: string;
|
|
514
|
+
snapshotSizeBytes?: number;
|
|
515
|
+
/** Populated when `status` is `ready_no_snapshot` — why the snapshot phase failed. */
|
|
516
|
+
snapshotError?: string;
|
|
517
|
+
/** True when the template has a usable snapshot for fast-boot. */
|
|
518
|
+
hasSnapshot: boolean;
|
|
519
|
+
/** True when the template was built with a per-sandbox writable overlay placeholder. */
|
|
520
|
+
hasOverlay: boolean;
|
|
521
|
+
pushState?: TemplatePushState;
|
|
522
|
+
/** Populated when `pushState` is `error` — the last push failure. */
|
|
523
|
+
pushError?: string;
|
|
524
|
+
}
|
|
525
|
+
export interface CreateTemplateOptions {
|
|
526
|
+
/** Optional explicit ID. Empty means the daemon generates one. Supplying an explicit ID lets retries be idempotent — a duplicate ID returns 409. */
|
|
527
|
+
id?: string;
|
|
528
|
+
/** skopeo-style image reference, e.g. `"docker://python:3.11"`. */
|
|
529
|
+
image: string;
|
|
530
|
+
/** Optional floor for the ext4 image size, in MiB. */
|
|
531
|
+
minSizeMiB?: number;
|
|
532
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aerol-ai/aerolvm-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"test": "tsc -p tsconfig.test.json --outDir dist-test && node --test dist-test/*.test.js dist-test/internal/*.test.js"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@types/node": "^25.
|
|
24
|
+
"@types/node": "^25.9.1",
|
|
25
25
|
"typescript": "^6.0.3",
|
|
26
26
|
"tsx": "^4.22.3"
|
|
27
27
|
}
|