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