@blaxel/core 0.3.31 → 0.3.32-preview.332
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/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/common/settings.js +2 -2
- package/dist/cjs/sandbox/drift.js +60 -0
- package/dist/cjs/sandbox/mount-drives.js +273 -0
- package/dist/cjs/sandbox/preview.js +2 -1
- package/dist/cjs/sandbox/sandbox.js +60 -3
- package/dist/cjs/types/sandbox/drift.d.ts +30 -0
- package/dist/cjs/types/sandbox/mount-drives.d.ts +66 -0
- package/dist/cjs/types/sandbox/sandbox.d.ts +21 -1
- package/dist/cjs/types/sandbox/types.d.ts +16 -0
- package/dist/cjs-browser/.tsbuildinfo +1 -1
- package/dist/cjs-browser/common/settings.js +2 -2
- package/dist/cjs-browser/sandbox/drift.js +60 -0
- package/dist/cjs-browser/sandbox/mount-drives.js +273 -0
- package/dist/cjs-browser/sandbox/preview.js +2 -1
- package/dist/cjs-browser/sandbox/sandbox.js +60 -3
- package/dist/cjs-browser/types/sandbox/drift.d.ts +30 -0
- package/dist/cjs-browser/types/sandbox/mount-drives.d.ts +66 -0
- package/dist/cjs-browser/types/sandbox/sandbox.d.ts +21 -1
- package/dist/cjs-browser/types/sandbox/types.d.ts +16 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/common/settings.js +2 -2
- package/dist/esm/sandbox/drift.js +56 -0
- package/dist/esm/sandbox/mount-drives.js +267 -0
- package/dist/esm/sandbox/preview.js +2 -1
- package/dist/esm/sandbox/sandbox.js +58 -2
- package/dist/esm-browser/.tsbuildinfo +1 -1
- package/dist/esm-browser/common/settings.js +2 -2
- package/dist/esm-browser/sandbox/drift.js +56 -0
- package/dist/esm-browser/sandbox/mount-drives.js +267 -0
- package/dist/esm-browser/sandbox/preview.js +2 -1
- package/dist/esm-browser/sandbox/sandbox.js +58 -2
- package/package.json +1 -1
|
@@ -24,8 +24,8 @@ function missingCredentialsMessage() {
|
|
|
24
24
|
return "No Blaxel credentials found. Set the BL_API_KEY and BL_WORKSPACE environment variables, or run `bl login`.";
|
|
25
25
|
}
|
|
26
26
|
// Build info - these placeholders are replaced at build time by build:replace-imports
|
|
27
|
-
const BUILD_VERSION = "0.3.
|
|
28
|
-
const BUILD_COMMIT = "
|
|
27
|
+
const BUILD_VERSION = "0.3.32-preview.332";
|
|
28
|
+
const BUILD_COMMIT = "eeb11112e6f23be5c60b770389ef29eda8452fb0";
|
|
29
29
|
const BUILD_SENTRY_DSN = "https://fd5e60e1c9820e1eef5ccebb84a07127@o4508714045276160.ingest.us.sentry.io/4510465864564736";
|
|
30
30
|
const BLAXEL_API_VERSION = "2026-09-22";
|
|
31
31
|
// Bun < 1.3.11 never sends connection-level WINDOW_UPDATE: the pooled h2
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The control plane masks every env value it reads back, so values can only be compared when it did not.
|
|
2
|
+
const MASKED_ENV_VALUE = "****";
|
|
3
|
+
// The control plane keeps the image reference as submitted: "app" and "app:latest" are the same image.
|
|
4
|
+
function withTag(image) {
|
|
5
|
+
if (image.includes("@"))
|
|
6
|
+
return image;
|
|
7
|
+
return image.indexOf(":", image.lastIndexOf("/") + 1) === -1 ? `${image}:latest` : image;
|
|
8
|
+
}
|
|
9
|
+
function envDrift(requested, existing) {
|
|
10
|
+
const byName = new Map(existing.map((env) => [env.name, env]));
|
|
11
|
+
const missing = [];
|
|
12
|
+
const changed = [];
|
|
13
|
+
for (const env of requested) {
|
|
14
|
+
const current = byName.get(env.name);
|
|
15
|
+
if (!current)
|
|
16
|
+
missing.push(env.name ?? "");
|
|
17
|
+
else if (current.value !== undefined && current.value !== MASKED_ENV_VALUE && current.value !== env.value)
|
|
18
|
+
changed.push(env.name ?? "");
|
|
19
|
+
}
|
|
20
|
+
// Names only: env values are often secrets and never go into a log line.
|
|
21
|
+
const parts = [
|
|
22
|
+
missing.length ? `not set on the existing sandbox: ${missing.join(", ")}` : "",
|
|
23
|
+
changed.length ? `different value: ${changed.join(", ")}` : "",
|
|
24
|
+
].filter(Boolean);
|
|
25
|
+
return parts.length ? `envs (${parts.join("; ")})` : undefined;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Describes how an existing sandbox differs from the configuration that was
|
|
29
|
+
* asked for, one entry per field (image, memory, region, envs). Empty when
|
|
30
|
+
* nothing differs. A field the existing sandbox does not report is not drift.
|
|
31
|
+
*/
|
|
32
|
+
export function describeDrift(requested, existing) {
|
|
33
|
+
const drift = [];
|
|
34
|
+
const runtime = existing.spec?.runtime;
|
|
35
|
+
if (requested.image && runtime?.image && withTag(requested.image) !== withTag(runtime.image)) {
|
|
36
|
+
drift.push(`image (requested ${requested.image}, existing ${runtime.image})`);
|
|
37
|
+
}
|
|
38
|
+
if (requested.memory !== undefined && runtime?.memory !== undefined && requested.memory !== runtime.memory) {
|
|
39
|
+
drift.push(`memory (requested ${requested.memory} MB, existing ${runtime.memory} MB)`);
|
|
40
|
+
}
|
|
41
|
+
if (requested.region && existing.spec?.region && requested.region !== existing.spec.region) {
|
|
42
|
+
drift.push(`region (requested ${requested.region}, existing ${existing.spec.region})`);
|
|
43
|
+
}
|
|
44
|
+
if (requested.envs?.length && runtime?.envs) {
|
|
45
|
+
const envs = envDrift(requested.envs, runtime.envs);
|
|
46
|
+
if (envs)
|
|
47
|
+
drift.push(envs);
|
|
48
|
+
}
|
|
49
|
+
return drift;
|
|
50
|
+
}
|
|
51
|
+
/** The single warning createIfNotExists logs when the sandbox it returned is not the one that was asked for. */
|
|
52
|
+
export function driftWarning(name, drift) {
|
|
53
|
+
return (`SandboxInstance.createIfNotExists: sandbox ${name ? `"${name}" ` : ""}already exists, so it was returned as is and the requested configuration was not applied. ` +
|
|
54
|
+
`It differs on: ${drift.join("; ")}. ` +
|
|
55
|
+
"Delete the sandbox and create it again to apply the new configuration.");
|
|
56
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2
|
+
import { DriveInstance } from "../drive/index.js";
|
|
3
|
+
/** Most drive lookups/creations, mounts and deletions in flight at once. */
|
|
4
|
+
export const MOUNT_DRIVES_CONCURRENCY = 5;
|
|
5
|
+
/**
|
|
6
|
+
* Thrown when `mountDrives` could not be set up. Drives this call created
|
|
7
|
+
* under a name it generated and did not mount are deleted first; a drive
|
|
8
|
+
* created under a name you chose is never deleted, since a concurrent call may
|
|
9
|
+
* be using it. `createdDrives` names the drives this call created (or may have
|
|
10
|
+
* created, when a response was lost) that are left in place.
|
|
11
|
+
*
|
|
12
|
+
* - `sandbox` is set when the sandbox is ready: it and the mounts made so far
|
|
13
|
+
* are kept. `driveNames` lists the drives this call looked up or created that
|
|
14
|
+
* are left in place.
|
|
15
|
+
* - `sandbox` is undefined when the sandbox could not be created (its error is
|
|
16
|
+
* the `cause`) and some drives created for it could not be deleted. When they
|
|
17
|
+
* all could, the sandbox's error is thrown as is instead.
|
|
18
|
+
*/
|
|
19
|
+
export class SandboxDriveSetupError extends Error {
|
|
20
|
+
sandbox;
|
|
21
|
+
driveNames;
|
|
22
|
+
createdDrives;
|
|
23
|
+
constructor(sandbox, driveNames, createdDrives, cause) {
|
|
24
|
+
const detail = cause instanceof Error ? cause.message : JSON.stringify(cause);
|
|
25
|
+
const left = createdDrives.length ? `. Drives this call created (or may have created) are left in place: ${createdDrives.join(", ")}.` : "";
|
|
26
|
+
super(sandbox
|
|
27
|
+
? `Sandbox ${sandbox.metadata.name} is ready, but mounting its drives failed: ${detail}${left}`
|
|
28
|
+
: `Sandbox creation failed: ${detail}${left}`, { cause });
|
|
29
|
+
this.sandbox = sandbox;
|
|
30
|
+
this.driveNames = driveNames;
|
|
31
|
+
this.createdDrives = createdDrives;
|
|
32
|
+
this.name = "SandboxDriveSetupError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const cleanPath = (path = "/") => `/${path.split("/").filter(Boolean).join("/")}`;
|
|
36
|
+
/** The HTTP status of an API error, if it has one. */
|
|
37
|
+
function httpStatus(e) {
|
|
38
|
+
if (typeof e !== "object" || e === null)
|
|
39
|
+
return undefined;
|
|
40
|
+
const error = e;
|
|
41
|
+
for (const value of [error.response?.status, error.status, error.code]) {
|
|
42
|
+
if (typeof value === "number" && value >= 100 && value < 600)
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const isConflict = (e) => httpStatus(e) === 409 || (typeof e === "object" && e !== null && "code" in e && e.code === "DRIVE_ALREADY_EXISTS");
|
|
48
|
+
/** The server answered and refused the request, so it changed nothing. Anything else (5xx, network error, timeout) may have gone through. */
|
|
49
|
+
function isRejected(e) {
|
|
50
|
+
const status = httpStatus(e);
|
|
51
|
+
return status !== undefined && status >= 400 && status < 500 && status !== 408;
|
|
52
|
+
}
|
|
53
|
+
export function validateMountDrives(mounts, createIfNotExist) {
|
|
54
|
+
for (const { driveName, create } of mounts) {
|
|
55
|
+
if (!driveName === !create) {
|
|
56
|
+
throw new TypeError("Each mountDrives entry needs exactly one of 'driveName' or 'create'.");
|
|
57
|
+
}
|
|
58
|
+
if (createIfNotExist && create && !create.name) {
|
|
59
|
+
throw new TypeError("With createIfNotExist, a new drive in mountDrives needs a name; otherwise every call would create another drive.");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Runs tasks with at most `limit` in flight; a freed slot goes straight to the next waiter. */
|
|
64
|
+
function limiter(limit) {
|
|
65
|
+
let active = 0;
|
|
66
|
+
const waiting = [];
|
|
67
|
+
return async (task) => {
|
|
68
|
+
if (active >= limit)
|
|
69
|
+
await new Promise(resolve => waiting.push(resolve));
|
|
70
|
+
else
|
|
71
|
+
active++;
|
|
72
|
+
try {
|
|
73
|
+
return await task();
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
const next = waiting.shift();
|
|
77
|
+
if (next)
|
|
78
|
+
next();
|
|
79
|
+
else
|
|
80
|
+
active--;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Drives for one `SandboxInstance.create` call. Looking up or creating drives
|
|
86
|
+
* (`start`) runs alongside the sandbox creation, at most
|
|
87
|
+
* `MOUNT_DRIVES_CONCURRENCY` at a time; only mounting waits for both the sandbox
|
|
88
|
+
* and its drive (`mount`). On failure, drives this call created under a
|
|
89
|
+
* generated name and did not mount are deleted (`discard`, or `mount` when it
|
|
90
|
+
* fails); drives under a caller's name are kept and reported.
|
|
91
|
+
*/
|
|
92
|
+
export class SandboxDriveSetup {
|
|
93
|
+
mounts;
|
|
94
|
+
stopped = false;
|
|
95
|
+
prepared = [];
|
|
96
|
+
entries;
|
|
97
|
+
region;
|
|
98
|
+
setRegion;
|
|
99
|
+
/**
|
|
100
|
+
* `region` is the region the sandbox creation request sends, if any. New
|
|
101
|
+
* drives are created in it at once; without one they wait for the created
|
|
102
|
+
* sandbox and use its region.
|
|
103
|
+
*/
|
|
104
|
+
constructor(mounts, region) {
|
|
105
|
+
this.mounts = mounts;
|
|
106
|
+
this.entries = mounts.map(() => ({}));
|
|
107
|
+
this.region = new Promise(resolve => { this.setRegion = resolve; });
|
|
108
|
+
if (region)
|
|
109
|
+
this.setRegion(region);
|
|
110
|
+
}
|
|
111
|
+
start() {
|
|
112
|
+
const limit = limiter(MOUNT_DRIVES_CONCURRENCY);
|
|
113
|
+
this.prepared = this.mounts.map(async (mount, index) => {
|
|
114
|
+
// A new drive goes in the sandbox's region, so it waits for it (without holding a slot) if it is not known yet.
|
|
115
|
+
// Lookups await too, so that entries keep their order in the queue.
|
|
116
|
+
const region = await (mount.create ? this.region : undefined);
|
|
117
|
+
return limit(async () => {
|
|
118
|
+
if (this.stopped)
|
|
119
|
+
return undefined;
|
|
120
|
+
try {
|
|
121
|
+
const drive = mount.create ? await this.createDrive(this.entries[index], mount.create, region) : await DriveInstance.get(mount.driveName);
|
|
122
|
+
Object.assign(this.entries[index], { name: drive.name, exists: true });
|
|
123
|
+
return drive;
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
this.stopped = true;
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
// Failures surface in mount() or discard().
|
|
132
|
+
for (const promise of this.prepared)
|
|
133
|
+
promise.catch(() => { });
|
|
134
|
+
}
|
|
135
|
+
/** Create a drive in the sandbox's region; a named one that already exists is reused. */
|
|
136
|
+
async createDrive(entry, create, region) {
|
|
137
|
+
if (!region)
|
|
138
|
+
throw new Error("The sandbox reports no region, so its drives were not created.");
|
|
139
|
+
const requested = create.region;
|
|
140
|
+
if (requested && requested !== region) {
|
|
141
|
+
throw new Error(`Drive region ${requested} does not match the sandbox region ${region}.`);
|
|
142
|
+
}
|
|
143
|
+
// Name unnamed drives here so that a create whose response is lost can still be looked up.
|
|
144
|
+
const name = create.name || `drive-${uuidv4().replace(/-/g, "").slice(0, 16)}`;
|
|
145
|
+
entry.name = name;
|
|
146
|
+
entry.generated = !create.name;
|
|
147
|
+
try {
|
|
148
|
+
const drive = await DriveInstance.create({ ...create, name, region });
|
|
149
|
+
entry.created = true;
|
|
150
|
+
return drive;
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
if (isConflict(e)) {
|
|
154
|
+
// Only a name the caller chose can belong to an existing drive worth reusing.
|
|
155
|
+
if (!create.name)
|
|
156
|
+
throw e;
|
|
157
|
+
return DriveInstance.get(name);
|
|
158
|
+
}
|
|
159
|
+
if (isRejected(e))
|
|
160
|
+
throw e;
|
|
161
|
+
// The create may have succeeded server-side: look the drive up by its name.
|
|
162
|
+
entry.unconfirmed = true;
|
|
163
|
+
const drive = await DriveInstance.get(name).catch(() => { throw e; });
|
|
164
|
+
entry.unconfirmed = false;
|
|
165
|
+
// A generated name is this call's own; a chosen one may be an existing drive, which is never deleted.
|
|
166
|
+
if (!create.name)
|
|
167
|
+
entry.created = true;
|
|
168
|
+
return drive;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The sandbox could not be created: stop, wait for drives in flight and delete
|
|
173
|
+
* the ones this call created under a generated name. Returns the error to
|
|
174
|
+
* throw: `cause` itself, or a `SandboxDriveSetupError` naming drives left in place.
|
|
175
|
+
*/
|
|
176
|
+
async discard(cause) {
|
|
177
|
+
this.stopped = true;
|
|
178
|
+
this.setRegion(undefined);
|
|
179
|
+
await Promise.allSettled(this.prepared);
|
|
180
|
+
await this.rollback();
|
|
181
|
+
const left = this.leftCreated();
|
|
182
|
+
return left.length ? new SandboxDriveSetupError(undefined, this.leftNames(), left, cause) : cause;
|
|
183
|
+
}
|
|
184
|
+
/** Mount each drive as soon as it is ready, then check the sandbox lists the mounts as requested. */
|
|
185
|
+
async mount(sandbox) {
|
|
186
|
+
const region = sandbox.spec?.region;
|
|
187
|
+
this.setRegion(region);
|
|
188
|
+
const limit = limiter(MOUNT_DRIVES_CONCURRENCY);
|
|
189
|
+
const requests = [];
|
|
190
|
+
const results = await Promise.allSettled(this.prepared.map(async (prepared, index) => {
|
|
191
|
+
try {
|
|
192
|
+
const drive = await prepared;
|
|
193
|
+
if (!drive)
|
|
194
|
+
return;
|
|
195
|
+
if (drive.region !== region) {
|
|
196
|
+
throw new Error(`Drive ${drive.name} is in ${drive.region}, but the sandbox is in ${region}.`);
|
|
197
|
+
}
|
|
198
|
+
const { mountPath, drivePath = "/", readOnly = false } = this.mounts[index];
|
|
199
|
+
const request = { driveName: drive.name, mountPath, drivePath, readOnly };
|
|
200
|
+
await limit(async () => {
|
|
201
|
+
if (this.stopped)
|
|
202
|
+
return;
|
|
203
|
+
requests.push(request);
|
|
204
|
+
this.entries[index].mounted = true;
|
|
205
|
+
await sandbox.drives.mount(request);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
this.stopped = true;
|
|
210
|
+
throw e;
|
|
211
|
+
}
|
|
212
|
+
}));
|
|
213
|
+
try {
|
|
214
|
+
const failed = results.find((result) => result.status === "rejected");
|
|
215
|
+
if (failed)
|
|
216
|
+
throw failed.reason;
|
|
217
|
+
// Mounting reports success even if an existing mount at that path keeps a different readOnly, so check.
|
|
218
|
+
const mounted = await sandbox.drives.list();
|
|
219
|
+
for (const request of requests) {
|
|
220
|
+
const actual = mounted.find(mount => cleanPath(mount.mountPath) === cleanPath(request.mountPath));
|
|
221
|
+
if (actual?.driveName !== request.driveName || cleanPath(actual.drivePath) !== cleanPath(request.drivePath) || (actual.readOnly ?? false) !== request.readOnly) {
|
|
222
|
+
throw new Error(`${request.mountPath} is not mounted as requested; see sandbox.drives.list().`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch (cause) {
|
|
227
|
+
await this.rollback(sandbox);
|
|
228
|
+
throw new SandboxDriveSetupError(sandbox, this.leftNames(), this.leftCreated(), cause);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Delete the drives this call created under a generated name and did not mount.
|
|
233
|
+
* A drive under a caller's name may already be mounted by a concurrent call that
|
|
234
|
+
* reused it, so it is kept. Drives left in place are listed in `leftCreated`.
|
|
235
|
+
*/
|
|
236
|
+
async rollback(sandbox) {
|
|
237
|
+
const created = this.entries.filter(entry => entry.created && entry.generated && entry.exists);
|
|
238
|
+
if (sandbox && created.some(entry => entry.mounted)) {
|
|
239
|
+
// A failed mount call may still have mounted its drive (a lost response), and a
|
|
240
|
+
// successful one may not have (the path was already mounted): ask the sandbox.
|
|
241
|
+
try {
|
|
242
|
+
const mounted = new Set((await sandbox.drives.list()).map(mount => mount.driveName));
|
|
243
|
+
for (const entry of created)
|
|
244
|
+
entry.mounted = mounted.has(entry.name);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// Unknown: keep every drive a mount was attempted for.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const limit = limiter(MOUNT_DRIVES_CONCURRENCY);
|
|
251
|
+
await Promise.all(created.filter(entry => !entry.mounted).map(entry => limit(async () => {
|
|
252
|
+
try {
|
|
253
|
+
await DriveInstance.delete(entry.name);
|
|
254
|
+
entry.exists = false;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// Reported through SandboxDriveSetupError.createdDrives.
|
|
258
|
+
}
|
|
259
|
+
})));
|
|
260
|
+
}
|
|
261
|
+
leftNames() {
|
|
262
|
+
return this.entries.filter(entry => entry.exists).map(entry => entry.name);
|
|
263
|
+
}
|
|
264
|
+
leftCreated() {
|
|
265
|
+
return this.entries.filter(entry => (entry.created && entry.exists) || entry.unconfirmed).map(entry => entry.name);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createSandboxPreview, createSandboxPreviewToken, deleteSandboxPreview, deleteSandboxPreviewToken, getSandboxPreview, listSandboxPreviews, listSandboxPreviewTokens } from "../client/index.js";
|
|
2
|
+
import { logger } from "../common/logger.js";
|
|
2
3
|
function isShorthand(preview) {
|
|
3
4
|
return !("metadata" in preview || "spec" in preview);
|
|
4
5
|
}
|
|
@@ -253,7 +254,7 @@ export class SandboxPreviews {
|
|
|
253
254
|
return data;
|
|
254
255
|
}
|
|
255
256
|
async waitForDeletion(previewName, timeoutMs = 10000) {
|
|
256
|
-
|
|
257
|
+
logger.debug(`Waiting for preview deletion: ${previewName}`);
|
|
257
258
|
const pollInterval = 500; // Poll every 500ms
|
|
258
259
|
const startTime = Date.now();
|
|
259
260
|
while (Date.now() - startTime < timeoutMs) {
|
|
@@ -5,10 +5,12 @@ import { createPaginatedList } from "../common/pagination.js";
|
|
|
5
5
|
import { settings } from "../common/settings.js";
|
|
6
6
|
import { SandboxCodegen } from "./codegen/index.js";
|
|
7
7
|
import { SandboxDrive } from "./drive/index.js";
|
|
8
|
+
import { describeDrift, driftWarning } from "./drift.js";
|
|
8
9
|
import { SandboxFileSystem } from "./filesystem/index.js";
|
|
9
10
|
import { SandboxNetwork } from "./network/index.js";
|
|
10
11
|
import { SandboxPreviews } from "./preview.js";
|
|
11
12
|
import { SandboxProcess } from "./process/index.js";
|
|
13
|
+
import { SandboxDriveSetup, validateMountDrives } from "./mount-drives.js";
|
|
12
14
|
import { SandboxSchedules } from "./schedule.js";
|
|
13
15
|
import { SandboxSnapshotsResource } from "./snapshot.js";
|
|
14
16
|
import { SandboxSessions } from "./session.js";
|
|
@@ -46,6 +48,7 @@ const CREATE_GATEWAY_TIMEOUT_MAX_POLL_MS = 5_000;
|
|
|
46
48
|
// client instead of being masked by an edge 504.
|
|
47
49
|
const CREATION_TIMEOUT_HEADER = "X-Blaxel-Creation-Timeout";
|
|
48
50
|
export const MAX_CREATION_TIMEOUT_SECONDS = 50;
|
|
51
|
+
export { SandboxDriveSetupError } from "./mount-drives.js";
|
|
49
52
|
/**
|
|
50
53
|
* Thrown by SandboxInstance.create / createIfNotExists when the sandbox was
|
|
51
54
|
* not ready within the creation deadline. The control plane has released the
|
|
@@ -292,13 +295,39 @@ export class SandboxInstance {
|
|
|
292
295
|
return this;
|
|
293
296
|
}
|
|
294
297
|
static async create(sandbox, options = {}) {
|
|
295
|
-
const {
|
|
296
|
-
|
|
298
|
+
const { mountDrives = [], createIfNotExist = false } = options;
|
|
299
|
+
validateCreateOptions(options);
|
|
300
|
+
validateMountDrives(mountDrives, createIfNotExist);
|
|
301
|
+
const { model, requested } = SandboxInstance.toSandboxModel(sandbox);
|
|
302
|
+
if (!mountDrives.length)
|
|
303
|
+
return SandboxInstance.createSandboxInstance(model, requested, options);
|
|
304
|
+
// Drives are set up alongside the sandbox, in the region this request sends (if it
|
|
305
|
+
// sends none, the control plane picks one and new drives wait for the sandbox).
|
|
306
|
+
const drives = new SandboxDriveSetup(mountDrives, model.spec?.region);
|
|
307
|
+
drives.start();
|
|
308
|
+
let instance;
|
|
309
|
+
try {
|
|
310
|
+
instance = await SandboxInstance.createSandboxInstance(model, requested, options);
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
throw await drives.discard(err);
|
|
314
|
+
}
|
|
315
|
+
await drives.mount(instance);
|
|
316
|
+
return instance;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* The creation request body, with the SDK's defaults (image, memory, region from BL_REGION) applied,
|
|
320
|
+
* plus what the caller explicitly asked for (before defaults).
|
|
321
|
+
*/
|
|
322
|
+
static toSandboxModel(sandbox) {
|
|
297
323
|
// No client-side default name: when the caller omits a name we send the
|
|
298
324
|
// creation without metadata.name so the server can assign one and unnamed
|
|
299
325
|
// creations become eligible for warm sandbox pools (ENG-3931).
|
|
300
326
|
const defaultImage = `blaxel/base-image:latest`;
|
|
301
327
|
const defaultMemory = 4096;
|
|
328
|
+
// What the caller asked for, read before the defaults below fill the gaps:
|
|
329
|
+
// createIfNotExists compares only these with the sandbox it gets back.
|
|
330
|
+
let requested = {};
|
|
302
331
|
// Handle SandboxCreateConfiguration or simple dict with name/image/memory/ports/envs/volumes keys
|
|
303
332
|
if (!sandbox ||
|
|
304
333
|
'name' in sandbox ||
|
|
@@ -316,6 +345,7 @@ export class SandboxInstance {
|
|
|
316
345
|
'standbyGracePeriodSeconds' in sandbox) {
|
|
317
346
|
if (!sandbox)
|
|
318
347
|
sandbox = {};
|
|
348
|
+
requested = { image: sandbox.image, memory: sandbox.memory, region: sandbox.region, envs: normalizeEnvs(sandbox.envs) };
|
|
319
349
|
if (!sandbox.image)
|
|
320
350
|
sandbox.image = defaultImage;
|
|
321
351
|
if (!sandbox.memory)
|
|
@@ -362,6 +392,10 @@ export class SandboxInstance {
|
|
|
362
392
|
sandbox.spec.runtime.expires = expires.toISOString();
|
|
363
393
|
}
|
|
364
394
|
}
|
|
395
|
+
else {
|
|
396
|
+
const { region, runtime } = sandbox.spec ?? {};
|
|
397
|
+
requested = { image: runtime?.image, memory: runtime?.memory, region, envs: runtime?.envs };
|
|
398
|
+
}
|
|
365
399
|
sandbox = sandbox;
|
|
366
400
|
if (!sandbox.metadata) {
|
|
367
401
|
// Leave name unset so the server assigns one (ENG-3931).
|
|
@@ -375,6 +409,11 @@ export class SandboxInstance {
|
|
|
375
409
|
}
|
|
376
410
|
sandbox.spec.runtime.image = sandbox.spec.runtime.image || defaultImage;
|
|
377
411
|
sandbox.spec.runtime.memory = sandbox.spec.runtime.memory || defaultMemory;
|
|
412
|
+
return { model: sandbox, requested };
|
|
413
|
+
}
|
|
414
|
+
static async createSandboxInstance(sandbox, requested, options) {
|
|
415
|
+
const { safe = false, createIfNotExist = false } = options;
|
|
416
|
+
const { timeout } = validateCreateOptions(options);
|
|
378
417
|
const edgeDomain = SandboxInstance.edgeDomainForRegion(sandbox.spec?.region);
|
|
379
418
|
// Kick off warming so h2Pool.get() can join it during the API call
|
|
380
419
|
if (edgeDomain && !settings.disableH2) {
|
|
@@ -404,6 +443,8 @@ export class SandboxInstance {
|
|
|
404
443
|
throw createResult.error;
|
|
405
444
|
}
|
|
406
445
|
}
|
|
446
|
+
if (createIfNotExist)
|
|
447
|
+
SandboxInstance.warnOnDrift(requested, data);
|
|
407
448
|
// Inject the H2 session into the config so subsystems can use it
|
|
408
449
|
const config = { ...data, h2Session, h2Domain: settings.disableH2 ? null : edgeDomain };
|
|
409
450
|
const instance = new SandboxInstance(config);
|
|
@@ -641,6 +682,21 @@ export class SandboxInstance {
|
|
|
641
682
|
static async createIfNotExists(sandbox, options = {}) {
|
|
642
683
|
return this.create(sandbox, { ...options, createIfNotExist: true });
|
|
643
684
|
}
|
|
685
|
+
// createIfNotExists hands back the sandbox already holding the name, whatever
|
|
686
|
+
// it was created with. Say so when it is not what was asked for; this only
|
|
687
|
+
// logs and never throws or changes what is returned.
|
|
688
|
+
static warnOnDrift(requested, existing) {
|
|
689
|
+
if (!existing)
|
|
690
|
+
return;
|
|
691
|
+
try {
|
|
692
|
+
const drift = describeDrift(requested, existing);
|
|
693
|
+
if (drift.length)
|
|
694
|
+
console.warn(driftWarning(existing.metadata?.name, drift));
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
// Best-effort diagnostics: a failed comparison must not fail the create.
|
|
698
|
+
}
|
|
699
|
+
}
|
|
644
700
|
// Poll the record after a create was cut by the edge with a 504 while the
|
|
645
701
|
// control plane is still deploying it. Resolves with the sandbox once it
|
|
646
702
|
// reaches DEPLOYED; throws on FAILED or once the wait budget is spent.
|