withruntime 0.7.2 → 0.8.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.
@@ -0,0 +1,781 @@
1
+ import { RuntimeError } from "../errors.js";
2
+ import { clientFor, defaultRegion, } from "./client.js";
3
+ import { checkEnvNames, envFileCommand, envFileLines, parseProcessLine, toRuntimePath, } from "./context.js";
4
+ import { codeOf, guard, NotSupportedError, responseError, translate } from "./errors.js";
5
+ import { SandboxFileSystem } from "./filesystem.js";
6
+ import { ARCHIVED, DISPLAY_NAME, EXTERNAL_ID, IDLE_PAUSE, LABEL, findByExternalId, findSandbox, LEASE_SECONDS, ownLabels, STANDBY, userLabels, whenNameFree, } from "./lookup.js";
7
+ import { paginate } from "./pagination.js";
8
+ import { portAccess, SandboxPreviews } from "./preview.js";
9
+ import { SandboxProcess } from "./process.js";
10
+ import { appendEnvs, envRecord, KEEP_DAYS, refuseApplication, SandboxSnapshotsResource, startFromSnapshot, } from "./snapshot.js";
11
+ import { normalizeEnvs, normalizePorts, normalizeVolumes, } from "./types.js";
12
+ import { CODEGEN, DRIVES, SCHEDULES, SESSIONS, SYSTEM, unsupportedPart, } from "./unsupported.js";
13
+ /* Blaxel's SandboxInstance over a Runtime sandbox. Every call is one or a few
14
+ calls of the withruntime SDK; nothing here speaks HTTP. What Runtime cannot
15
+ do the way Blaxel does throws NotSupportedError before anything happens.
16
+
17
+ A Blaxel sandbox goes to standby by itself when unused and resumes on the
18
+ next call, and lives until deleted or until its TTL. On Runtime it pauses
19
+ after the shortest idle time Runtime allows (60 s), wakes on the next call,
20
+ pauses rather than ends when its lease runs out, and a paused one is kept
21
+ 365 days unless a TTL says otherwise: never deleted before Blaxel would
22
+ delete it (BLAXEL.md, "Standby and lifetime"). */
23
+ /** Blaxel's defaults (checked 27 September 2026). */
24
+ export const DEFAULT_IMAGE = "blaxel/base-image:latest";
25
+ export const DEFAULT_MEMORY = 4096;
26
+ /** Blaxel gives a sandbox one vCPU per 2048 MB of memory. */
27
+ export const MEMORY_PER_VCPU = 2048;
28
+ const MAX_VCPU = 16;
29
+ /** Blaxel's stock images whose tools Runtime's stock image has (Node.js,
30
+ * Python, a shell, Docker after `sudo enable-docker`), and the Jupyter server
31
+ * that CodeInterpreter uses, whose work Runtime's interpreter does. */
32
+ const STOCK_IMAGE = /^(docker\.io\/)?blaxel\/(prod-)?(base|base-image|py-app|ts-app|node|jupyter-server|docker-in-sandbox)(:latest)?$/;
33
+ /** Blaxel regions in the United States, where Runtime runs. */
34
+ const US_REGIONS = new Set(["us-pdx-1", "us-was-1", "auto"]);
35
+ const REGION = "us-was-1";
36
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
37
+ const DAY_MS = 86_400_000;
38
+ /** The lease is renewed in the background once this little of it is left. */
39
+ const RENEW_BELOW_MS = 10 * 60_000;
40
+ /** The labels a new sandbox carries: the caller's, and under `blaxel/` what
41
+ * Blaxel keeps and Runtime has no field for, so any client (the Python
42
+ * adapter too) reads the same `spec`. */
43
+ export function labelsFor(config, image) {
44
+ const ports = normalizePorts(config.ports);
45
+ const mine = {
46
+ image,
47
+ memory: String(config.memory || DEFAULT_MEMORY),
48
+ ...(config.externalId ? { externalId: config.externalId } : {}),
49
+ ...(ports ? { ports: ports.map((port) => port.target).join(",") } : {}),
50
+ ...(config.region ? { region: config.region } : {}),
51
+ ...(config.ttl ? { ttl: config.ttl } : {}),
52
+ ...(config.expires ? { expires: config.expires.toISOString() } : {}),
53
+ ...(config.lifecycle ? { lifecycle: JSON.stringify(config.lifecycle) } : {}),
54
+ };
55
+ return {
56
+ ...Object.fromEntries(Object.entries(config.labels ?? {}).map(([k, v]) => [k, String(v)])),
57
+ ...Object.fromEntries(Object.entries(mine).map(([k, v]) => [`${LABEL}${k}`, v])),
58
+ };
59
+ }
60
+ /** Port accesses for `sandbox.fetch`, by sandbox and port. */
61
+ const accesses = new Map();
62
+ /** A duration as Blaxel writes it ("30m", "24h", "7d", "1w", "1h30m"). */
63
+ export function durationMs(value) {
64
+ const match = /^(?:\d+[smhdw])+$/.exec(value.trim());
65
+ if (!match)
66
+ throw responseError(400, `invalid duration "${value}": use units s, m, h, d or w`);
67
+ const unit = { s: 1000, m: 60_000, h: 3_600_000, d: DAY_MS, w: 7 * DAY_MS };
68
+ let total = 0;
69
+ for (const [, count, letter] of value.matchAll(/(\d+)([smhdw])/g))
70
+ total += Number(count) * unit[letter];
71
+ return total;
72
+ }
73
+ /** The lease and the paused retention that honour Blaxel's TTL, expiry and
74
+ * lifecycle: never deleted before Blaxel would delete it. A fixed deadline
75
+ * within the hour is a lease that ends the sandbox; any other deadline keeps
76
+ * a paused sandbox that many days, rounded up; none keeps it 365 days. */
77
+ export function lifetimeOf(asked) {
78
+ const now = Date.now();
79
+ const fixed = [];
80
+ const idle = [];
81
+ if (asked.ttl)
82
+ fixed.push(durationMs(asked.ttl));
83
+ if (asked.expires)
84
+ fixed.push(asked.expires.getTime() - now);
85
+ for (const policy of asked.lifecycle?.expirationPolicies ?? []) {
86
+ if (policy.action !== undefined && policy.action !== "delete")
87
+ throw new NotSupportedError(`The expiration action ${String(policy.action)}`, 'Use action "delete", the only one Blaxel defines.');
88
+ if (!policy.value)
89
+ throw responseError(400, "an expiration policy needs a value");
90
+ if (policy.type === "ttl-max-age")
91
+ fixed.push(durationMs(policy.value));
92
+ else if (policy.type === "ttl-idle")
93
+ idle.push(durationMs(policy.value));
94
+ else if (policy.type === "date") {
95
+ const at = Date.parse(policy.value);
96
+ if (Number.isNaN(at))
97
+ throw responseError(400, `invalid date "${policy.value}"`);
98
+ fixed.push(at - now);
99
+ }
100
+ else
101
+ throw responseError(400, `unknown expiration policy type ${String(policy.type)}`);
102
+ }
103
+ if (fixed.some((ms) => ms <= 0))
104
+ throw responseError(400, "the sandbox's expiry is in the past");
105
+ const soonest = fixed.length ? Math.min(...fixed) : Infinity;
106
+ const all = [...fixed, ...idle];
107
+ const days = all.length
108
+ ? Math.min(365, Math.max(1, Math.ceil(Math.min(...all) / DAY_MS)))
109
+ : KEEP_DAYS;
110
+ return soonest <= LEASE_SECONDS * 1000
111
+ ? { timeoutSeconds: Math.max(60, Math.ceil(soonest / 1000)), onLeaseEnd: "stop", days }
112
+ : { timeoutSeconds: LEASE_SECONDS, onLeaseEnd: "pause", days };
113
+ }
114
+ /** Blaxel's network settings as Runtime's rules: allowed and forbidden
115
+ * domains, from the network itself or from its proxy (Blaxel's current
116
+ * place for them). Anything else of the proxy is refused. */
117
+ export function networkRules(network) {
118
+ for (const [field, alternative] of [
119
+ [
120
+ "egress",
121
+ "Runtime's outbound traffic leaves from Runtime's addresses; allow destinations with allowedDomains.",
122
+ ],
123
+ ["firewall", "Allow destinations with allowedDomains and refuse them with forbiddenDomains."],
124
+ ["subnet", "Runtime chooses each sandbox's addresses."],
125
+ ])
126
+ if (network[field] !== undefined)
127
+ throw new NotSupportedError(`A sandbox network's ${field}`, alternative);
128
+ const proxy = (network.proxy ?? {});
129
+ const other = Object.keys(proxy).find((key) => key !== "allowedDomains" && key !== "forbiddenDomains");
130
+ if (other !== undefined)
131
+ throw new NotSupportedError(`A network proxy's ${other}`, "To send credentials to a host without the sandbox seeing them, use Runtime secrets (runtime.secrets): the egress proxy adds them on HTTPS to the hosts you name.");
132
+ const allow = [...(network.allowedDomains ?? []), ...(proxy.allowedDomains ?? [])];
133
+ const deny = [...(network.forbiddenDomains ?? []), ...(proxy.forbiddenDomains ?? [])];
134
+ return { internet: true, ...(allow.length ? { allow } : {}), ...(deny.length ? { deny } : {}) };
135
+ }
136
+ /** A Blaxel create in either of its shapes (a configuration, or a Sandbox
137
+ * model with metadata and spec) as one configuration. */
138
+ function configOf(input) {
139
+ if (!input)
140
+ return {};
141
+ if (!("metadata" in input) && !("spec" in input))
142
+ return input;
143
+ const model = input;
144
+ const runtime = model.spec?.runtime ?? {};
145
+ const config = {};
146
+ if (model.metadata?.name)
147
+ config.name = model.metadata.name;
148
+ if (model.metadata?.labels)
149
+ config.labels = model.metadata.labels;
150
+ if (model.metadata?.externalId)
151
+ config.externalId = model.metadata.externalId;
152
+ if (runtime.image)
153
+ config.image = runtime.image;
154
+ if (runtime.memory)
155
+ config.memory = runtime.memory;
156
+ if (runtime.ports)
157
+ config.ports = runtime.ports;
158
+ if (runtime.envs)
159
+ config.envs = runtime.envs.map((env) => ({ name: env.name ?? "", value: env.value ?? "" }));
160
+ if (runtime.ttl)
161
+ config.ttl = runtime.ttl;
162
+ if (runtime.expires)
163
+ config.expires = new Date(runtime.expires);
164
+ if (runtime.extraArgs)
165
+ config.extraArgs = runtime.extraArgs;
166
+ if (model.spec?.region)
167
+ config.region = model.spec.region;
168
+ if (model.spec?.lifecycle)
169
+ config.lifecycle = model.spec.lifecycle;
170
+ if (model.spec?.network)
171
+ config.network = model.spec.network;
172
+ if (model.spec?.volumes)
173
+ config.volumes = model.spec.volumes;
174
+ if (model.withruntime)
175
+ config.withruntime = model.withruntime;
176
+ return config;
177
+ }
178
+ function checkRegion(region) {
179
+ if (region !== undefined && region !== "" && !US_REGIONS.has(region))
180
+ throw new NotSupportedError(`The region ${region}`, "Runtime runs in one US region: remove region (and BL_REGION), or use us-was-1.");
181
+ }
182
+ /** A Blaxel image `ns/name:tag` as the Runtime image name and tag it maps to
183
+ * (Runtime names allow no "/"): every "/" becomes "-" (`ns-name`), and the
184
+ * tag is `latest` when none is given. The Python adapter maps the same way. */
185
+ export function imageRef(image) {
186
+ const slash = image.lastIndexOf("/");
187
+ const colon = image.lastIndexOf(":");
188
+ const [name, tag] = colon > slash ? [image.slice(0, colon), image.slice(colon + 1)] : [image, ""];
189
+ return { name: name.replaceAll("/", "-"), tag: tag || "latest" };
190
+ }
191
+ /** Where an image sends the create: Runtime's stock image, or the ready
192
+ * Runtime image `imageRef` maps it to. */
193
+ async function resolveImage(client, image) {
194
+ if (image === undefined || STOCK_IMAGE.test(image))
195
+ return {};
196
+ if (UUID.test(image))
197
+ return { image };
198
+ const { name, tag } = imageRef(image);
199
+ let found;
200
+ try {
201
+ found = await client.images.resolve(`${name}:${tag}`);
202
+ }
203
+ catch (error) {
204
+ // Not an image, or a name Runtime could not hold: the same answer.
205
+ const status = error instanceof RuntimeError ? error.status : undefined;
206
+ if (status !== 400 && status !== 404)
207
+ throw translate(error);
208
+ }
209
+ if (found && found.state === "ready")
210
+ return { image: found.id };
211
+ const feature = `The image ${image}, which is not a Runtime image`;
212
+ const alternative = `Build it as a Runtime image named ${name}: \`npx withruntime image build --dockerfile Dockerfile --name ${name} -t ${name}:${tag}\`. The code can keep "${image}": the adapter starts from ${name}:${tag}.`;
213
+ throw new NotSupportedError(feature, alternative, `${feature}, is not supported on Runtime. ${alternative}`);
214
+ }
215
+ /** Blaxel volumes (by name) as Runtime volumes. */
216
+ async function resolveVolumes(client, volumes) {
217
+ if (!volumes?.length)
218
+ return [];
219
+ for (const volume of volumes) {
220
+ if (volume.type === "ephemeral")
221
+ throw new NotSupportedError("Ephemeral volumes", "Write scratch files under /workspace (or /tmp); they go with the sandbox.");
222
+ if (volume.readOnly)
223
+ throw new NotSupportedError("Read-only volume mounts", 'Mount it read-write, or give the sandbox a copy with withruntime: { create: { volumes: [{ volumeId, path, mode: "snapshot" }] } }.');
224
+ }
225
+ return Promise.all(volumes.map(async (volume) => {
226
+ const found = (await guard(() => client.volumes.list({ name: volume.name }))).data[0];
227
+ if (!found)
228
+ throw new NotSupportedError(`The volume ${volume.name}, which is not a Runtime volume`, `Create it first: runtime.volumes.create({ name: "${volume.name}" }) or \`npx withruntime volume create --name ${volume.name}\`.`);
229
+ return { volumeId: found.id, path: toRuntimePath(volume.mountPath) };
230
+ }));
231
+ }
232
+ function statusOf(state) {
233
+ switch (state) {
234
+ case "starting":
235
+ return "DEPLOYING";
236
+ case "stopping":
237
+ return "DELETING";
238
+ case "stopped":
239
+ return "TERMINATED";
240
+ default:
241
+ return "DEPLOYED";
242
+ }
243
+ }
244
+ /** A sandbox with Blaxel's methods. `sandbox.withruntime` is the Runtime
245
+ * sandbox underneath, for anything Blaxel has no name for. */
246
+ export class SandboxInstance {
247
+ fs;
248
+ network;
249
+ process;
250
+ previews;
251
+ snapshots;
252
+ sessions = unsupportedPart("sandbox.sessions", SESSIONS);
253
+ schedules = unsupportedPart("sandbox.schedules", SCHEDULES);
254
+ codegen = unsupportedPart("sandbox.codegen", CODEGEN);
255
+ system = unsupportedPart("sandbox.system", SYSTEM);
256
+ drives = unsupportedPart("sandbox.drives", DRIVES);
257
+ /** Blaxel's HTTP/2 session; Runtime's SDK keeps its own connections. */
258
+ h2Session = null;
259
+ #rt;
260
+ #client;
261
+ #asked;
262
+ #deleted = false;
263
+ #renewing;
264
+ /** Use SandboxInstance.create, createIfNotExists or get. */
265
+ constructor(resolved) {
266
+ this.#rt = resolved.runtime;
267
+ this.#client = resolved.client;
268
+ this.#asked = resolved.asked ?? {};
269
+ const run = (work) => this.#run(work);
270
+ this.fs = new SandboxFileSystem({ run });
271
+ this.process = new SandboxProcess({
272
+ id: this.#rt.id,
273
+ live: () => this.#live(),
274
+ wake: () => this.#wake(),
275
+ keepAwake: (seconds) => this.#keepAwake(seconds),
276
+ keepAliveEnded: () => this.#giveBackIdle(),
277
+ });
278
+ this.previews = new SandboxPreviews({
279
+ sandboxName: this.name,
280
+ run,
281
+ labels: () => this.#rt.info.labels,
282
+ setLabels: (changes) => this.#setLabels(changes),
283
+ });
284
+ this.snapshots = new SandboxSnapshotsResource({
285
+ sandboxName: this.name,
286
+ sandboxId: this.#rt.id,
287
+ client: this.#client,
288
+ run,
289
+ labels: () => this.#rt.info.labels,
290
+ });
291
+ this.network = { fetch: (port, path, init) => this.fetch(port, path, init) };
292
+ }
293
+ /** The Runtime sandbox underneath: metrics, network rules, desktop,
294
+ * terminals and the rest of Runtime's SDK. */
295
+ get withruntime() {
296
+ return this.#rt;
297
+ }
298
+ /** The sandbox's name, or its Runtime id when it was made without one. */
299
+ get name() {
300
+ return this.#rt.info.name ?? this.#rt.id;
301
+ }
302
+ get metadata() {
303
+ const info = this.#rt.info;
304
+ const labels = info.labels ?? {};
305
+ return {
306
+ name: this.name,
307
+ labels: userLabels(labels),
308
+ ...(labels[DISPLAY_NAME] ? { displayName: labels[DISPLAY_NAME] } : {}),
309
+ ...(labels[EXTERNAL_ID] ? { externalId: labels[EXTERNAL_ID] } : {}),
310
+ createdAt: info.createdAt,
311
+ updatedAt: info.readyAt ?? info.createdAt,
312
+ };
313
+ }
314
+ /** From the sandbox's labels, so every client sees the same; the envs,
315
+ * network and volumes only in the client that created it. */
316
+ get spec() {
317
+ const asked = this.#asked;
318
+ const labels = this.#rt.info.labels ?? {};
319
+ const own = (key) => labels[`${LABEL}${key}`];
320
+ const image = own("image") ??
321
+ (typeof this.#rt.info.image === "string" ? this.#rt.info.image : DEFAULT_IMAGE);
322
+ const ports = (own("ports") ?? "")
323
+ .split(",")
324
+ .filter(Boolean)
325
+ .map((port) => ({ target: Number(port), protocol: "HTTP" }));
326
+ const lifecycle = own("lifecycle");
327
+ const region = own("region");
328
+ return {
329
+ enabled: true,
330
+ region: region && region !== "auto" ? region : REGION,
331
+ runtime: {
332
+ image,
333
+ memory: this.#rt.info.memoryMiB,
334
+ ...(ports.length ? { ports } : {}),
335
+ ...(asked.envs
336
+ ? { envs: Object.entries(asked.envs).map(([name, value]) => ({ name, value })) }
337
+ : {}),
338
+ ...(own("ttl") ? { ttl: own("ttl") } : {}),
339
+ ...(own("expires") ? { expires: own("expires") } : {}),
340
+ },
341
+ ...(lifecycle ? { lifecycle: JSON.parse(lifecycle) } : {}),
342
+ ...(asked.network ? { network: asked.network } : {}),
343
+ ...(asked.volumes ? { volumes: asked.volumes } : {}),
344
+ };
345
+ }
346
+ get status() {
347
+ const state = this.#rt.state;
348
+ if ((state === "paused" || state === "pausing") && this.#rt.info.labels?.[ARCHIVED] === "1")
349
+ return "ARCHIVED";
350
+ return statusOf(state);
351
+ }
352
+ /** RUNNING, or STANDBY for a paused sandbox. */
353
+ get state() {
354
+ const state = this.#rt.state;
355
+ if (state === "paused" || state === "pausing")
356
+ return "STANDBY";
357
+ if (state === "running" || state === "starting" || state === "resuming")
358
+ return "RUNNING";
359
+ return undefined;
360
+ }
361
+ get events() {
362
+ return [];
363
+ }
364
+ get errors() {
365
+ return [];
366
+ }
367
+ get lastUsedAt() {
368
+ return this.#rt.info.lastActiveAt ?? undefined;
369
+ }
370
+ get h2Domain() {
371
+ return null;
372
+ }
373
+ /** Seconds until Runtime deletes the sandbox: the end of a lease that ends
374
+ * it, or of a paused one's retention; undefined while not yet known. */
375
+ get expiresIn() {
376
+ const info = this.#rt.info;
377
+ const left = (at) => Math.max(0, Math.floor((Date.parse(at) - Date.now()) / 1000));
378
+ if (info.state === "paused" && info.pausedExpiresAt)
379
+ return left(info.pausedExpiresAt);
380
+ if ((info.state === "running" || info.state === "starting" || info.state === "resuming") &&
381
+ info.onLeaseEnd === "stop")
382
+ return left(info.expiresAt);
383
+ return undefined;
384
+ }
385
+ #model(status) {
386
+ const state = this.state;
387
+ return {
388
+ metadata: this.metadata,
389
+ spec: this.spec,
390
+ status: status ?? this.status,
391
+ ...(state ? { state } : {}),
392
+ events: [],
393
+ };
394
+ }
395
+ // ---- the live sandbox ---------------------------------------------------
396
+ /** The sandbox, ready for a call. A paused one wakes by itself on the call
397
+ * (autoWake); one made without autoWake is woken here. A lease nearing its
398
+ * end is renewed in the background, so a sandbox in use is not paused by
399
+ * its lease. */
400
+ async #live() {
401
+ if (this.#deleted)
402
+ throw responseError(404, `Sandbox ${this.name} was deleted.`);
403
+ const rt = this.#rt;
404
+ const state = rt.state;
405
+ if ((state === "paused" || state === "pausing") && rt.info.autoWake === false)
406
+ await this.#wake();
407
+ else if (state === "running" &&
408
+ rt.info.onLeaseEnd === "pause" &&
409
+ !this.#renewing &&
410
+ Date.parse(rt.info.expiresAt) - Date.now() < RENEW_BELOW_MS)
411
+ this.#renewing = rt
412
+ .extend(LEASE_SECONDS)
413
+ .catch(() => undefined)
414
+ .finally(() => (this.#renewing = undefined));
415
+ return this.#rt;
416
+ }
417
+ async #wake() {
418
+ await guard(() => this.#rt.wake({}));
419
+ }
420
+ /** Runs `work` on the live sandbox. A call refused because the sandbox was
421
+ * paused (autoWake off) never started, so it is made again once, after a
422
+ * wake. */
423
+ async #run(work) {
424
+ const runtime = await this.#live();
425
+ try {
426
+ return await work(runtime);
427
+ }
428
+ catch (error) {
429
+ if (codeOf(error) !== "sandbox_paused")
430
+ throw translate(error);
431
+ await this.#wake();
432
+ return guard(() => work(this.#rt));
433
+ }
434
+ }
435
+ /** Keeps the sandbox awake while a keepAlive process runs: its idle pause
436
+ * becomes the process's time limit (off for one without), and its lease
437
+ * covers that time, up to Runtime's hour. The idle pause it had is kept in
438
+ * the `blaxel/idlePauseSeconds` label, written in the same update and only
439
+ * when absent, to be given back once no keepAlive process runs. */
440
+ async #keepAwake(seconds) {
441
+ const rt = await this.#live();
442
+ const forever = seconds >= 86_400;
443
+ const idle = forever ? 0 : Math.max(STANDBY.idlePauseSeconds, Math.ceil(seconds));
444
+ const current = rt.info.idlePauseSeconds;
445
+ const raise = current !== 0 && (idle === 0 || current === undefined || idle > current);
446
+ const labels = rt.info.labels ?? {};
447
+ const need = Math.min(LEASE_SECONDS, forever ? LEASE_SECONDS : seconds) -
448
+ (Date.parse(rt.info.expiresAt) - Date.now()) / 1000;
449
+ await Promise.all([
450
+ raise
451
+ ? guard(() => rt.update({
452
+ idlePauseSeconds: idle,
453
+ labels: { ...labels, [IDLE_PAUSE]: labels[IDLE_PAUSE] ?? String(current ?? 0) },
454
+ }))
455
+ : undefined,
456
+ rt.info.onLeaseEnd === "pause" && need >= 1
457
+ ? rt.extend(Math.ceil(need)).catch(() => undefined)
458
+ : undefined,
459
+ ]);
460
+ }
461
+ /** Gives the sandbox back the idle pause it had before keepAlive raised it,
462
+ * once no keepAlive process runs, so no idle time is paid for after: the
463
+ * rule the Python adapter follows too. The label `blaxel/idlePauseSeconds`
464
+ * holds the idle pause to give back; a keepAlive process is one whose
465
+ * command line carries ": rt-blaxel-keep". Any client does it when it sees
466
+ * a keepAlive process end (a waited exec, wait, get), after stop or kill,
467
+ * and in SandboxInstance.get. With no label it costs nothing; a failure is
468
+ * left for the next of those, never thrown. */
469
+ async #giveBackIdle() {
470
+ const rt = this.#rt;
471
+ const labels = rt.info.labels ?? {};
472
+ const saved = labels[IDLE_PAUSE];
473
+ if (saved === undefined || this.#deleted)
474
+ return;
475
+ try {
476
+ const running = (await rt.processes.list()).some((one) => one.state === "running" && parseProcessLine(one.command)?.keepAlive);
477
+ if (running)
478
+ return;
479
+ const rest = { ...labels };
480
+ delete rest[IDLE_PAUSE];
481
+ await rt.update({ idlePauseSeconds: Number(saved), labels: rest });
482
+ }
483
+ catch {
484
+ // Given back at the next chance.
485
+ }
486
+ }
487
+ async #setLabels(changes) {
488
+ const labels = { ...this.#rt.info.labels };
489
+ for (const [key, value] of Object.entries(changes))
490
+ if (value === undefined)
491
+ delete labels[key];
492
+ else
493
+ labels[key] = value;
494
+ await guard(() => this.#rt.update({ labels }));
495
+ }
496
+ /** Sets the sandbox up after a create: its envs, in a file every process
497
+ * reads, and (paid sandboxes) how long a paused one is kept. */
498
+ async #setUp(envs, days) {
499
+ const rt = this.#rt;
500
+ await Promise.all([
501
+ Object.keys(envs).length
502
+ ? guard(() => rt.exec(envFileCommand(false), { stdin: envFileLines(envs) })).then((result) => {
503
+ if (result.exitCode !== 0)
504
+ throw responseError(500, `Setting the sandbox's envs failed: ${result.stderr.trim()}`);
505
+ })
506
+ : undefined,
507
+ rt.info.funding === "paid" ? guard(() => rt.setRetention(days)) : undefined,
508
+ ]);
509
+ }
510
+ // ---- create, get, list ---------------------------------------------------
511
+ /** Creates a sandbox and answers once it runs. Blaxel's defaults: the base
512
+ * image (Runtime's stock image) and 4096 MB, with vCPUs at one per 2048 MB.
513
+ * Funding is left to Runtime: the free trial while the account has trial
514
+ * time, then prepaid credit. A name another live sandbox holds is refused
515
+ * with a 409, as in Blaxel. */
516
+ static async create(sandbox, { createIfNotExist = false } = {}) {
517
+ const config = configOf(sandbox);
518
+ const region = config.region || defaultRegion();
519
+ checkRegion(region);
520
+ for (const key of Object.keys(config.extraArgs ?? {}))
521
+ if (key !== "iptables")
522
+ throw new NotSupportedError(`The extra argument ${key}`, "Remove it; iptables (iptables-legacy) is always there on Runtime.");
523
+ const envs = Object.fromEntries((normalizeEnvs(config.envs) ?? []).map((env) => [env.name, env.value]));
524
+ checkEnvNames(Object.keys(envs));
525
+ normalizePorts(config.ports);
526
+ const volumes = normalizeVolumes(config.volumes);
527
+ const lifetime = lifetimeOf(config);
528
+ const network = config.network ? networkRules(config.network) : undefined;
529
+ const memory = config.memory || DEFAULT_MEMORY;
530
+ const client = clientFor(config);
531
+ const [image, mounts] = await Promise.all([
532
+ guard(() => resolveImage(client, config.image)),
533
+ resolveVolumes(client, volumes),
534
+ ]);
535
+ const labels = labelsFor(config, config.image ?? DEFAULT_IMAGE);
536
+ const input = {
537
+ vcpu: Math.min(MAX_VCPU, Math.max(1, Math.round(memory / MEMORY_PER_VCPU))),
538
+ memoryMiB: memory,
539
+ ...STANDBY,
540
+ timeoutSeconds: lifetime.timeoutSeconds,
541
+ onLeaseEnd: lifetime.onLeaseEnd,
542
+ ...(config.name ? { name: config.name } : {}),
543
+ labels,
544
+ ...(network ? { network } : {}),
545
+ ...(mounts.length ? { volumes: mounts } : {}),
546
+ ...image,
547
+ ...(createIfNotExist && config.name ? { getOrCreate: true } : {}),
548
+ ...config.withruntime?.create,
549
+ };
550
+ const runtime = await guard(() => whenNameFree(client, () => client.sandboxes.create(input)));
551
+ const asked = {
552
+ ...(Object.keys(envs).length ? { envs } : {}),
553
+ ...(config.network ? { network: config.network } : {}),
554
+ ...(volumes ? { volumes } : {}),
555
+ };
556
+ // A sandbox that already held the name is answered as it is.
557
+ if (runtime.info.reused)
558
+ return new this({ runtime, client });
559
+ const instance = new this({ runtime, client, asked });
560
+ try {
561
+ await instance.#setUp(envs, lifetime.days);
562
+ }
563
+ catch (error) {
564
+ await runtime.stop({ wait: false }).catch(() => undefined);
565
+ throw translate(error);
566
+ }
567
+ return instance;
568
+ }
569
+ /** The named sandbox, or a new one when no live sandbox has the name.
570
+ * Runtime settles a race between two callers: both get the same sandbox. */
571
+ static async createIfNotExists(sandbox) {
572
+ return this.create(sandbox, { createIfNotExist: true });
573
+ }
574
+ /** The live sandbox with this name (or Runtime id). It is not woken: the
575
+ * next call wakes it. */
576
+ static async get(sandboxName, options = {}) {
577
+ const client = clientFor(options);
578
+ const runtime = await guard(() => findSandbox(client, sandboxName));
579
+ const sandbox = new this({ runtime, client });
580
+ await sandbox.#giveBackIdle();
581
+ return sandbox;
582
+ }
583
+ /** The newest live sandbox created with this externalId. */
584
+ static async getByExternalId(externalId, options = {}) {
585
+ const client = clientFor(options);
586
+ const runtime = await guard(() => findByExternalId(client, externalId));
587
+ return new this({ runtime, client });
588
+ }
589
+ /** Live sandboxes, oldest first, a page at a time; `for await` walks them
590
+ * all. `externalId` narrows; `showTerminated` adds ended ones. */
591
+ static async list(query = {}, options = {}) {
592
+ for (const field of ["cursor", "q", "anchor"])
593
+ if (query[field] !== undefined)
594
+ throw new NotSupportedError(`Listing sandboxes by ${field}`, "List them (oldest first) and filter the result yourself; nextPage() walks the pages.");
595
+ if (query.sort !== undefined && query.sort !== "createdAt:asc")
596
+ throw new NotSupportedError(`Listing sandboxes sorted by ${query.sort}`, "Runtime lists them oldest first: sort the result yourself.");
597
+ if (query.status !== undefined && query.status !== "DEPLOYED")
598
+ throw new NotSupportedError(`Listing sandboxes by status ${query.status}`, "List them and filter by sandbox.status yourself.");
599
+ const client = clientFor(options);
600
+ const page = await guard(() => client.sandboxes.list({
601
+ ...(query.externalId ? { labels: { [EXTERNAL_ID]: query.externalId } } : {}),
602
+ ...(query.showTerminated ? { includeStopped: true } : {}),
603
+ ...(query.limit ? { limit: Math.min(query.limit, 100) } : {}),
604
+ }));
605
+ return paginate(page, (runtime) => new this({ runtime, client }));
606
+ }
607
+ /** Deletes the sandbox: it ends at once, its files and memory with it. */
608
+ static async delete(sandboxName, options = {}) {
609
+ return (await this.get(sandboxName, options)).delete();
610
+ }
611
+ async delete() {
612
+ if (this.#deleted)
613
+ throw responseError(404, `Sandbox ${this.name} was deleted.`);
614
+ await guard(() => this.#rt.stop({ wait: false }));
615
+ this.#deleted = true;
616
+ for (const key of accesses.keys())
617
+ if (key.startsWith(`${this.#rt.id}:`))
618
+ accesses.delete(key);
619
+ return this.#model("DELETING");
620
+ }
621
+ // ---- updates -------------------------------------------------------------
622
+ /** Replaces the labels (when given), the display name and the externalId. */
623
+ static async updateMetadata(sandboxName, metadata, options = {}) {
624
+ const sandbox = await this.get(sandboxName, options);
625
+ const current = sandbox.#rt.info.labels;
626
+ const labels = {
627
+ ...(metadata.labels ?? userLabels(current)),
628
+ ...ownLabels(current),
629
+ ...(metadata.displayName !== undefined ? { [DISPLAY_NAME]: metadata.displayName } : {}),
630
+ ...(metadata.externalId !== undefined ? { [EXTERNAL_ID]: metadata.externalId } : {}),
631
+ };
632
+ await guard(() => sandbox.#rt.update({ labels }));
633
+ return sandbox;
634
+ }
635
+ /** A new TTL (null: none): how long a paused sandbox is kept follows it, as
636
+ * at create. A running lease is not shortened. */
637
+ static async updateTtl(sandboxName, ttl, options = {}) {
638
+ const sandbox = await this.get(sandboxName, options);
639
+ const labels = sandbox.#rt.info.labels ?? {};
640
+ const lifetime = lifetimeOf({
641
+ ttl: ttl || null,
642
+ ...(labels[`${LABEL}expires`] ? { expires: new Date(labels[`${LABEL}expires`]) } : {}),
643
+ ...(labels[`${LABEL}lifecycle`]
644
+ ? { lifecycle: JSON.parse(labels[`${LABEL}lifecycle`]) }
645
+ : {}),
646
+ });
647
+ await Promise.all([
648
+ sandbox.#setLabels({ [`${LABEL}ttl`]: ttl || undefined }),
649
+ sandbox.#rt.info.funding === "paid"
650
+ ? guard(() => sandbox.#rt.setRetention(lifetime.days))
651
+ : undefined,
652
+ ]);
653
+ return sandbox;
654
+ }
655
+ /** A new lifecycle (null: none), as updateTtl. */
656
+ static async updateLifecycle(sandboxName, lifecycle, options = {}) {
657
+ const sandbox = await this.get(sandboxName, options);
658
+ const labels = sandbox.#rt.info.labels ?? {};
659
+ const lifetime = lifetimeOf({
660
+ lifecycle,
661
+ ...(labels[`${LABEL}ttl`] ? { ttl: labels[`${LABEL}ttl`] } : {}),
662
+ ...(labels[`${LABEL}expires`] ? { expires: new Date(labels[`${LABEL}expires`]) } : {}),
663
+ });
664
+ await Promise.all([
665
+ sandbox.#setLabels({
666
+ [`${LABEL}lifecycle`]: lifecycle ? JSON.stringify(lifecycle) : undefined,
667
+ }),
668
+ sandbox.#rt.info.funding === "paid"
669
+ ? guard(() => sandbox.#rt.setRetention(lifetime.days))
670
+ : undefined,
671
+ ]);
672
+ return sandbox;
673
+ }
674
+ /** Replaces the sandbox's network rules. */
675
+ static async updateNetwork(sandboxName, network, options = {}) {
676
+ const sandbox = await this.get(sandboxName, options);
677
+ const rules = networkRules(network.network ?? {});
678
+ await sandbox.#run((runtime) => runtime.network.set(rules));
679
+ if (network.network)
680
+ sandbox.#asked.network = network.network;
681
+ return sandbox;
682
+ }
683
+ // ---- lifecycle ------------------------------------------------------------
684
+ /** Deprecated in Blaxel: a sandbox is ready when create answers. */
685
+ async wait(_options = {}) {
686
+ return this;
687
+ }
688
+ /** Archives the sandbox: on Runtime it pauses, keeping its files and also
689
+ * its memory and processes. The next call, or unarchive, wakes it. */
690
+ static async archive(sandboxName, options = {}) {
691
+ return (await this.get(sandboxName, options)).archive(options);
692
+ }
693
+ async archive(options = {}) {
694
+ await this.#live();
695
+ await this.#setLabels({ [ARCHIVED]: "1" });
696
+ if (this.#rt.state !== "paused" && this.#rt.state !== "pausing")
697
+ await guard(() => this.#rt.pause({ wait: options.wait !== false }));
698
+ return this;
699
+ }
700
+ static async unarchive(sandboxName, options = {}) {
701
+ return (await this.get(sandboxName, options)).unarchive(options);
702
+ }
703
+ async unarchive(options = {}) {
704
+ await this.#live();
705
+ await this.#setLabels({ [ARCHIVED]: undefined });
706
+ if (this.#rt.state === "paused" || this.#rt.state === "pausing")
707
+ await guard(() => this.#rt.wake({ wait: options.wait !== false }));
708
+ return this;
709
+ }
710
+ // ---- ports -------------------------------------------------------------------
711
+ /** A request to a port inside the sandbox, through a private preview of
712
+ * that port (made on first use, then reused) and its token. */
713
+ async fetch(port, path = "/", init) {
714
+ const key = `${this.#rt.id}:${port}`;
715
+ let access = accesses.get(key);
716
+ if (!access || access.until - Date.now() < 60_000) {
717
+ access = await this.#run((runtime) => portAccess(runtime, port));
718
+ accesses.set(key, access);
719
+ }
720
+ const headers = new Headers(init?.headers);
721
+ if (access.token)
722
+ headers.set("x-runtime-preview-token", access.token);
723
+ return fetch(`${access.url}${path.startsWith("/") ? path : `/${path}`}`, { ...init, headers });
724
+ }
725
+ // ---- snapshots and forks ----------------------------------------------------
726
+ /** Deprecated in Blaxel: sandbox.snapshots.create(name). */
727
+ async snapshot(name) {
728
+ return modelOfSnapshot(await this.snapshots.create(name));
729
+ }
730
+ /** Deprecated in Blaxel: sandbox.snapshots.list(). */
731
+ async listSnapshots() {
732
+ return (await this.snapshots.list()).map(modelOfSnapshot);
733
+ }
734
+ /** Deprecated in Blaxel: sandbox.snapshots.delete(name). */
735
+ async deleteSnapshot(snapshotId) {
736
+ await this.snapshots.delete(snapshotId);
737
+ }
738
+ restore(snapshotId) {
739
+ return this.snapshots.restore(snapshotId);
740
+ }
741
+ /** A new sandbox named `targetName`: a copy of this one as it is now
742
+ * (files, memory and running processes), or started from a snapshot with
743
+ * `snapshotId`. `envs` are added over this sandbox's. */
744
+ async fork(targetName, options = {}) {
745
+ refuseApplication(options);
746
+ const envs = envRecord(options.envs);
747
+ checkEnvNames(Object.keys(envs));
748
+ const labels = userLabels(this.#rt.info.labels);
749
+ if (options.snapshotId !== undefined) {
750
+ const id = await this.snapshots.idOf(options.snapshotId);
751
+ await startFromSnapshot(this.#client, id, targetName, labels, envs);
752
+ return { name: targetName, snapshotId: id, type: "sandbox" };
753
+ }
754
+ const copy = await this.#run((runtime) => whenNameFree(this.#client, () => runtime.fork({ name: targetName, labels })));
755
+ try {
756
+ await Promise.all([
757
+ appendEnvs(copy, envs),
758
+ copy.info.funding === "paid" ? guard(() => copy.setRetention(KEEP_DAYS)) : undefined,
759
+ ]);
760
+ }
761
+ catch (error) {
762
+ await copy.stop({ wait: false }).catch(() => undefined);
763
+ throw error;
764
+ }
765
+ return { name: targetName, snapshotId: "", type: "sandbox" };
766
+ }
767
+ static fromSession(_session) {
768
+ return Promise.reject(new NotSupportedError("Blaxel sessions (SandboxInstance.fromSession)", "Use SandboxInstance.get(name) with a Runtime key; to reach a port from a browser, share it with sandbox.previews.create and a token."));
769
+ }
770
+ }
771
+ function modelOfSnapshot(snapshot) {
772
+ return {
773
+ id: snapshot.id,
774
+ name: snapshot.name,
775
+ status: snapshot.status,
776
+ workspace: snapshot.workspace,
777
+ createdAt: snapshot.createdAt,
778
+ ...(snapshot.source ? { source: snapshot.source, sandboxName: snapshot.source.name } : {}),
779
+ ...(snapshot.spec ? { spec: snapshot.spec } : {}),
780
+ };
781
+ }