@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.
Files changed (33) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/common/settings.js +2 -2
  3. package/dist/cjs/sandbox/drift.js +60 -0
  4. package/dist/cjs/sandbox/mount-drives.js +273 -0
  5. package/dist/cjs/sandbox/preview.js +2 -1
  6. package/dist/cjs/sandbox/sandbox.js +60 -3
  7. package/dist/cjs/types/sandbox/drift.d.ts +30 -0
  8. package/dist/cjs/types/sandbox/mount-drives.d.ts +66 -0
  9. package/dist/cjs/types/sandbox/sandbox.d.ts +21 -1
  10. package/dist/cjs/types/sandbox/types.d.ts +16 -0
  11. package/dist/cjs-browser/.tsbuildinfo +1 -1
  12. package/dist/cjs-browser/common/settings.js +2 -2
  13. package/dist/cjs-browser/sandbox/drift.js +60 -0
  14. package/dist/cjs-browser/sandbox/mount-drives.js +273 -0
  15. package/dist/cjs-browser/sandbox/preview.js +2 -1
  16. package/dist/cjs-browser/sandbox/sandbox.js +60 -3
  17. package/dist/cjs-browser/types/sandbox/drift.d.ts +30 -0
  18. package/dist/cjs-browser/types/sandbox/mount-drives.d.ts +66 -0
  19. package/dist/cjs-browser/types/sandbox/sandbox.d.ts +21 -1
  20. package/dist/cjs-browser/types/sandbox/types.d.ts +16 -0
  21. package/dist/esm/.tsbuildinfo +1 -1
  22. package/dist/esm/common/settings.js +2 -2
  23. package/dist/esm/sandbox/drift.js +56 -0
  24. package/dist/esm/sandbox/mount-drives.js +267 -0
  25. package/dist/esm/sandbox/preview.js +2 -1
  26. package/dist/esm/sandbox/sandbox.js +58 -2
  27. package/dist/esm-browser/.tsbuildinfo +1 -1
  28. package/dist/esm-browser/common/settings.js +2 -2
  29. package/dist/esm-browser/sandbox/drift.js +56 -0
  30. package/dist/esm-browser/sandbox/mount-drives.js +267 -0
  31. package/dist/esm-browser/sandbox/preview.js +2 -1
  32. package/dist/esm-browser/sandbox/sandbox.js +58 -2
  33. package/package.json +1 -1
@@ -30,8 +30,8 @@ function missingCredentialsMessage() {
30
30
  return "No Blaxel credentials found. Set the BL_API_KEY and BL_WORKSPACE environment variables, or run `bl login`.";
31
31
  }
32
32
  // Build info - these placeholders are replaced at build time by build:replace-imports
33
- const BUILD_VERSION = "0.3.31";
34
- const BUILD_COMMIT = "e08b96df13cebc750a05401ee4d824d42f37a191";
33
+ const BUILD_VERSION = "0.3.32-preview.332";
34
+ const BUILD_COMMIT = "eeb11112e6f23be5c60b770389ef29eda8452fb0";
35
35
  const BUILD_SENTRY_DSN = "https://fd5e60e1c9820e1eef5ccebb84a07127@o4508714045276160.ingest.us.sentry.io/4510465864564736";
36
36
  const BLAXEL_API_VERSION = "2026-09-22";
37
37
  // Bun < 1.3.11 never sends connection-level WINDOW_UPDATE: the pooled h2
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.describeDrift = describeDrift;
4
+ exports.driftWarning = driftWarning;
5
+ // The control plane masks every env value it reads back, so values can only be compared when it did not.
6
+ const MASKED_ENV_VALUE = "****";
7
+ // The control plane keeps the image reference as submitted: "app" and "app:latest" are the same image.
8
+ function withTag(image) {
9
+ if (image.includes("@"))
10
+ return image;
11
+ return image.indexOf(":", image.lastIndexOf("/") + 1) === -1 ? `${image}:latest` : image;
12
+ }
13
+ function envDrift(requested, existing) {
14
+ const byName = new Map(existing.map((env) => [env.name, env]));
15
+ const missing = [];
16
+ const changed = [];
17
+ for (const env of requested) {
18
+ const current = byName.get(env.name);
19
+ if (!current)
20
+ missing.push(env.name ?? "");
21
+ else if (current.value !== undefined && current.value !== MASKED_ENV_VALUE && current.value !== env.value)
22
+ changed.push(env.name ?? "");
23
+ }
24
+ // Names only: env values are often secrets and never go into a log line.
25
+ const parts = [
26
+ missing.length ? `not set on the existing sandbox: ${missing.join(", ")}` : "",
27
+ changed.length ? `different value: ${changed.join(", ")}` : "",
28
+ ].filter(Boolean);
29
+ return parts.length ? `envs (${parts.join("; ")})` : undefined;
30
+ }
31
+ /**
32
+ * Describes how an existing sandbox differs from the configuration that was
33
+ * asked for, one entry per field (image, memory, region, envs). Empty when
34
+ * nothing differs. A field the existing sandbox does not report is not drift.
35
+ */
36
+ function describeDrift(requested, existing) {
37
+ const drift = [];
38
+ const runtime = existing.spec?.runtime;
39
+ if (requested.image && runtime?.image && withTag(requested.image) !== withTag(runtime.image)) {
40
+ drift.push(`image (requested ${requested.image}, existing ${runtime.image})`);
41
+ }
42
+ if (requested.memory !== undefined && runtime?.memory !== undefined && requested.memory !== runtime.memory) {
43
+ drift.push(`memory (requested ${requested.memory} MB, existing ${runtime.memory} MB)`);
44
+ }
45
+ if (requested.region && existing.spec?.region && requested.region !== existing.spec.region) {
46
+ drift.push(`region (requested ${requested.region}, existing ${existing.spec.region})`);
47
+ }
48
+ if (requested.envs?.length && runtime?.envs) {
49
+ const envs = envDrift(requested.envs, runtime.envs);
50
+ if (envs)
51
+ drift.push(envs);
52
+ }
53
+ return drift;
54
+ }
55
+ /** The single warning createIfNotExists logs when the sandbox it returned is not the one that was asked for. */
56
+ function driftWarning(name, drift) {
57
+ return (`SandboxInstance.createIfNotExists: sandbox ${name ? `"${name}" ` : ""}already exists, so it was returned as is and the requested configuration was not applied. ` +
58
+ `It differs on: ${drift.join("; ")}. ` +
59
+ "Delete the sandbox and create it again to apply the new configuration.");
60
+ }
@@ -0,0 +1,273 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SandboxDriveSetup = exports.SandboxDriveSetupError = exports.MOUNT_DRIVES_CONCURRENCY = void 0;
4
+ exports.validateMountDrives = validateMountDrives;
5
+ const uuid_1 = require("uuid");
6
+ const index_js_1 = require("../drive/index.js");
7
+ /** Most drive lookups/creations, mounts and deletions in flight at once. */
8
+ exports.MOUNT_DRIVES_CONCURRENCY = 5;
9
+ /**
10
+ * Thrown when `mountDrives` could not be set up. Drives this call created
11
+ * under a name it generated and did not mount are deleted first; a drive
12
+ * created under a name you chose is never deleted, since a concurrent call may
13
+ * be using it. `createdDrives` names the drives this call created (or may have
14
+ * created, when a response was lost) that are left in place.
15
+ *
16
+ * - `sandbox` is set when the sandbox is ready: it and the mounts made so far
17
+ * are kept. `driveNames` lists the drives this call looked up or created that
18
+ * are left in place.
19
+ * - `sandbox` is undefined when the sandbox could not be created (its error is
20
+ * the `cause`) and some drives created for it could not be deleted. When they
21
+ * all could, the sandbox's error is thrown as is instead.
22
+ */
23
+ class SandboxDriveSetupError extends Error {
24
+ sandbox;
25
+ driveNames;
26
+ createdDrives;
27
+ constructor(sandbox, driveNames, createdDrives, cause) {
28
+ const detail = cause instanceof Error ? cause.message : JSON.stringify(cause);
29
+ const left = createdDrives.length ? `. Drives this call created (or may have created) are left in place: ${createdDrives.join(", ")}.` : "";
30
+ super(sandbox
31
+ ? `Sandbox ${sandbox.metadata.name} is ready, but mounting its drives failed: ${detail}${left}`
32
+ : `Sandbox creation failed: ${detail}${left}`, { cause });
33
+ this.sandbox = sandbox;
34
+ this.driveNames = driveNames;
35
+ this.createdDrives = createdDrives;
36
+ this.name = "SandboxDriveSetupError";
37
+ }
38
+ }
39
+ exports.SandboxDriveSetupError = SandboxDriveSetupError;
40
+ const cleanPath = (path = "/") => `/${path.split("/").filter(Boolean).join("/")}`;
41
+ /** The HTTP status of an API error, if it has one. */
42
+ function httpStatus(e) {
43
+ if (typeof e !== "object" || e === null)
44
+ return undefined;
45
+ const error = e;
46
+ for (const value of [error.response?.status, error.status, error.code]) {
47
+ if (typeof value === "number" && value >= 100 && value < 600)
48
+ return value;
49
+ }
50
+ return undefined;
51
+ }
52
+ const isConflict = (e) => httpStatus(e) === 409 || (typeof e === "object" && e !== null && "code" in e && e.code === "DRIVE_ALREADY_EXISTS");
53
+ /** The server answered and refused the request, so it changed nothing. Anything else (5xx, network error, timeout) may have gone through. */
54
+ function isRejected(e) {
55
+ const status = httpStatus(e);
56
+ return status !== undefined && status >= 400 && status < 500 && status !== 408;
57
+ }
58
+ function validateMountDrives(mounts, createIfNotExist) {
59
+ for (const { driveName, create } of mounts) {
60
+ if (!driveName === !create) {
61
+ throw new TypeError("Each mountDrives entry needs exactly one of 'driveName' or 'create'.");
62
+ }
63
+ if (createIfNotExist && create && !create.name) {
64
+ throw new TypeError("With createIfNotExist, a new drive in mountDrives needs a name; otherwise every call would create another drive.");
65
+ }
66
+ }
67
+ }
68
+ /** Runs tasks with at most `limit` in flight; a freed slot goes straight to the next waiter. */
69
+ function limiter(limit) {
70
+ let active = 0;
71
+ const waiting = [];
72
+ return async (task) => {
73
+ if (active >= limit)
74
+ await new Promise(resolve => waiting.push(resolve));
75
+ else
76
+ active++;
77
+ try {
78
+ return await task();
79
+ }
80
+ finally {
81
+ const next = waiting.shift();
82
+ if (next)
83
+ next();
84
+ else
85
+ active--;
86
+ }
87
+ };
88
+ }
89
+ /**
90
+ * Drives for one `SandboxInstance.create` call. Looking up or creating drives
91
+ * (`start`) runs alongside the sandbox creation, at most
92
+ * `MOUNT_DRIVES_CONCURRENCY` at a time; only mounting waits for both the sandbox
93
+ * and its drive (`mount`). On failure, drives this call created under a
94
+ * generated name and did not mount are deleted (`discard`, or `mount` when it
95
+ * fails); drives under a caller's name are kept and reported.
96
+ */
97
+ class SandboxDriveSetup {
98
+ mounts;
99
+ stopped = false;
100
+ prepared = [];
101
+ entries;
102
+ region;
103
+ setRegion;
104
+ /**
105
+ * `region` is the region the sandbox creation request sends, if any. New
106
+ * drives are created in it at once; without one they wait for the created
107
+ * sandbox and use its region.
108
+ */
109
+ constructor(mounts, region) {
110
+ this.mounts = mounts;
111
+ this.entries = mounts.map(() => ({}));
112
+ this.region = new Promise(resolve => { this.setRegion = resolve; });
113
+ if (region)
114
+ this.setRegion(region);
115
+ }
116
+ start() {
117
+ const limit = limiter(exports.MOUNT_DRIVES_CONCURRENCY);
118
+ this.prepared = this.mounts.map(async (mount, index) => {
119
+ // A new drive goes in the sandbox's region, so it waits for it (without holding a slot) if it is not known yet.
120
+ // Lookups await too, so that entries keep their order in the queue.
121
+ const region = await (mount.create ? this.region : undefined);
122
+ return limit(async () => {
123
+ if (this.stopped)
124
+ return undefined;
125
+ try {
126
+ const drive = mount.create ? await this.createDrive(this.entries[index], mount.create, region) : await index_js_1.DriveInstance.get(mount.driveName);
127
+ Object.assign(this.entries[index], { name: drive.name, exists: true });
128
+ return drive;
129
+ }
130
+ catch (e) {
131
+ this.stopped = true;
132
+ throw e;
133
+ }
134
+ });
135
+ });
136
+ // Failures surface in mount() or discard().
137
+ for (const promise of this.prepared)
138
+ promise.catch(() => { });
139
+ }
140
+ /** Create a drive in the sandbox's region; a named one that already exists is reused. */
141
+ async createDrive(entry, create, region) {
142
+ if (!region)
143
+ throw new Error("The sandbox reports no region, so its drives were not created.");
144
+ const requested = create.region;
145
+ if (requested && requested !== region) {
146
+ throw new Error(`Drive region ${requested} does not match the sandbox region ${region}.`);
147
+ }
148
+ // Name unnamed drives here so that a create whose response is lost can still be looked up.
149
+ const name = create.name || `drive-${(0, uuid_1.v4)().replace(/-/g, "").slice(0, 16)}`;
150
+ entry.name = name;
151
+ entry.generated = !create.name;
152
+ try {
153
+ const drive = await index_js_1.DriveInstance.create({ ...create, name, region });
154
+ entry.created = true;
155
+ return drive;
156
+ }
157
+ catch (e) {
158
+ if (isConflict(e)) {
159
+ // Only a name the caller chose can belong to an existing drive worth reusing.
160
+ if (!create.name)
161
+ throw e;
162
+ return index_js_1.DriveInstance.get(name);
163
+ }
164
+ if (isRejected(e))
165
+ throw e;
166
+ // The create may have succeeded server-side: look the drive up by its name.
167
+ entry.unconfirmed = true;
168
+ const drive = await index_js_1.DriveInstance.get(name).catch(() => { throw e; });
169
+ entry.unconfirmed = false;
170
+ // A generated name is this call's own; a chosen one may be an existing drive, which is never deleted.
171
+ if (!create.name)
172
+ entry.created = true;
173
+ return drive;
174
+ }
175
+ }
176
+ /**
177
+ * The sandbox could not be created: stop, wait for drives in flight and delete
178
+ * the ones this call created under a generated name. Returns the error to
179
+ * throw: `cause` itself, or a `SandboxDriveSetupError` naming drives left in place.
180
+ */
181
+ async discard(cause) {
182
+ this.stopped = true;
183
+ this.setRegion(undefined);
184
+ await Promise.allSettled(this.prepared);
185
+ await this.rollback();
186
+ const left = this.leftCreated();
187
+ return left.length ? new SandboxDriveSetupError(undefined, this.leftNames(), left, cause) : cause;
188
+ }
189
+ /** Mount each drive as soon as it is ready, then check the sandbox lists the mounts as requested. */
190
+ async mount(sandbox) {
191
+ const region = sandbox.spec?.region;
192
+ this.setRegion(region);
193
+ const limit = limiter(exports.MOUNT_DRIVES_CONCURRENCY);
194
+ const requests = [];
195
+ const results = await Promise.allSettled(this.prepared.map(async (prepared, index) => {
196
+ try {
197
+ const drive = await prepared;
198
+ if (!drive)
199
+ return;
200
+ if (drive.region !== region) {
201
+ throw new Error(`Drive ${drive.name} is in ${drive.region}, but the sandbox is in ${region}.`);
202
+ }
203
+ const { mountPath, drivePath = "/", readOnly = false } = this.mounts[index];
204
+ const request = { driveName: drive.name, mountPath, drivePath, readOnly };
205
+ await limit(async () => {
206
+ if (this.stopped)
207
+ return;
208
+ requests.push(request);
209
+ this.entries[index].mounted = true;
210
+ await sandbox.drives.mount(request);
211
+ });
212
+ }
213
+ catch (e) {
214
+ this.stopped = true;
215
+ throw e;
216
+ }
217
+ }));
218
+ try {
219
+ const failed = results.find((result) => result.status === "rejected");
220
+ if (failed)
221
+ throw failed.reason;
222
+ // Mounting reports success even if an existing mount at that path keeps a different readOnly, so check.
223
+ const mounted = await sandbox.drives.list();
224
+ for (const request of requests) {
225
+ const actual = mounted.find(mount => cleanPath(mount.mountPath) === cleanPath(request.mountPath));
226
+ if (actual?.driveName !== request.driveName || cleanPath(actual.drivePath) !== cleanPath(request.drivePath) || (actual.readOnly ?? false) !== request.readOnly) {
227
+ throw new Error(`${request.mountPath} is not mounted as requested; see sandbox.drives.list().`);
228
+ }
229
+ }
230
+ }
231
+ catch (cause) {
232
+ await this.rollback(sandbox);
233
+ throw new SandboxDriveSetupError(sandbox, this.leftNames(), this.leftCreated(), cause);
234
+ }
235
+ }
236
+ /**
237
+ * Delete the drives this call created under a generated name and did not mount.
238
+ * A drive under a caller's name may already be mounted by a concurrent call that
239
+ * reused it, so it is kept. Drives left in place are listed in `leftCreated`.
240
+ */
241
+ async rollback(sandbox) {
242
+ const created = this.entries.filter(entry => entry.created && entry.generated && entry.exists);
243
+ if (sandbox && created.some(entry => entry.mounted)) {
244
+ // A failed mount call may still have mounted its drive (a lost response), and a
245
+ // successful one may not have (the path was already mounted): ask the sandbox.
246
+ try {
247
+ const mounted = new Set((await sandbox.drives.list()).map(mount => mount.driveName));
248
+ for (const entry of created)
249
+ entry.mounted = mounted.has(entry.name);
250
+ }
251
+ catch {
252
+ // Unknown: keep every drive a mount was attempted for.
253
+ }
254
+ }
255
+ const limit = limiter(exports.MOUNT_DRIVES_CONCURRENCY);
256
+ await Promise.all(created.filter(entry => !entry.mounted).map(entry => limit(async () => {
257
+ try {
258
+ await index_js_1.DriveInstance.delete(entry.name);
259
+ entry.exists = false;
260
+ }
261
+ catch {
262
+ // Reported through SandboxDriveSetupError.createdDrives.
263
+ }
264
+ })));
265
+ }
266
+ leftNames() {
267
+ return this.entries.filter(entry => entry.exists).map(entry => entry.name);
268
+ }
269
+ leftCreated() {
270
+ return this.entries.filter(entry => (entry.created && entry.exists) || entry.unconfirmed).map(entry => entry.name);
271
+ }
272
+ }
273
+ exports.SandboxDriveSetup = SandboxDriveSetup;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SandboxPreviews = exports.SandboxPreview = exports.SandboxPreviewTokens = exports.SandboxPreviewToken = void 0;
4
4
  const index_js_1 = require("../client/index.js");
5
+ const logger_js_1 = require("../common/logger.js");
5
6
  function isShorthand(preview) {
6
7
  return !("metadata" in preview || "spec" in preview);
7
8
  }
@@ -259,7 +260,7 @@ class SandboxPreviews {
259
260
  return data;
260
261
  }
261
262
  async waitForDeletion(previewName, timeoutMs = 10000) {
262
- console.log(`Waiting for preview deletion: ${previewName}`);
263
+ logger_js_1.logger.debug(`Waiting for preview deletion: ${previewName}`);
263
264
  const pollInterval = 500; // Poll every 500ms
264
265
  const startTime = Date.now();
265
266
  while (Date.now() - startTime < timeoutMs) {
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.SandboxInstance = exports.SandboxCreationTimeoutError = exports.MAX_CREATION_TIMEOUT_SECONDS = void 0;
36
+ exports.SandboxInstance = exports.SandboxCreationTimeoutError = exports.SandboxDriveSetupError = exports.MAX_CREATION_TIMEOUT_SECONDS = void 0;
37
37
  exports.isCreationTimeoutError = isCreationTimeoutError;
38
38
  const index_js_1 = require("../client/index.js");
39
39
  const logger_js_1 = require("../common/logger.js");
@@ -42,10 +42,12 @@ const pagination_js_1 = require("../common/pagination.js");
42
42
  const settings_js_1 = require("../common/settings.js");
43
43
  const index_js_2 = require("./codegen/index.js");
44
44
  const index_js_3 = require("./drive/index.js");
45
+ const drift_js_1 = require("./drift.js");
45
46
  const index_js_4 = require("./filesystem/index.js");
46
47
  const index_js_5 = require("./network/index.js");
47
48
  const preview_js_1 = require("./preview.js");
48
49
  const index_js_6 = require("./process/index.js");
50
+ const mount_drives_js_1 = require("./mount-drives.js");
49
51
  const schedule_js_1 = require("./schedule.js");
50
52
  const snapshot_js_1 = require("./snapshot.js");
51
53
  const session_js_1 = require("./session.js");
@@ -83,6 +85,8 @@ const CREATE_GATEWAY_TIMEOUT_MAX_POLL_MS = 5_000;
83
85
  // client instead of being masked by an edge 504.
84
86
  const CREATION_TIMEOUT_HEADER = "X-Blaxel-Creation-Timeout";
85
87
  exports.MAX_CREATION_TIMEOUT_SECONDS = 50;
88
+ var mount_drives_js_2 = require("./mount-drives.js");
89
+ Object.defineProperty(exports, "SandboxDriveSetupError", { enumerable: true, get: function () { return mount_drives_js_2.SandboxDriveSetupError; } });
86
90
  /**
87
91
  * Thrown by SandboxInstance.create / createIfNotExists when the sandbox was
88
92
  * not ready within the creation deadline. The control plane has released the
@@ -330,13 +334,39 @@ class SandboxInstance {
330
334
  return this;
331
335
  }
332
336
  static async create(sandbox, options = {}) {
333
- const { safe = false, createIfNotExist = false } = options;
334
- const { timeout } = validateCreateOptions(options);
337
+ const { mountDrives = [], createIfNotExist = false } = options;
338
+ validateCreateOptions(options);
339
+ (0, mount_drives_js_1.validateMountDrives)(mountDrives, createIfNotExist);
340
+ const { model, requested } = SandboxInstance.toSandboxModel(sandbox);
341
+ if (!mountDrives.length)
342
+ return SandboxInstance.createSandboxInstance(model, requested, options);
343
+ // Drives are set up alongside the sandbox, in the region this request sends (if it
344
+ // sends none, the control plane picks one and new drives wait for the sandbox).
345
+ const drives = new mount_drives_js_1.SandboxDriveSetup(mountDrives, model.spec?.region);
346
+ drives.start();
347
+ let instance;
348
+ try {
349
+ instance = await SandboxInstance.createSandboxInstance(model, requested, options);
350
+ }
351
+ catch (err) {
352
+ throw await drives.discard(err);
353
+ }
354
+ await drives.mount(instance);
355
+ return instance;
356
+ }
357
+ /**
358
+ * The creation request body, with the SDK's defaults (image, memory, region from BL_REGION) applied,
359
+ * plus what the caller explicitly asked for (before defaults).
360
+ */
361
+ static toSandboxModel(sandbox) {
335
362
  // No client-side default name: when the caller omits a name we send the
336
363
  // creation without metadata.name so the server can assign one and unnamed
337
364
  // creations become eligible for warm sandbox pools (ENG-3931).
338
365
  const defaultImage = `blaxel/base-image:latest`;
339
366
  const defaultMemory = 4096;
367
+ // What the caller asked for, read before the defaults below fill the gaps:
368
+ // createIfNotExists compares only these with the sandbox it gets back.
369
+ let requested = {};
340
370
  // Handle SandboxCreateConfiguration or simple dict with name/image/memory/ports/envs/volumes keys
341
371
  if (!sandbox ||
342
372
  'name' in sandbox ||
@@ -354,6 +384,7 @@ class SandboxInstance {
354
384
  'standbyGracePeriodSeconds' in sandbox) {
355
385
  if (!sandbox)
356
386
  sandbox = {};
387
+ requested = { image: sandbox.image, memory: sandbox.memory, region: sandbox.region, envs: (0, types_js_1.normalizeEnvs)(sandbox.envs) };
357
388
  if (!sandbox.image)
358
389
  sandbox.image = defaultImage;
359
390
  if (!sandbox.memory)
@@ -400,6 +431,10 @@ class SandboxInstance {
400
431
  sandbox.spec.runtime.expires = expires.toISOString();
401
432
  }
402
433
  }
434
+ else {
435
+ const { region, runtime } = sandbox.spec ?? {};
436
+ requested = { image: runtime?.image, memory: runtime?.memory, region, envs: runtime?.envs };
437
+ }
403
438
  sandbox = sandbox;
404
439
  if (!sandbox.metadata) {
405
440
  // Leave name unset so the server assigns one (ENG-3931).
@@ -413,6 +448,11 @@ class SandboxInstance {
413
448
  }
414
449
  sandbox.spec.runtime.image = sandbox.spec.runtime.image || defaultImage;
415
450
  sandbox.spec.runtime.memory = sandbox.spec.runtime.memory || defaultMemory;
451
+ return { model: sandbox, requested };
452
+ }
453
+ static async createSandboxInstance(sandbox, requested, options) {
454
+ const { safe = false, createIfNotExist = false } = options;
455
+ const { timeout } = validateCreateOptions(options);
416
456
  const edgeDomain = SandboxInstance.edgeDomainForRegion(sandbox.spec?.region);
417
457
  // Kick off warming so h2Pool.get() can join it during the API call
418
458
  if (edgeDomain && !settings_js_1.settings.disableH2) {
@@ -442,6 +482,8 @@ class SandboxInstance {
442
482
  throw createResult.error;
443
483
  }
444
484
  }
485
+ if (createIfNotExist)
486
+ SandboxInstance.warnOnDrift(requested, data);
445
487
  // Inject the H2 session into the config so subsystems can use it
446
488
  const config = { ...data, h2Session, h2Domain: settings_js_1.settings.disableH2 ? null : edgeDomain };
447
489
  const instance = new SandboxInstance(config);
@@ -679,6 +721,21 @@ class SandboxInstance {
679
721
  static async createIfNotExists(sandbox, options = {}) {
680
722
  return this.create(sandbox, { ...options, createIfNotExist: true });
681
723
  }
724
+ // createIfNotExists hands back the sandbox already holding the name, whatever
725
+ // it was created with. Say so when it is not what was asked for; this only
726
+ // logs and never throws or changes what is returned.
727
+ static warnOnDrift(requested, existing) {
728
+ if (!existing)
729
+ return;
730
+ try {
731
+ const drift = (0, drift_js_1.describeDrift)(requested, existing);
732
+ if (drift.length)
733
+ console.warn((0, drift_js_1.driftWarning)(existing.metadata?.name, drift));
734
+ }
735
+ catch {
736
+ // Best-effort diagnostics: a failed comparison must not fail the create.
737
+ }
738
+ }
682
739
  // Poll the record after a create was cut by the edge with a 504 while the
683
740
  // control plane is still deploying it. Resolves with the sandbox once it
684
741
  // reaches DEPLOYED; throws on FAILED or once the wait budget is spent.
@@ -0,0 +1,30 @@
1
+ import type { Env } from "../client/index.js";
2
+ /** The fields of a create request that are compared with an existing sandbox. A field the caller did not set is not compared. */
3
+ export type RequestedSandbox = {
4
+ image?: string;
5
+ memory?: number;
6
+ region?: string;
7
+ envs?: Env[];
8
+ };
9
+ type ExistingSandbox = {
10
+ metadata?: {
11
+ name?: string;
12
+ };
13
+ spec?: {
14
+ region?: string;
15
+ runtime?: {
16
+ image?: string;
17
+ memory?: number;
18
+ envs?: Env[];
19
+ };
20
+ };
21
+ };
22
+ /**
23
+ * Describes how an existing sandbox differs from the configuration that was
24
+ * asked for, one entry per field (image, memory, region, envs). Empty when
25
+ * nothing differs. A field the existing sandbox does not report is not drift.
26
+ */
27
+ export declare function describeDrift(requested: RequestedSandbox, existing: ExistingSandbox): string[];
28
+ /** The single warning createIfNotExists logs when the sandbox it returned is not the one that was asked for. */
29
+ export declare function driftWarning(name: string | undefined, drift: string[]): string;
30
+ export {};
@@ -0,0 +1,66 @@
1
+ import type { SandboxInstance } from "./sandbox.js";
2
+ import type { SandboxDriveMountConfiguration } from "./types.js";
3
+ /** Most drive lookups/creations, mounts and deletions in flight at once. */
4
+ export declare 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 declare class SandboxDriveSetupError extends Error {
20
+ readonly sandbox: SandboxInstance | undefined;
21
+ readonly driveNames: string[];
22
+ readonly createdDrives: string[];
23
+ constructor(sandbox: SandboxInstance | undefined, driveNames: string[], createdDrives: string[], cause: unknown);
24
+ }
25
+ export declare function validateMountDrives(mounts: SandboxDriveMountConfiguration[], createIfNotExist: boolean): void;
26
+ /**
27
+ * Drives for one `SandboxInstance.create` call. Looking up or creating drives
28
+ * (`start`) runs alongside the sandbox creation, at most
29
+ * `MOUNT_DRIVES_CONCURRENCY` at a time; only mounting waits for both the sandbox
30
+ * and its drive (`mount`). On failure, drives this call created under a
31
+ * generated name and did not mount are deleted (`discard`, or `mount` when it
32
+ * fails); drives under a caller's name are kept and reported.
33
+ */
34
+ export declare class SandboxDriveSetup {
35
+ private mounts;
36
+ private stopped;
37
+ private prepared;
38
+ private entries;
39
+ private region;
40
+ private setRegion;
41
+ /**
42
+ * `region` is the region the sandbox creation request sends, if any. New
43
+ * drives are created in it at once; without one they wait for the created
44
+ * sandbox and use its region.
45
+ */
46
+ constructor(mounts: SandboxDriveMountConfiguration[], region?: string);
47
+ start(): void;
48
+ /** Create a drive in the sandbox's region; a named one that already exists is reused. */
49
+ private createDrive;
50
+ /**
51
+ * The sandbox could not be created: stop, wait for drives in flight and delete
52
+ * the ones this call created under a generated name. Returns the error to
53
+ * throw: `cause` itself, or a `SandboxDriveSetupError` naming drives left in place.
54
+ */
55
+ discard(cause: unknown): Promise<unknown>;
56
+ /** Mount each drive as soon as it is ready, then check the sandbox lists the mounts as requested. */
57
+ mount(sandbox: SandboxInstance): Promise<void>;
58
+ /**
59
+ * Delete the drives this call created under a generated name and did not mount.
60
+ * A drive under a caller's name may already be mounted by a concurrent call that
61
+ * reused it, so it is kept. Drives left in place are listed in `leftCreated`.
62
+ */
63
+ private rollback;
64
+ private leftNames;
65
+ private leftCreated;
66
+ }
@@ -10,7 +10,7 @@ import { SandboxSchedules } from "./schedule.js";
10
10
  import { SandboxSnapshotsResource } from "./snapshot.js";
11
11
  import { SandboxSessions } from "./session.js";
12
12
  import { SandboxSystem } from "./system.js";
13
- import { SandboxConfiguration, SandboxCreateConfiguration, SandboxUpdateMetadata, SandboxUpdateNetwork, SessionWithToken } from "./types.js";
13
+ import { SandboxConfiguration, SandboxCreateConfiguration, SandboxDriveMountConfiguration, SandboxUpdateMetadata, SandboxUpdateNetwork, SessionWithToken } from "./types.js";
14
14
  export type SandboxListQuery = NonNullable<ListSandboxesData["query"]>;
15
15
  export type SandboxForkOptions = {
16
16
  /** Resource type to fork into. Defaults to "sandbox". */
@@ -66,7 +66,20 @@ export type SandboxCreateOptions = {
66
66
  * control plane's default deadline applies.
67
67
  */
68
68
  timeout?: number;
69
+ /**
70
+ * Drives to mount on the sandbox. Drives are looked up or created while the
71
+ * sandbox is being created (a few at a time), in the region the request sends
72
+ * (or, without one, in the region the sandbox gets), and each is mounted as
73
+ * soon as the sandbox and that drive are ready. On failure, unnamed drives
74
+ * this call created and did not mount are deleted; named ones are kept (a
75
+ * concurrent call may be using them) and listed in the error. If drive setup
76
+ * fails the sandbox is kept and a SandboxDriveSetupError is thrown; if the
77
+ * sandbox cannot be created, its error is thrown (wrapped in a
78
+ * SandboxDriveSetupError only if a drive created for it is left in place).
79
+ */
80
+ mountDrives?: SandboxDriveMountConfiguration[];
69
81
  };
82
+ export { SandboxDriveSetupError } from "./mount-drives.js";
70
83
  /**
71
84
  * Thrown by SandboxInstance.create / createIfNotExists when the sandbox was
72
85
  * not ready within the creation deadline. The control plane has released the
@@ -188,6 +201,12 @@ export declare class SandboxInstance {
188
201
  interval?: number;
189
202
  }): Promise<this>;
190
203
  static create(sandbox?: SandboxModel | SandboxCreateConfiguration, options?: SandboxCreateOptions): Promise<SandboxInstance>;
204
+ /**
205
+ * The creation request body, with the SDK's defaults (image, memory, region from BL_REGION) applied,
206
+ * plus what the caller explicitly asked for (before defaults).
207
+ */
208
+ private static toSandboxModel;
209
+ private static createSandboxInstance;
191
210
  /**
192
211
  * Archive a sandbox: keep its filesystem, stop the sandbox.
193
212
  *
@@ -276,6 +295,7 @@ export declare class SandboxInstance {
276
295
  * `options` (e.g. `timeout`) are forwarded to `create`.
277
296
  */
278
297
  static createIfNotExists(sandbox: SandboxModel | SandboxCreateConfiguration, options?: Omit<SandboxCreateOptions, "createIfNotExist">): Promise<SandboxInstance>;
298
+ private static warnOnDrift;
279
299
  private static waitAfterCreateGatewayTimeout;
280
300
  static fromSession(session: SessionWithToken): Promise<SandboxInstance>;
281
301
  }
@@ -1,4 +1,5 @@
1
1
  import type http2 from "http2";
2
+ import type { DriveCreateConfiguration } from "../drive/index.js";
2
3
  import { Port, Sandbox, SandboxLifecycle, VolumeAttachment, SandboxNetwork } from "../client/types.gen";
3
4
  import { PostProcessResponse, ProcessRequest } from "./client";
4
5
  export interface SessionCreateOptions {
@@ -38,6 +39,21 @@ export type SandboxUpdateMetadata = {
38
39
  export type SandboxUpdateNetwork = {
39
40
  network?: SandboxNetwork;
40
41
  };
42
+ /**
43
+ * A drive to mount on the sandbox: `driveName` for a drive that already
44
+ * exists, `create` for a new one. New drives are created in the sandbox's region.
45
+ */
46
+ export type SandboxDriveMountConfiguration = {
47
+ mountPath: string;
48
+ drivePath?: string;
49
+ readOnly?: boolean;
50
+ } & ({
51
+ driveName: string;
52
+ create?: never;
53
+ } | {
54
+ create: Omit<DriveCreateConfiguration, "region">;
55
+ driveName?: never;
56
+ });
41
57
  export type SandboxPreviewCreateConfiguration = {
42
58
  port: number;
43
59
  /** Defaults to preview-<port> within this sandbox. */