withruntime 0.11.2 → 0.12.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 +2 -2
- package/COMPATIBILITY.md +74 -9
- package/DAYTONA.md +33 -5
- package/E2B.md +85 -24
- package/README.md +9 -8
- package/README.src.md +8 -7
- package/VERCEL.md +43 -5
- package/compatibility-lock.json +58 -49
- package/dist/billing-cli.d.ts +1 -1
- package/dist/billing-cli.js +77 -1
- package/dist/blaxel/errors.d.ts +14 -0
- package/dist/blaxel/errors.js +29 -0
- package/dist/blaxel/index.d.ts +2 -2
- package/dist/blaxel/index.js +4 -4
- package/dist/blaxel/interpreter.d.ts +2 -5
- package/dist/blaxel/sandbox.d.ts +14 -5
- package/dist/blaxel/sandbox.js +63 -7
- package/dist/blaxel/snapshot.d.ts +4 -0
- package/dist/blaxel/snapshot.js +4 -0
- package/dist/cli/logs.d.ts +21 -0
- package/dist/cli/logs.js +188 -0
- package/dist/cli/logs.test.d.ts +1 -0
- package/dist/cli/logs.test.js +272 -0
- package/dist/cli-extras.d.ts +3 -3
- package/dist/cli-extras.js +17 -12
- package/dist/cli-words.d.ts +10 -1
- package/dist/cli-words.js +18 -0
- package/dist/cli.d.ts +5 -1
- package/dist/cli.js +405 -204
- package/dist/client.d.ts +2 -4
- package/dist/client.js +27 -11
- package/dist/cloudflare/errors.d.ts +143 -0
- package/dist/cloudflare/errors.js +167 -0
- package/dist/cloudflare/index.d.ts +175 -6
- package/dist/cloudflare/index.js +416 -26
- package/dist/cloudflare/interpreter.d.ts +119 -0
- package/dist/cloudflare/interpreter.js +223 -0
- package/dist/cloudflare/streams.d.ts +38 -0
- package/dist/cloudflare/streams.js +145 -0
- package/dist/cloudflare/watch.d.ts +9 -0
- package/dist/cloudflare/watch.js +24 -0
- package/dist/codesandbox/index.d.ts +216 -28
- package/dist/codesandbox/index.js +478 -78
- package/dist/compat/core.d.ts +18 -0
- package/dist/compat/core.js +29 -0
- package/dist/compat/files.d.ts +1 -0
- package/dist/compat/files.js +25 -5
- package/dist/daytona/computer-use.d.ts +13 -4
- package/dist/daytona/computer-use.js +66 -8
- package/dist/daytona/daytona.d.ts +6 -0
- package/dist/daytona/daytona.js +30 -3
- package/dist/daytona/errors.d.ts +11 -0
- package/dist/daytona/errors.js +18 -0
- package/dist/daytona/filesystem.js +2 -1
- package/dist/daytona/image.d.ts +13 -9
- package/dist/daytona/image.js +18 -12
- package/dist/daytona/index.d.ts +1 -1
- package/dist/daytona/sandbox.d.ts +8 -2
- package/dist/daytona/sandbox.js +16 -2
- package/dist/domains/index.d.ts +9 -0
- package/dist/domains/index.js +10 -0
- package/dist/e2b/commands.d.ts +1 -1
- package/dist/e2b/commands.js +6 -7
- package/dist/e2b/errors.d.ts +17 -0
- package/dist/e2b/errors.js +40 -1
- package/dist/e2b/index.d.ts +2 -2
- package/dist/e2b/index.js +1 -1
- package/dist/e2b/sandbox.d.ts +27 -21
- package/dist/e2b/sandbox.js +34 -8
- package/dist/e2b/unsupported.js +1 -1
- package/dist/errors.d.ts +10 -1
- package/dist/errors.js +16 -4
- package/dist/freestyle/index.d.ts +285 -37
- package/dist/freestyle/index.js +656 -144
- package/dist/identity.d.ts +3 -0
- package/dist/identity.js +27 -0
- package/dist/images/index.d.ts +9 -0
- package/dist/images/index.js +10 -0
- package/dist/index.d.ts +1 -0
- package/dist/ips/index.d.ts +9 -0
- package/dist/ips/index.js +10 -0
- package/dist/jobs/index.d.ts +9 -0
- package/dist/jobs/index.js +10 -0
- package/dist/keys.js +2 -0
- package/dist/login.js +2 -0
- package/dist/machine.d.ts +12 -3
- package/dist/machine.js +20 -3
- package/dist/mcp.js +1 -1
- package/dist/modal/index.d.ts +339 -61
- package/dist/modal/index.js +781 -121
- package/dist/network-cli.d.ts +3 -1
- package/dist/network-cli.js +78 -36
- package/dist/networks/index.d.ts +11 -0
- package/dist/networks/index.js +10 -0
- package/dist/ports/index.d.ts +9 -0
- package/dist/ports/index.js +10 -0
- package/dist/product-names.d.ts +52 -0
- package/dist/product-names.js +164 -0
- package/dist/product-renames.d.ts +103 -0
- package/dist/product-renames.js +189 -0
- package/dist/products/apps.d.ts +176 -0
- package/dist/products/apps.js +165 -0
- package/dist/products/billing.d.ts +41 -0
- package/dist/products/billing.js +13 -0
- package/dist/products/browser.d.ts +10 -0
- package/dist/products/browsers.d.ts +71 -0
- package/dist/products/browsers.js +102 -0
- package/dist/products/builds.d.ts +126 -0
- package/dist/products/builds.js +221 -0
- package/dist/products/index.d.ts +3 -16
- package/dist/products/index.js +3 -14
- package/dist/products/jobs.d.ts +17 -0
- package/dist/products/jobs.js +27 -10
- package/dist/products/logs.d.ts +245 -0
- package/dist/products/logs.js +274 -0
- package/dist/products/logs.test.d.ts +1 -0
- package/dist/products/logs.test.js +343 -0
- package/dist/products/network-products.d.ts +38 -7
- package/dist/products/network-products.js +31 -18
- package/dist/products/observability.d.ts +3 -2
- package/dist/products/private-network.js +1 -1
- package/dist/products/queues.d.ts +149 -0
- package/dist/products/queues.js +213 -0
- package/dist/products/referrals.d.ts +21 -7
- package/dist/products/referrals.js +3 -3
- package/dist/products/secrets.d.ts +7 -3
- package/dist/products/secrets.js +39 -18
- package/dist/products/signed-requests.d.ts +21 -0
- package/dist/products/signed-requests.js +11 -0
- package/dist/products/streams.d.ts +73 -0
- package/dist/products/streams.js +17 -0
- package/dist/products/switching.d.ts +35 -7
- package/dist/products/switching.js +4 -3
- package/dist/products/volumes.d.ts +3 -0
- package/dist/record-cli.d.ts +23 -0
- package/dist/record-cli.js +308 -0
- package/dist/renamed-products.d.ts +38 -0
- package/dist/renamed-products.js +34 -0
- package/dist/runloop/index.d.ts +9 -2
- package/dist/runloop/index.js +29 -8
- package/dist/runloop/sdk.d.ts +131 -0
- package/dist/runloop/sdk.js +167 -0
- package/dist/sandbox.d.ts +3 -2
- package/dist/sandbox.js +14 -9
- package/dist/sandboxes/index.d.ts +11 -0
- package/dist/sandboxes/index.js +12 -0
- package/dist/snapshots/index.d.ts +9 -0
- package/dist/snapshots/index.js +11 -0
- package/dist/snapshots.d.ts +7 -4
- package/dist/sprites/index.d.ts +343 -31
- package/dist/sprites/index.js +866 -84
- package/dist/ssh.d.ts +1 -1
- package/dist/ssh.js +11 -11
- package/dist/terminal.d.ts +24 -0
- package/dist/terminal.js +32 -0
- package/dist/transport.d.ts +15 -1
- package/dist/transport.js +56 -3
- package/dist/tunnel.js +1 -1
- package/dist/types.d.ts +63 -0
- package/dist/vercel/sandbox.js +5 -3
- package/dist/vercel/snapshot.js +10 -2
- package/dist/volumes/index.d.ts +9 -0
- package/dist/volumes/index.js +10 -0
- package/package.json +48 -2
package/dist/modal/index.js
CHANGED
|
@@ -1,12 +1,99 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ConflictError as NativeConflictError, InvalidRequestError, NotFoundError as NativeNotFoundError, RateLimitError, RuntimeError, ServiceUnavailableError, } from "../errors.js";
|
|
2
|
+
import { all, client, CompatibilityError, create, destroy, execOptions, only, refuse, region, } from "../compat/core.js";
|
|
2
3
|
import { GuestFiles } from "../compat/files.js";
|
|
3
4
|
export { CompatibilityError };
|
|
5
|
+
// Errors ----------------------------------------------------------------------
|
|
6
|
+
export class TimeoutError extends Error {
|
|
7
|
+
}
|
|
8
|
+
export class RemoteError extends Error {
|
|
9
|
+
}
|
|
10
|
+
export class ExecutionError extends Error {
|
|
11
|
+
}
|
|
12
|
+
export class InternalFailure extends Error {
|
|
13
|
+
}
|
|
4
14
|
export class NotFoundError extends Error {
|
|
5
15
|
}
|
|
6
|
-
export class
|
|
16
|
+
export class AlreadyExistsError extends Error {
|
|
17
|
+
}
|
|
18
|
+
export class ConflictError extends Error {
|
|
19
|
+
}
|
|
20
|
+
export class InvalidError extends Error {
|
|
21
|
+
}
|
|
22
|
+
export class ResourceExhaustedError extends Error {
|
|
7
23
|
}
|
|
8
24
|
export class SandboxTimeoutError extends TimeoutError {
|
|
9
25
|
}
|
|
26
|
+
export class SnapshotCreationError extends Error {
|
|
27
|
+
}
|
|
28
|
+
export class ClientClosedError extends Error {
|
|
29
|
+
}
|
|
30
|
+
export class SandboxFilesystemError extends Error {
|
|
31
|
+
}
|
|
32
|
+
export class SandboxFilesystemDirectoryNotEmptyError extends SandboxFilesystemError {
|
|
33
|
+
}
|
|
34
|
+
export class SandboxFilesystemFileTooLargeError extends SandboxFilesystemError {
|
|
35
|
+
}
|
|
36
|
+
export class SandboxFilesystemIsADirectoryError extends SandboxFilesystemError {
|
|
37
|
+
}
|
|
38
|
+
export class SandboxFilesystemNotADirectoryError extends SandboxFilesystemError {
|
|
39
|
+
}
|
|
40
|
+
export class SandboxFilesystemNotFoundError extends SandboxFilesystemError {
|
|
41
|
+
}
|
|
42
|
+
export class SandboxFilesystemPathAlreadyExistsError extends SandboxFilesystemError {
|
|
43
|
+
}
|
|
44
|
+
export class SandboxFilesystemPermissionError extends SandboxFilesystemError {
|
|
45
|
+
}
|
|
46
|
+
/** A Runtime failure as the Modal error a caller catches. */
|
|
47
|
+
function modalError(error) {
|
|
48
|
+
if (!(error instanceof RuntimeError))
|
|
49
|
+
return error;
|
|
50
|
+
if (error.code === "name_taken")
|
|
51
|
+
return new AlreadyExistsError(error.message);
|
|
52
|
+
if (error instanceof NativeNotFoundError)
|
|
53
|
+
return new NotFoundError(error.message);
|
|
54
|
+
if (error instanceof NativeConflictError)
|
|
55
|
+
return new ConflictError(error.message);
|
|
56
|
+
if (error instanceof InvalidRequestError)
|
|
57
|
+
return new InvalidError(error.message);
|
|
58
|
+
if (error instanceof RateLimitError || error.code === "quota_exceeded")
|
|
59
|
+
return new ResourceExhaustedError(error.message);
|
|
60
|
+
if (error instanceof ServiceUnavailableError || error.status >= 500)
|
|
61
|
+
return new InternalFailure(error.message);
|
|
62
|
+
return error;
|
|
63
|
+
}
|
|
64
|
+
async function modal(run) {
|
|
65
|
+
try {
|
|
66
|
+
return await run();
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
throw modalError(error);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const FILE_ERRORS = {
|
|
73
|
+
file_not_found: SandboxFilesystemNotFoundError,
|
|
74
|
+
path_not_found: SandboxFilesystemNotFoundError,
|
|
75
|
+
is_a_directory: SandboxFilesystemIsADirectoryError,
|
|
76
|
+
not_a_directory: SandboxFilesystemNotADirectoryError,
|
|
77
|
+
directory_not_empty: SandboxFilesystemDirectoryNotEmptyError,
|
|
78
|
+
already_exists: SandboxFilesystemPathAlreadyExistsError,
|
|
79
|
+
permission_denied: SandboxFilesystemPermissionError,
|
|
80
|
+
file_too_large: SandboxFilesystemFileTooLargeError,
|
|
81
|
+
too_large: SandboxFilesystemFileTooLargeError,
|
|
82
|
+
};
|
|
83
|
+
async function fileCall(path, run) {
|
|
84
|
+
try {
|
|
85
|
+
return await run();
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (!(error instanceof RuntimeError))
|
|
89
|
+
throw error;
|
|
90
|
+
const Mapped = FILE_ERRORS[error.code] ?? (error.status === 404 ? SandboxFilesystemNotFoundError : null);
|
|
91
|
+
if (!Mapped)
|
|
92
|
+
throw error;
|
|
93
|
+
throw new Mapped(`${path}: ${error.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Apps, secrets, volumes, bucket mounts -----------------------------------------
|
|
10
97
|
export class App {
|
|
11
98
|
appId;
|
|
12
99
|
name;
|
|
@@ -17,72 +104,225 @@ export class App {
|
|
|
17
104
|
this.environmentName = environmentName;
|
|
18
105
|
}
|
|
19
106
|
}
|
|
107
|
+
export class AppService {
|
|
108
|
+
client;
|
|
109
|
+
constructor(client) {
|
|
110
|
+
this.client = client;
|
|
111
|
+
}
|
|
112
|
+
async fromName(name, params = {}) {
|
|
113
|
+
this.client.open();
|
|
114
|
+
only("Modal app", params, ["createIfMissing", "environment"]);
|
|
115
|
+
if (params.environment)
|
|
116
|
+
throw new CompatibilityError("Modal", "named Modal environments");
|
|
117
|
+
if (!params.createIfMissing &&
|
|
118
|
+
!(await all(this.client.runtime, "modal")).some((s) => s.info.labels["modal.app"] === name))
|
|
119
|
+
throw new NotFoundError(`App ${name} has no Runtime sandboxes; pass createIfMissing: true.`);
|
|
120
|
+
return new App(name, name);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Values the sandbox receives as environment variables, from the caller. */
|
|
20
124
|
export class Secret {
|
|
21
125
|
values;
|
|
22
|
-
|
|
126
|
+
name;
|
|
127
|
+
secretId;
|
|
128
|
+
constructor(values, name) {
|
|
23
129
|
this.values = values;
|
|
130
|
+
this.name = name;
|
|
131
|
+
this.secretId = `st-${crypto.randomUUID()}`;
|
|
24
132
|
}
|
|
25
133
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
134
|
+
const RUNTIME_SECRETS = "Runtime secrets are used without being seen: runtime.secrets.set({ value, hosts }); or pass values with secrets.fromObject";
|
|
135
|
+
export class SecretService {
|
|
136
|
+
client;
|
|
137
|
+
constructor(client) {
|
|
138
|
+
this.client = client;
|
|
139
|
+
}
|
|
140
|
+
async fromObject(entries, params = {}) {
|
|
141
|
+
this.client.open();
|
|
142
|
+
only("Modal secret", params, ["environment"]);
|
|
143
|
+
if (params.environment)
|
|
144
|
+
throw new CompatibilityError("Modal", "named Modal environments");
|
|
145
|
+
for (const [key, value] of Object.entries(entries))
|
|
146
|
+
if (typeof value !== "string")
|
|
147
|
+
throw new InvalidError(`Secret value for ${key} must be a string`);
|
|
148
|
+
return new Secret({ ...entries });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
refuse("Modal", "SecretService", SecretService, {
|
|
152
|
+
fromName: RUNTIME_SECRETS,
|
|
153
|
+
delete: RUNTIME_SECRETS,
|
|
154
|
+
});
|
|
155
|
+
const FIXED_SIZE = "Runtime volumes have a fixed size: create one with runtime.volumes.create({ name, sizeMiB })";
|
|
156
|
+
export class Volume {
|
|
157
|
+
volumeId;
|
|
158
|
+
name;
|
|
159
|
+
readOnly;
|
|
160
|
+
constructor(volumeId, name, readOnly = false) {
|
|
161
|
+
this.volumeId = volumeId;
|
|
162
|
+
this.name = name;
|
|
163
|
+
this.readOnly = readOnly;
|
|
164
|
+
}
|
|
165
|
+
withMountOptions(params = {}) {
|
|
166
|
+
only("Modal volume mount", params, ["readOnly", "subPath"]);
|
|
167
|
+
if (params.subPath)
|
|
168
|
+
throw new CompatibilityError("Modal", "subPath; mount the whole volume");
|
|
169
|
+
return new Volume(this.volumeId, this.name, params.readOnly ?? this.readOnly);
|
|
170
|
+
}
|
|
171
|
+
closeEphemeral() {
|
|
172
|
+
throw new InvalidError(`Volume ${this.name ?? this.volumeId} is not ephemeral`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export class VolumeService {
|
|
176
|
+
client;
|
|
177
|
+
constructor(client) {
|
|
178
|
+
this.client = client;
|
|
179
|
+
}
|
|
180
|
+
async find(name) {
|
|
181
|
+
const page = await modal(() => this.client.runtime.volumes.list({ name, limit: 100 }));
|
|
182
|
+
return page.data.find((v) => v.name === name && v.state !== "deleted");
|
|
183
|
+
}
|
|
184
|
+
async fromName(name, params = {}) {
|
|
185
|
+
this.client.open();
|
|
186
|
+
only("Modal volume", params, ["createIfMissing", "environment"]);
|
|
187
|
+
if (params.environment)
|
|
188
|
+
throw new CompatibilityError("Modal", "named Modal environments");
|
|
189
|
+
const found = await this.find(name);
|
|
190
|
+
if (found)
|
|
191
|
+
return new Volume(found.id, name);
|
|
192
|
+
if (params.createIfMissing)
|
|
193
|
+
throw new CompatibilityError("Modal", `createIfMissing; ${FIXED_SIZE}`);
|
|
194
|
+
throw new NotFoundError(`Volume ${name} not found`);
|
|
195
|
+
}
|
|
196
|
+
async delete(name, params = {}) {
|
|
197
|
+
this.client.open();
|
|
198
|
+
only("Modal volume delete", params, ["allowMissing", "environment"]);
|
|
199
|
+
if (params.environment)
|
|
200
|
+
throw new CompatibilityError("Modal", "named Modal environments");
|
|
201
|
+
const found = await this.find(name);
|
|
202
|
+
if (!found) {
|
|
203
|
+
if (params.allowMissing)
|
|
204
|
+
return;
|
|
205
|
+
throw new NotFoundError(`Volume ${name} not found`);
|
|
206
|
+
}
|
|
207
|
+
await modal(() => this.client.runtime.volumes.delete(found.id));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
refuse("Modal", "VolumeService", VolumeService, { ephemeral: FIXED_SIZE });
|
|
211
|
+
const BUCKET_KEYS = "store the bucket key with runtime.secrets.set and mount it with the native sandbox's mounts.add";
|
|
212
|
+
export class CloudBucketMount {
|
|
213
|
+
bucketName;
|
|
214
|
+
readOnly;
|
|
215
|
+
bucketEndpointUrl;
|
|
216
|
+
keyPrefix;
|
|
217
|
+
/** Refused at creation: Runtime mounts public buckets here, keyed ones natively. */
|
|
218
|
+
secret;
|
|
219
|
+
requesterPays = false;
|
|
220
|
+
oidcAuthRoleArn;
|
|
221
|
+
constructor(bucketName, readOnly = false, bucketEndpointUrl, keyPrefix) {
|
|
222
|
+
this.bucketName = bucketName;
|
|
223
|
+
this.readOnly = readOnly;
|
|
224
|
+
this.bucketEndpointUrl = bucketEndpointUrl;
|
|
225
|
+
this.keyPrefix = keyPrefix;
|
|
226
|
+
}
|
|
227
|
+
/** The native mount for `path`: the provider read from the endpoint's host. */
|
|
228
|
+
native(path) {
|
|
229
|
+
const host = this.bucketEndpointUrl ? new URL(this.bucketEndpointUrl).hostname : undefined;
|
|
230
|
+
const r2 = host?.match(/^([0-9a-f]+)\.r2\.cloudflarestorage\.com$/);
|
|
231
|
+
return {
|
|
232
|
+
provider: r2
|
|
233
|
+
? "r2"
|
|
234
|
+
: host === "storage.googleapis.com"
|
|
235
|
+
? "gcs"
|
|
236
|
+
: "s3",
|
|
237
|
+
bucket: this.bucketName,
|
|
238
|
+
path,
|
|
239
|
+
...(r2 ? { accountId: r2[1] } : host ? { endpoint: host } : {}),
|
|
240
|
+
...(this.keyPrefix ? { prefix: this.keyPrefix } : {}),
|
|
241
|
+
readOnly: this.readOnly,
|
|
66
242
|
};
|
|
67
|
-
this.sandboxes = new SandboxService(runtime);
|
|
68
243
|
}
|
|
69
|
-
|
|
70
|
-
|
|
244
|
+
}
|
|
245
|
+
refuse("Modal", "CloudBucketMount", CloudBucketMount, { toProto: "pass it in cloudBucketMounts" }, { sync: true });
|
|
246
|
+
export class CloudBucketMountService {
|
|
247
|
+
create(bucketName, params = {}) {
|
|
248
|
+
only("Modal bucket mount", params, [
|
|
249
|
+
"secret",
|
|
250
|
+
"readOnly",
|
|
251
|
+
"requesterPays",
|
|
252
|
+
"bucketEndpointUrl",
|
|
253
|
+
"keyPrefix",
|
|
254
|
+
"oidcAuthRoleArn",
|
|
255
|
+
]);
|
|
256
|
+
if (params.secret)
|
|
257
|
+
throw new CompatibilityError("Modal", `bucket secret; ${BUCKET_KEYS}`);
|
|
258
|
+
if (params.requesterPays || params.oidcAuthRoleArn)
|
|
259
|
+
throw new CompatibilityError("Modal", `requesterPays and oidcAuthRoleArn; ${BUCKET_KEYS}`);
|
|
260
|
+
if (params.bucketEndpointUrl)
|
|
261
|
+
new URL(params.bucketEndpointUrl);
|
|
262
|
+
return new CloudBucketMount(bucketName, params.readOnly ?? false, params.bucketEndpointUrl, params.keyPrefix);
|
|
71
263
|
}
|
|
72
264
|
}
|
|
265
|
+
export class Proxy {
|
|
266
|
+
}
|
|
267
|
+
export class ProxyService {
|
|
268
|
+
}
|
|
269
|
+
const STATIC_IPS = "Runtime static egress IPs: runtime.ips";
|
|
270
|
+
refuse("Modal", "Proxy", Proxy, { proxyId: STATIC_IPS }, { sync: true });
|
|
271
|
+
refuse("Modal", "ProxyService", ProxyService, { fromName: STATIC_IPS });
|
|
272
|
+
export class ExperimentalOutboundPolicy {
|
|
273
|
+
}
|
|
274
|
+
refuse("Modal", "ExperimentalOutboundPolicy", ExperimentalOutboundPolicy, {
|
|
275
|
+
withHeaderReplacement: "Runtime secrets set the header on the way out: runtime.secrets.set({ value, hosts, header })",
|
|
276
|
+
}, { sync: true });
|
|
277
|
+
export class Probe {
|
|
278
|
+
spec;
|
|
279
|
+
constructor(spec) {
|
|
280
|
+
this.spec = spec;
|
|
281
|
+
}
|
|
282
|
+
static interval(name, intervalMs) {
|
|
283
|
+
if (!Number.isInteger(intervalMs))
|
|
284
|
+
throw new InvalidError(`${name}() expects an integer \`intervalMs\``);
|
|
285
|
+
if (intervalMs <= 0)
|
|
286
|
+
throw new InvalidError(`${name}() expects \`intervalMs\` > 0, got ${String(intervalMs)}`);
|
|
287
|
+
}
|
|
288
|
+
static withTcp(port, params = { intervalMs: 100 }) {
|
|
289
|
+
if (!Number.isInteger(port))
|
|
290
|
+
throw new InvalidError("Probe.withTcp() expects an integer `port`");
|
|
291
|
+
if (port <= 0 || port > 65535)
|
|
292
|
+
throw new InvalidError(`Probe.withTcp() expects \`port\` in [1, 65535], got ${port}`);
|
|
293
|
+
Probe.interval("Probe.withTcp", params.intervalMs);
|
|
294
|
+
return new Probe({ tcpPort: port, intervalMs: params.intervalMs });
|
|
295
|
+
}
|
|
296
|
+
static withExec(argv, params = { intervalMs: 100 }) {
|
|
297
|
+
if (!Array.isArray(argv) || argv.length === 0)
|
|
298
|
+
throw new InvalidError("Probe.withExec() requires at least one argument");
|
|
299
|
+
if (!argv.every((arg) => typeof arg === "string"))
|
|
300
|
+
throw new InvalidError("Probe.withExec() expects all arguments to be strings");
|
|
301
|
+
Probe.interval("Probe.withExec", params.intervalMs);
|
|
302
|
+
return new Probe({ execArgv: [...argv], intervalMs: params.intervalMs });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
refuse("Modal", "Probe", Probe, { toProto: "pass it as readinessProbe" }, { sync: true });
|
|
306
|
+
// Images ------------------------------------------------------------------------
|
|
73
307
|
export class Image {
|
|
74
308
|
runtime;
|
|
75
309
|
tag;
|
|
76
310
|
commands;
|
|
77
311
|
env;
|
|
78
312
|
cache;
|
|
313
|
+
snapshot;
|
|
79
314
|
built;
|
|
80
|
-
constructor(
|
|
315
|
+
constructor(
|
|
316
|
+
/** Absent only for an Image that is already built. */
|
|
317
|
+
runtime, tag, id, commands = [], env = {}, cache = true,
|
|
318
|
+
/** The id is a native disk snapshot (snapshotFilesystem), not an image. */
|
|
319
|
+
snapshot = false) {
|
|
81
320
|
this.runtime = runtime;
|
|
82
321
|
this.tag = tag;
|
|
83
322
|
this.commands = commands;
|
|
84
323
|
this.env = env;
|
|
85
324
|
this.cache = cache;
|
|
325
|
+
this.snapshot = snapshot;
|
|
86
326
|
this.built = id;
|
|
87
327
|
}
|
|
88
328
|
get imageId() {
|
|
@@ -98,47 +338,199 @@ export class Image {
|
|
|
98
338
|
}
|
|
99
339
|
async build(_app) {
|
|
100
340
|
if (!this.built) {
|
|
101
|
-
const built = this.commands.length
|
|
102
|
-
?
|
|
341
|
+
const built = await modal(() => this.commands.length
|
|
342
|
+
? this.runtime.images.build({
|
|
103
343
|
dockerfile: [`FROM ${this.tag}`, ...this.commands].join("\n"),
|
|
104
344
|
env: this.env,
|
|
105
345
|
cache: this.cache,
|
|
106
346
|
})
|
|
107
|
-
:
|
|
347
|
+
: this.runtime.images.build({ image: this.tag, env: this.env, cache: this.cache }));
|
|
108
348
|
this.built = built.id;
|
|
109
349
|
}
|
|
110
350
|
return this;
|
|
111
351
|
}
|
|
112
352
|
}
|
|
353
|
+
refuse("Modal", "Image", Image, {
|
|
354
|
+
publish: "build it under a name: runtime.images.build({ name }), then images.fromName",
|
|
355
|
+
});
|
|
356
|
+
const REGISTRY_LOGIN = "store the registry login with runtime.images.registries, then use images.fromRegistry";
|
|
357
|
+
export class ImageService {
|
|
358
|
+
client;
|
|
359
|
+
constructor(client) {
|
|
360
|
+
this.client = client;
|
|
361
|
+
}
|
|
362
|
+
fromRegistry(tag, secret) {
|
|
363
|
+
this.client.open();
|
|
364
|
+
if (secret)
|
|
365
|
+
throw new CompatibilityError("Modal", `registry Secret; ${REGISTRY_LOGIN}`);
|
|
366
|
+
return new Image(this.client.runtime, tag);
|
|
367
|
+
}
|
|
368
|
+
async fromId(imageId) {
|
|
369
|
+
this.client.open();
|
|
370
|
+
return new Image(this.client.runtime, undefined, (await modal(() => this.client.runtime.images.get(imageId))).id);
|
|
371
|
+
}
|
|
372
|
+
async fromName(name, params = {}) {
|
|
373
|
+
this.client.open();
|
|
374
|
+
only("Modal image name", params, ["environment"]);
|
|
375
|
+
if (params.environment)
|
|
376
|
+
throw new CompatibilityError("Modal", "named Modal environments");
|
|
377
|
+
return new Image(this.client.runtime, undefined, (await modal(() => this.client.runtime.images.resolve(name))).id);
|
|
378
|
+
}
|
|
379
|
+
async delete(imageId, _params = {}) {
|
|
380
|
+
this.client.open();
|
|
381
|
+
await modal(() => this.client.runtime.images.delete(imageId));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
refuse("Modal", "ImageService", ImageService, { fromAwsEcr: REGISTRY_LOGIN, fromGcpArtifactRegistry: REGISTRY_LOGIN }, { sync: true });
|
|
385
|
+
// Snapshots ---------------------------------------------------------------------
|
|
386
|
+
export class SandboxSnapshot {
|
|
387
|
+
snapshotId;
|
|
388
|
+
constructor(snapshotId) {
|
|
389
|
+
this.snapshotId = snapshotId;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
export class SandboxSnapshotService {
|
|
393
|
+
client;
|
|
394
|
+
constructor(client) {
|
|
395
|
+
this.client = client;
|
|
396
|
+
}
|
|
397
|
+
async fromId(snapshotId) {
|
|
398
|
+
this.client.open();
|
|
399
|
+
const snapshot = await modal(() => this.client.runtime.snapshots.get(snapshotId));
|
|
400
|
+
if (snapshot.state === "deleted" || snapshot.state === "deleting")
|
|
401
|
+
throw new NotFoundError(`Sandbox snapshot ${snapshotId} not found`);
|
|
402
|
+
return new SandboxSnapshot(snapshot.id);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
export class ModalClient {
|
|
406
|
+
runtime;
|
|
407
|
+
apps;
|
|
408
|
+
images;
|
|
409
|
+
sandboxes;
|
|
410
|
+
secrets;
|
|
411
|
+
volumes;
|
|
412
|
+
cloudBucketMounts = new CloudBucketMountService();
|
|
413
|
+
sandboxSnapshots;
|
|
414
|
+
proxies = new ProxyService();
|
|
415
|
+
#closed = false;
|
|
416
|
+
constructor(options = {}) {
|
|
417
|
+
if (options.endpoint !== undefined ||
|
|
418
|
+
options.oauthRefreshToken !== undefined ||
|
|
419
|
+
options.oauthClientId !== undefined ||
|
|
420
|
+
options.oauthClientSecret !== undefined ||
|
|
421
|
+
options.oauthJwtKey !== undefined ||
|
|
422
|
+
options.maxThrottleWaitSecs !== undefined ||
|
|
423
|
+
options.logger !== undefined ||
|
|
424
|
+
options.logLevel !== undefined ||
|
|
425
|
+
options.grpcMiddleware !== undefined ||
|
|
426
|
+
options.cpClient !== undefined)
|
|
427
|
+
throw new CompatibilityError("Modal", "Modal endpoints, OAuth, gRPC and logger options; set RUNTIME_API_KEY or pass apiKey");
|
|
428
|
+
only("Modal client", options, [
|
|
429
|
+
"apiKey",
|
|
430
|
+
"baseUrl",
|
|
431
|
+
"fetch",
|
|
432
|
+
"client",
|
|
433
|
+
"timeoutMs",
|
|
434
|
+
"maxRetries",
|
|
435
|
+
"tokenId",
|
|
436
|
+
"tokenSecret",
|
|
437
|
+
"environment",
|
|
438
|
+
]);
|
|
439
|
+
if (options.environment)
|
|
440
|
+
throw new CompatibilityError("Modal", "named Modal environments");
|
|
441
|
+
this.runtime = client(options);
|
|
442
|
+
this.apps = new AppService(this);
|
|
443
|
+
this.images = new ImageService(this);
|
|
444
|
+
this.sandboxes = new SandboxService(this.runtime, this);
|
|
445
|
+
this.secrets = new SecretService(this);
|
|
446
|
+
this.volumes = new VolumeService(this);
|
|
447
|
+
this.sandboxSnapshots = new SandboxSnapshotService(this);
|
|
448
|
+
}
|
|
449
|
+
/** Throws once close() was called, as Modal's client does. */
|
|
450
|
+
open() {
|
|
451
|
+
if (this.#closed)
|
|
452
|
+
throw new ClientClosedError("The Modal client was closed");
|
|
453
|
+
}
|
|
454
|
+
environmentName(environment) {
|
|
455
|
+
if (environment)
|
|
456
|
+
throw new CompatibilityError("Modal", "named Modal environments");
|
|
457
|
+
return "";
|
|
458
|
+
}
|
|
459
|
+
/** The Modal SDK version whose interface this adapter implements. */
|
|
460
|
+
version() {
|
|
461
|
+
return "0.11.0";
|
|
462
|
+
}
|
|
463
|
+
close() {
|
|
464
|
+
// The transport owns no background connection; later calls fail as Modal's do.
|
|
465
|
+
this.#closed = true;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const NO_MODAL_SERVER = "Runtime has no Modal control plane; use the native client at modal.runtime";
|
|
469
|
+
refuse("Modal", "ModalClient", ModalClient, {
|
|
470
|
+
cpClient: NO_MODAL_SERVER,
|
|
471
|
+
ipClient: NO_MODAL_SERVER,
|
|
472
|
+
profile: NO_MODAL_SERVER,
|
|
473
|
+
logger: NO_MODAL_SERVER,
|
|
474
|
+
}, { sync: true });
|
|
475
|
+
refuse("Modal", "ModalClient", ModalClient, {
|
|
476
|
+
getImageBuilderVersion: "Runtime images record theirs: (await runtime.images.get(id)).build.builderVersion",
|
|
477
|
+
});
|
|
478
|
+
/** Modal's outbound allowlists as Runtime network rules, never wider than
|
|
479
|
+
* Modal's: a missing list or a wildcard entry allows everything, an empty
|
|
480
|
+
* list blocks all egress. Runtime's rules are one list a connection matches
|
|
481
|
+
* any entry of, so a domain list and a CIDR list that both narrow are
|
|
482
|
+
* refused rather than joined into a wider rule. */
|
|
483
|
+
function egress(domains, cidrs) {
|
|
484
|
+
if (domains?.length === 0 || cidrs?.length === 0)
|
|
485
|
+
return { internet: false };
|
|
486
|
+
const anyDomain = !domains || domains.includes("*");
|
|
487
|
+
const anyCidr = !cidrs || cidrs.some((c) => c === "0.0.0.0/0" || c === "::/0");
|
|
488
|
+
if (anyDomain && anyCidr)
|
|
489
|
+
return { internet: true };
|
|
490
|
+
if (!anyDomain && !anyCidr)
|
|
491
|
+
throw new CompatibilityError("Modal", "a domain allowlist and a CIDR allowlist at once; pass one list of domains and CIDR ranges to sandbox.native.network.set({ internet: true, allow })");
|
|
492
|
+
return { internet: true, allow: anyDomain ? cidrs : domains };
|
|
493
|
+
}
|
|
494
|
+
const ENVIRONMENTS = "named Modal environments";
|
|
495
|
+
const CREATE_OPTIONS = [
|
|
496
|
+
"name",
|
|
497
|
+
"cpu",
|
|
498
|
+
"cpuLimit",
|
|
499
|
+
"memoryMiB",
|
|
500
|
+
"memoryLimitMiB",
|
|
501
|
+
"runtime",
|
|
502
|
+
"timeoutMs",
|
|
503
|
+
"workdir",
|
|
504
|
+
"command",
|
|
505
|
+
"env",
|
|
506
|
+
"encryptedPorts",
|
|
507
|
+
"blockNetwork",
|
|
508
|
+
"outboundDomainAllowlist",
|
|
509
|
+
"outboundCidrAllowlist",
|
|
510
|
+
"tags",
|
|
511
|
+
"regions",
|
|
512
|
+
"gpu",
|
|
513
|
+
"pty",
|
|
514
|
+
"secrets",
|
|
515
|
+
"volumes",
|
|
516
|
+
"cloudBucketMounts",
|
|
517
|
+
"readinessProbe",
|
|
518
|
+
"experimentalEnableSnapshot",
|
|
519
|
+
];
|
|
113
520
|
export class SandboxService {
|
|
114
521
|
runtime;
|
|
115
|
-
|
|
522
|
+
client;
|
|
523
|
+
constructor(runtime, client) {
|
|
116
524
|
this.runtime = runtime;
|
|
525
|
+
this.client = client;
|
|
117
526
|
}
|
|
118
527
|
async create(app, image, params = {}) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
"cpu",
|
|
122
|
-
"cpuLimit",
|
|
123
|
-
"memoryMiB",
|
|
124
|
-
"memoryLimitMiB",
|
|
125
|
-
"timeoutMs",
|
|
126
|
-
"workdir",
|
|
127
|
-
"command",
|
|
128
|
-
"env",
|
|
129
|
-
"encryptedPorts",
|
|
130
|
-
"blockNetwork",
|
|
131
|
-
"outboundDomainAllowlist",
|
|
132
|
-
"outboundCidrAllowlist",
|
|
133
|
-
"tags",
|
|
134
|
-
"regions",
|
|
135
|
-
"gpu",
|
|
136
|
-
"pty",
|
|
137
|
-
"secrets",
|
|
138
|
-
"experimentalEnableSnapshot",
|
|
139
|
-
]);
|
|
528
|
+
this.client?.open();
|
|
529
|
+
only("Modal sandbox", params, CREATE_OPTIONS);
|
|
140
530
|
if (params.gpu)
|
|
141
531
|
throw new CompatibilityError("Modal", "GPU allocation");
|
|
532
|
+
if (params.runtime === "gvisor")
|
|
533
|
+
throw new CompatibilityError("Modal", 'gVisor; Runtime sandboxes are Firecracker VMs (runtime: "vm")');
|
|
142
534
|
if (params.cpuLimit !== undefined && params.cpuLimit !== params.cpu)
|
|
143
535
|
throw new CompatibilityError("Modal", "separate CPU reservation and hard limit");
|
|
144
536
|
if (params.memoryLimitMiB !== undefined && params.memoryLimitMiB !== params.memoryMiB)
|
|
@@ -147,23 +539,49 @@ export class SandboxService {
|
|
|
147
539
|
throw new CompatibilityError("Modal", "multiple region placement");
|
|
148
540
|
if (params.cpu !== undefined && !Number.isInteger(params.cpu))
|
|
149
541
|
throw new CompatibilityError("Modal", "fractional CPU reservations");
|
|
542
|
+
if (image.snapshot && (params.cpu !== undefined || params.memoryMiB !== undefined))
|
|
543
|
+
throw new CompatibilityError("Modal", "cpu or memoryMiB with a snapshot image; it keeps the snapshot's shape, resize it after");
|
|
150
544
|
if (params.blockNetwork && (params.outboundDomainAllowlist || params.outboundCidrAllowlist))
|
|
151
545
|
throw new TypeError("blockNetwork cannot be combined with allowlists");
|
|
546
|
+
if (params.runtime !== undefined && params.runtime !== "vm")
|
|
547
|
+
throw new InvalidError(`Unknown sandbox runtime ${String(params.runtime)}`);
|
|
548
|
+
const network = params.blockNetwork
|
|
549
|
+
? { internet: false }
|
|
550
|
+
: egress(params.outboundDomainAllowlist, params.outboundCidrAllowlist);
|
|
152
551
|
const placement = region("Modal", params.regions?.[0]);
|
|
153
552
|
const built = await image.build(app);
|
|
553
|
+
const volumes = Object.entries(params.volumes ?? {}).map(([path, v]) => {
|
|
554
|
+
if (!(v instanceof Volume))
|
|
555
|
+
throw new InvalidError(`volumes[${path}] must be a Volume`);
|
|
556
|
+
return {
|
|
557
|
+
volumeId: v.volumeId,
|
|
558
|
+
path,
|
|
559
|
+
mode: v.readOnly ? "snapshot" : "rw",
|
|
560
|
+
};
|
|
561
|
+
});
|
|
562
|
+
const mounts = Object.entries(params.cloudBucketMounts ?? {}).map(([path, m]) => {
|
|
563
|
+
if (!(m instanceof CloudBucketMount))
|
|
564
|
+
throw new InvalidError(`cloudBucketMounts[${path}] must be a CloudBucketMount`);
|
|
565
|
+
return m.native(path);
|
|
566
|
+
});
|
|
154
567
|
let primary;
|
|
155
|
-
const
|
|
568
|
+
const input = {
|
|
156
569
|
name: params.name ? `${app.name ?? app.appId}-${params.name}` : undefined,
|
|
157
570
|
labels: {
|
|
158
571
|
"modal.app": app.name ?? app.appId,
|
|
159
572
|
...(params.name ? { "modal.name": params.name } : {}),
|
|
160
573
|
"modal.tags": JSON.stringify(params.tags ?? {}),
|
|
574
|
+
...(params.readinessProbe
|
|
575
|
+
? { "modal.probe": JSON.stringify(params.readinessProbe.spec) }
|
|
576
|
+
: {}),
|
|
577
|
+
...(volumes.some((v) => v.mode === "snapshot") ? { "modal.readonly": "1" } : {}),
|
|
161
578
|
},
|
|
162
|
-
image: built.imageId,
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
579
|
+
...(image.snapshot ? { snapshot: built.imageId } : { image: built.imageId }),
|
|
580
|
+
...(image.snapshot
|
|
581
|
+
? {}
|
|
582
|
+
: { cpu: "reserved", vcpu: params.cpu, memoryMiB: params.memoryMiB }),
|
|
166
583
|
region: placement,
|
|
584
|
+
...(volumes.length ? { volumes } : {}),
|
|
167
585
|
// A timeout is the customer's limit, and the sandbox never pauses
|
|
168
586
|
// for idleness before it. None is no time limit (0300): it runs while
|
|
169
587
|
// it works and pauses when idle.
|
|
@@ -171,14 +589,11 @@ export class SandboxService {
|
|
|
171
589
|
? {}
|
|
172
590
|
: { timeoutSeconds: Math.ceil(params.timeoutMs / 1000), idlePauseSeconds: 0 }),
|
|
173
591
|
onLeaseEnd: "stop",
|
|
174
|
-
network
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
],
|
|
180
|
-
},
|
|
181
|
-
}, Object.assign({}, ...(params.secrets?.map((s) => s.values) ?? []), params.env ?? {}), async (s) => {
|
|
592
|
+
network,
|
|
593
|
+
};
|
|
594
|
+
const s = await modal(() => create(this.runtime, "modal", input, Object.assign({}, ...(params.secrets?.map((s) => s.values) ?? []), params.env ?? {}), async (s) => {
|
|
595
|
+
for (const mount of mounts)
|
|
596
|
+
await s.mounts.add(mount);
|
|
182
597
|
primary = await s.spawn(params.command ?? ["sleep", "infinity"], {
|
|
183
598
|
...(await execOptions(s, { cwd: params.workdir })),
|
|
184
599
|
stdin: "pipe",
|
|
@@ -187,19 +602,28 @@ export class SandboxService {
|
|
|
187
602
|
await s.update({ labels: { ...s.info.labels, "modal.entrypoint": primary.id } });
|
|
188
603
|
for (const port of params.encryptedPorts ?? [])
|
|
189
604
|
await s.previews.create(port, { visibility: "public" });
|
|
190
|
-
});
|
|
191
|
-
const sandbox = new Sandbox(s, new ContainerProcess(primary, "text"));
|
|
605
|
+
}));
|
|
606
|
+
const sandbox = new Sandbox(s, new ContainerProcess(primary, "text"), this.client);
|
|
192
607
|
sandbox.monitorEntrypoint();
|
|
193
608
|
return sandbox;
|
|
194
609
|
}
|
|
610
|
+
async experimentalCreate(app, image, params = {}) {
|
|
611
|
+
only("Modal sandbox", params, CREATE_OPTIONS);
|
|
612
|
+
return this.create(app, image, params);
|
|
613
|
+
}
|
|
195
614
|
async fromId(id) {
|
|
196
|
-
|
|
615
|
+
this.client?.open();
|
|
616
|
+
const s = await modal(() => this.runtime.sandboxes.get(id));
|
|
197
617
|
const p = s.info.labels["modal.entrypoint"];
|
|
198
618
|
if (!p)
|
|
199
619
|
throw new NotFoundError("Sandbox is not a Modal-compatible sandbox");
|
|
200
|
-
return new Sandbox(s, new ContainerProcess(await s.processes.get(p), "text"));
|
|
620
|
+
return new Sandbox(s, new ContainerProcess(await modal(() => s.processes.get(p)), "text"), this.client);
|
|
201
621
|
}
|
|
202
|
-
async fromName(appName, name) {
|
|
622
|
+
async fromName(appName, name, params = {}) {
|
|
623
|
+
this.client?.open();
|
|
624
|
+
only("Modal sandbox name", params, ["environment"]);
|
|
625
|
+
if (params.environment)
|
|
626
|
+
throw new CompatibilityError("Modal", ENVIRONMENTS);
|
|
203
627
|
const s = (await all(this.runtime, "modal")).find((s) => s.info.labels["modal.app"] === appName &&
|
|
204
628
|
s.info.labels["modal.name"] === name &&
|
|
205
629
|
s.state === "running");
|
|
@@ -207,14 +631,55 @@ export class SandboxService {
|
|
|
207
631
|
throw new NotFoundError(`Sandbox ${name} not found`);
|
|
208
632
|
return this.fromId(s.id);
|
|
209
633
|
}
|
|
634
|
+
experimentalFromName(appName, name, params = {}) {
|
|
635
|
+
return this.fromName(appName, name, params);
|
|
636
|
+
}
|
|
637
|
+
/** A new sandbox continuing a memory snapshot: its files, memory and
|
|
638
|
+
* running entrypoint, under the snapshot's app and tags. */
|
|
639
|
+
async experimentalFromSnapshot(snapshot, params = {}) {
|
|
640
|
+
this.client?.open();
|
|
641
|
+
only("Modal snapshot restore", params, ["name"]);
|
|
642
|
+
const saved = await modal(() => this.runtime.snapshots.get(snapshot.snapshotId));
|
|
643
|
+
const labels = (saved.labels ?? {});
|
|
644
|
+
const entrypoint = labels["modal.entrypoint"];
|
|
645
|
+
if (!entrypoint || saved.mode === "disk")
|
|
646
|
+
throw new InvalidError(`Snapshot ${snapshot.snapshotId} was not taken with sandbox.experimentalSnapshot()`);
|
|
647
|
+
const app = labels["modal.app"];
|
|
648
|
+
const s = await modal(() => create(this.runtime, "modal", {
|
|
649
|
+
snapshot: saved.id,
|
|
650
|
+
...(params.name ? { name: `${app}-${params.name}` } : {}),
|
|
651
|
+
labels: {
|
|
652
|
+
...Object.fromEntries(Object.entries(labels).filter(([k]) => k.startsWith("modal."))),
|
|
653
|
+
...(params.name ? { "modal.name": params.name } : {}),
|
|
654
|
+
},
|
|
655
|
+
onLeaseEnd: "stop",
|
|
656
|
+
}));
|
|
657
|
+
let process;
|
|
658
|
+
try {
|
|
659
|
+
process = await s.processes.get(entrypoint);
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
await destroy(s);
|
|
663
|
+
throw modalError(error);
|
|
664
|
+
}
|
|
665
|
+
const sandbox = new Sandbox(s, new ContainerProcess(process, "text"), this.client);
|
|
666
|
+
sandbox.monitorEntrypoint();
|
|
667
|
+
return sandbox;
|
|
668
|
+
}
|
|
210
669
|
async *list(params = {}) {
|
|
211
|
-
|
|
670
|
+
this.client?.open();
|
|
671
|
+
only("Modal list", params, ["appId", "tags", "environment"]);
|
|
672
|
+
if (params.environment)
|
|
673
|
+
throw new CompatibilityError("Modal", ENVIRONMENTS);
|
|
212
674
|
for (const s of await all(this.runtime, "modal"))
|
|
213
675
|
if (s.state === "running" &&
|
|
214
676
|
(!params.appId || s.info.labels["modal.app"] === params.appId) &&
|
|
215
677
|
Object.entries(params.tags ?? {}).every(([k, v]) => JSON.parse(s.info.labels["modal.tags"] ?? "{}")[k] === v))
|
|
216
678
|
yield await this.fromId(s.id);
|
|
217
679
|
}
|
|
680
|
+
experimentalList(params = {}) {
|
|
681
|
+
return this.list(params);
|
|
682
|
+
}
|
|
218
683
|
}
|
|
219
684
|
export class ModalReadStream extends ReadableStream {
|
|
220
685
|
constructor(process, channel, mode, ignore = false) {
|
|
@@ -328,13 +793,67 @@ export class ContainerProcess {
|
|
|
328
793
|
return p.state === "running" ? null : p.exitCode;
|
|
329
794
|
}
|
|
330
795
|
}
|
|
796
|
+
/** Host and port of a sandbox port's public HTTPS address. Runtime previews
|
|
797
|
+
* are HTTPS only, so there is no unencrypted address. */
|
|
798
|
+
export class Tunnel {
|
|
799
|
+
host;
|
|
800
|
+
port;
|
|
801
|
+
unencryptedHost;
|
|
802
|
+
unencryptedPort;
|
|
803
|
+
constructor(host, port, unencryptedHost, unencryptedPort) {
|
|
804
|
+
this.host = host;
|
|
805
|
+
this.port = port;
|
|
806
|
+
this.unencryptedHost = unencryptedHost;
|
|
807
|
+
this.unencryptedPort = unencryptedPort;
|
|
808
|
+
}
|
|
809
|
+
get url() {
|
|
810
|
+
return this.port === 443 ? `https://${this.host}` : `https://${this.host}:${this.port}`;
|
|
811
|
+
}
|
|
812
|
+
get tlsSocket() {
|
|
813
|
+
return [this.host, this.port];
|
|
814
|
+
}
|
|
815
|
+
get tcpSocket() {
|
|
816
|
+
if (!this.unencryptedHost || this.unencryptedPort === undefined)
|
|
817
|
+
throw new InvalidError("This tunnel is not configured for unencrypted TCP.");
|
|
818
|
+
return [this.unencryptedHost, this.unencryptedPort];
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const NEW_SANDBOX_FROM_SNAPSHOT = "take sandbox.snapshotFilesystem() and start a new sandbox from that image";
|
|
822
|
+
export class SandboxLogsManager {
|
|
823
|
+
}
|
|
824
|
+
refuse("Modal", "SandboxLogsManager", SandboxLogsManager, {
|
|
825
|
+
fetch: "read sandbox.stdout and sandbox.stderr",
|
|
826
|
+
tail: "read sandbox.stdout and sandbox.stderr",
|
|
827
|
+
}, { sync: true });
|
|
828
|
+
const SECOND_SANDBOX = "start a second Runtime sandbox and join both to a private network";
|
|
829
|
+
export class SidecarService {
|
|
830
|
+
}
|
|
831
|
+
refuse("Modal", "SidecarService", SidecarService, { create: SECOND_SANDBOX, get: SECOND_SANDBOX });
|
|
832
|
+
refuse("Modal", "SidecarService", SidecarService, { list: SECOND_SANDBOX }, { sync: true });
|
|
833
|
+
export class SidecarContainer {
|
|
834
|
+
}
|
|
835
|
+
refuse("Modal", "SidecarContainer", SidecarContainer, { containerId: SECOND_SANDBOX, containerName: SECOND_SANDBOX, filesystem: SECOND_SANDBOX }, { sync: true });
|
|
836
|
+
refuse("Modal", "SidecarContainer", SidecarContainer, Object.fromEntries([
|
|
837
|
+
"exec",
|
|
838
|
+
"mountImage",
|
|
839
|
+
"poll",
|
|
840
|
+
"reloadVolumes",
|
|
841
|
+
"snapshotDirectory",
|
|
842
|
+
"snapshotFilesystem",
|
|
843
|
+
"terminate",
|
|
844
|
+
"unmountImage",
|
|
845
|
+
"wait",
|
|
846
|
+
].map((m) => [m, SECOND_SANDBOX])));
|
|
331
847
|
export class Sandbox {
|
|
332
848
|
native;
|
|
333
849
|
primary;
|
|
850
|
+
client;
|
|
334
851
|
filesystem;
|
|
335
852
|
stdin;
|
|
336
853
|
stdout;
|
|
337
854
|
stderr;
|
|
855
|
+
logs = new SandboxLogsManager();
|
|
856
|
+
experimentalSidecars = new SidecarService();
|
|
338
857
|
detached = false;
|
|
339
858
|
lifecycle;
|
|
340
859
|
monitorEntrypoint() {
|
|
@@ -344,9 +863,10 @@ export class Sandbox {
|
|
|
344
863
|
});
|
|
345
864
|
this.lifecycle.catch(() => undefined);
|
|
346
865
|
}
|
|
347
|
-
constructor(native, primary) {
|
|
866
|
+
constructor(native, primary, client) {
|
|
348
867
|
this.native = native;
|
|
349
868
|
this.primary = primary;
|
|
869
|
+
this.client = client;
|
|
350
870
|
this.filesystem = new SandboxFilesystem(native);
|
|
351
871
|
this.stdin = primary.stdin;
|
|
352
872
|
this.stdout = primary.stdout;
|
|
@@ -355,9 +875,13 @@ export class Sandbox {
|
|
|
355
875
|
get sandboxId() {
|
|
356
876
|
return this.native.id;
|
|
357
877
|
}
|
|
358
|
-
|
|
878
|
+
attached() {
|
|
879
|
+
this.client?.open();
|
|
359
880
|
if (this.detached)
|
|
360
|
-
throw new
|
|
881
|
+
throw new ClientClosedError("Sandbox is detached");
|
|
882
|
+
}
|
|
883
|
+
async exec(command, params = {}) {
|
|
884
|
+
this.attached();
|
|
361
885
|
only("Modal exec", params, [
|
|
362
886
|
"mode",
|
|
363
887
|
"stdout",
|
|
@@ -381,8 +905,7 @@ export class Sandbox {
|
|
|
381
905
|
return new ContainerProcess(p, params.mode ?? "text", params);
|
|
382
906
|
}
|
|
383
907
|
async setTags(tags) {
|
|
384
|
-
|
|
385
|
-
throw new Error("Sandbox is detached");
|
|
908
|
+
this.attached();
|
|
386
909
|
await this.native.update({
|
|
387
910
|
labels: {
|
|
388
911
|
...Object.fromEntries(Object.entries(this.native.info.labels).filter(([k]) => k.startsWith("modal.") || k === "compat.provider")),
|
|
@@ -391,11 +914,21 @@ export class Sandbox {
|
|
|
391
914
|
});
|
|
392
915
|
}
|
|
393
916
|
async getTags() {
|
|
394
|
-
|
|
395
|
-
throw new Error("Sandbox is detached");
|
|
917
|
+
this.attached();
|
|
396
918
|
await this.native.refresh();
|
|
397
919
|
return JSON.parse(this.native.info.labels["modal.tags"] ?? "{}");
|
|
398
920
|
}
|
|
921
|
+
/** Names the sandbox so sandboxes.fromName(app, name) finds it. */
|
|
922
|
+
async experimentalSetName(name) {
|
|
923
|
+
this.attached();
|
|
924
|
+
if (!name)
|
|
925
|
+
throw new InvalidError("Sandbox name must not be empty");
|
|
926
|
+
const app = this.native.info.labels["modal.app"];
|
|
927
|
+
await modal(() => this.native.update({
|
|
928
|
+
name: `${app}-${name}`,
|
|
929
|
+
labels: { ...this.native.info.labels, "modal.name": name },
|
|
930
|
+
}));
|
|
931
|
+
}
|
|
399
932
|
async terminate(params = {}) {
|
|
400
933
|
await destroy(this.native);
|
|
401
934
|
return params.wait ? this.primary.wait() : undefined;
|
|
@@ -413,23 +946,99 @@ export class Sandbox {
|
|
|
413
946
|
poll() {
|
|
414
947
|
return this.primary._poll();
|
|
415
948
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
949
|
+
/** Runs the sandbox's readinessProbe until it passes. */
|
|
950
|
+
async waitUntilReady(timeoutMs = 300_000) {
|
|
951
|
+
this.attached();
|
|
952
|
+
if (timeoutMs <= 0)
|
|
953
|
+
throw new InvalidError(`timeoutMs must be positive, got ${timeoutMs}`);
|
|
954
|
+
const raw = this.native.info.labels["modal.probe"];
|
|
955
|
+
if (!raw)
|
|
956
|
+
throw new InvalidError("Sandbox has no readinessProbe");
|
|
957
|
+
const probe = JSON.parse(raw);
|
|
958
|
+
const argv = probe.execArgv ?? ["bash", "-c", `exec 3<>/dev/tcp/127.0.0.1/${probe.tcpPort}`];
|
|
959
|
+
const until = performance.now() + timeoutMs;
|
|
960
|
+
while (true) {
|
|
961
|
+
const left = Math.ceil(until - performance.now());
|
|
962
|
+
if (left <= 0)
|
|
963
|
+
throw new TimeoutError("Timeout expired");
|
|
964
|
+
const result = await this.native.exec(argv, { timeoutMs: left });
|
|
965
|
+
if (result.exitCode === 0)
|
|
966
|
+
return;
|
|
967
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(probe.intervalMs, left)));
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
async tunnels(timeoutMs = 50_000) {
|
|
971
|
+
this.attached();
|
|
972
|
+
const tunnels = {};
|
|
973
|
+
for (const p of await this.native.previews.list({ timeoutMs }))
|
|
974
|
+
tunnels[p.port] = new Tunnel(new URL(p.url).hostname, 443);
|
|
975
|
+
return tunnels;
|
|
421
976
|
}
|
|
422
977
|
async updateNetworkPolicy(params) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
978
|
+
this.attached();
|
|
979
|
+
only("Modal network policy", params, ["outboundCidrAllowlist", "outboundDomainAllowlist"]);
|
|
980
|
+
if (!params.outboundCidrAllowlist || !params.outboundDomainAllowlist)
|
|
981
|
+
throw new InvalidError("Both outboundCidrAllowlist and outboundDomainAllowlist must be provided");
|
|
982
|
+
await this.native.network.set(egress(params.outboundDomainAllowlist, params.outboundCidrAllowlist));
|
|
427
983
|
}
|
|
984
|
+
/** Runtime volumes are block devices with one writer: a read-write mount
|
|
985
|
+
* always shows the latest writes. A read-only mount is a copy taken at
|
|
986
|
+
* start, which cannot reload. */
|
|
987
|
+
async reloadVolumes(params = {}) {
|
|
988
|
+
this.attached();
|
|
989
|
+
only("Modal reload volumes", params, ["timeoutMs"]);
|
|
990
|
+
if (this.native.info.labels["modal.readonly"])
|
|
991
|
+
throw new CompatibilityError("Modal", "reloading a read-only volume; it is a copy taken at start");
|
|
992
|
+
}
|
|
993
|
+
/** The whole sandbox, memory and running processes included. */
|
|
428
994
|
async experimentalSnapshot() {
|
|
429
|
-
|
|
430
|
-
|
|
995
|
+
this.attached();
|
|
996
|
+
const snapshot = await modal(() => this.native.snapshot({
|
|
997
|
+
labels: Object.fromEntries(Object.entries(this.native.info.labels).filter(([k]) => k.startsWith("modal."))),
|
|
998
|
+
})).catch((error) => {
|
|
999
|
+
throw error instanceof Error && !(error instanceof NotFoundError)
|
|
1000
|
+
? new SnapshotCreationError(error.message)
|
|
1001
|
+
: error;
|
|
1002
|
+
});
|
|
1003
|
+
return new SandboxSnapshot(snapshot.id);
|
|
1004
|
+
}
|
|
1005
|
+
/** The sandbox's files as an Image new sandboxes start from. `ttlMs` is
|
|
1006
|
+
* kept in whole days. */
|
|
1007
|
+
async snapshotFilesystem(params = {}) {
|
|
1008
|
+
this.attached();
|
|
1009
|
+
only("Modal filesystem snapshot", params, ["timeoutMs", "ttlMs"]);
|
|
1010
|
+
const day = 86_400_000;
|
|
1011
|
+
if (params.ttlMs === null ||
|
|
1012
|
+
(params.ttlMs !== undefined && (!Number.isInteger(params.ttlMs / day) || params.ttlMs <= 0)))
|
|
1013
|
+
throw new CompatibilityError("Modal", "ttlMs that is null or not whole days; Runtime keeps snapshots for a number of days");
|
|
1014
|
+
const snapshot = await this.native
|
|
1015
|
+
.snapshot({
|
|
1016
|
+
mode: "disk",
|
|
1017
|
+
...(params.ttlMs === undefined ? {} : { retentionDays: params.ttlMs / day }),
|
|
1018
|
+
...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }),
|
|
1019
|
+
})
|
|
1020
|
+
.catch((error) => {
|
|
1021
|
+
throw error instanceof RuntimeError && error.code === "timeout"
|
|
1022
|
+
? new TimeoutError(error.message)
|
|
1023
|
+
: new SnapshotCreationError(error instanceof Error ? error.message : String(error));
|
|
1024
|
+
});
|
|
1025
|
+
return new Image(undefined, undefined, snapshot.id, [], {}, true, true);
|
|
431
1026
|
}
|
|
432
1027
|
}
|
|
1028
|
+
refuse("Modal", "Sandbox", Sandbox, {
|
|
1029
|
+
createConnectToken: "a private preview: sandbox.native.previews.create(port), sending its token as x-runtime-preview-token",
|
|
1030
|
+
experimentalGetExitSnapshot: NEW_SANDBOX_FROM_SNAPSHOT,
|
|
1031
|
+
snapshotDirectory: NEW_SANDBOX_FROM_SNAPSHOT,
|
|
1032
|
+
mountImage: NEW_SANDBOX_FROM_SNAPSHOT,
|
|
1033
|
+
unmountImage: NEW_SANDBOX_FROM_SNAPSHOT,
|
|
1034
|
+
experimentalUpdateOutboundPolicy: "updateNetworkPolicy for allowlists; runtime.secrets.set({ value, hosts, header }) for header replacement",
|
|
1035
|
+
});
|
|
1036
|
+
const WATCHED = {
|
|
1037
|
+
Access: ["access"],
|
|
1038
|
+
Create: ["create"],
|
|
1039
|
+
Modify: ["write", "chmod", "rename"],
|
|
1040
|
+
Remove: ["remove"],
|
|
1041
|
+
};
|
|
433
1042
|
export class SandboxFilesystem {
|
|
434
1043
|
native;
|
|
435
1044
|
files;
|
|
@@ -438,36 +1047,35 @@ export class SandboxFilesystem {
|
|
|
438
1047
|
this.files = new GuestFiles(async () => native, "/");
|
|
439
1048
|
}
|
|
440
1049
|
readBytes(path) {
|
|
441
|
-
return this.files.readFile(path);
|
|
1050
|
+
return fileCall(path, () => this.files.readFile(path));
|
|
442
1051
|
}
|
|
443
1052
|
readText(path) {
|
|
444
|
-
return this.files.readTextFile(path);
|
|
1053
|
+
return fileCall(path, () => this.files.readTextFile(path));
|
|
445
1054
|
}
|
|
446
1055
|
writeBytes(content, path) {
|
|
447
|
-
return this.files.writeFile(path, content instanceof Uint8Array ? content : new Uint8Array(content));
|
|
1056
|
+
return fileCall(path, () => this.files.writeFile(path, content instanceof Uint8Array ? content : new Uint8Array(content)));
|
|
448
1057
|
}
|
|
449
1058
|
writeText(content, path) {
|
|
450
|
-
return this.files.writeTextFile(path, content);
|
|
1059
|
+
return fileCall(path, () => this.files.writeTextFile(path, content));
|
|
451
1060
|
}
|
|
452
1061
|
makeDirectory(path, options = {}) {
|
|
453
|
-
return this.files.mkdir(path, options.createParents ?? true);
|
|
1062
|
+
return fileCall(path, () => this.files.mkdir(path, options.createParents ?? true));
|
|
454
1063
|
}
|
|
455
1064
|
remove(path, options = {}) {
|
|
456
|
-
return this.files.remove(path, options.recursive);
|
|
1065
|
+
return fileCall(path, () => this.files.remove(path, options.recursive));
|
|
457
1066
|
}
|
|
458
1067
|
copyFromLocal(localPath, remotePath) {
|
|
459
|
-
return this.native.files.upload(localPath, remotePath);
|
|
1068
|
+
return fileCall(remotePath, () => this.native.files.upload(localPath, remotePath));
|
|
460
1069
|
}
|
|
461
1070
|
copyToLocal(remotePath, localPath) {
|
|
462
|
-
return this.native.files.download(remotePath, localPath);
|
|
1071
|
+
return fileCall(remotePath, () => this.native.files.download(remotePath, localPath));
|
|
463
1072
|
}
|
|
464
1073
|
async listFiles(path) {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
.map((e) => this.stat(e.path)));
|
|
1074
|
+
const entries = await fileCall(path, () => this.files.readdir(path));
|
|
1075
|
+
return Promise.all(entries.sort((a, b) => a.name.localeCompare(b.name)).map((e) => this.stat(e.path)));
|
|
468
1076
|
}
|
|
469
1077
|
async stat(path) {
|
|
470
|
-
const e = await this.files.stat(path);
|
|
1078
|
+
const e = await fileCall(path, () => this.files.stat(path));
|
|
471
1079
|
const r = await this.native.exec(["stat", "-c", "%U:%G", "--", this.files.path(path)], {
|
|
472
1080
|
check: true,
|
|
473
1081
|
});
|
|
@@ -487,4 +1095,56 @@ export class SandboxFilesystem {
|
|
|
487
1095
|
: null,
|
|
488
1096
|
};
|
|
489
1097
|
}
|
|
1098
|
+
/** Changes under `remotePath` until `timeoutMs` passes or the loop stops. */
|
|
1099
|
+
async *watch(remotePath, params = {}) {
|
|
1100
|
+
only("Modal watch", params, ["filter", "recursive", "timeoutMs"]);
|
|
1101
|
+
if (!remotePath.startsWith("/"))
|
|
1102
|
+
throw new InvalidError(`watch() expects an absolute path, got ${remotePath}`);
|
|
1103
|
+
const types = (params.filter ?? ["Access", "Create", "Modify", "Remove"]).flatMap((t) => (t === "Unknown" ? [] : WATCHED[t]));
|
|
1104
|
+
const queue = [];
|
|
1105
|
+
let ended = false;
|
|
1106
|
+
let wake;
|
|
1107
|
+
const notify = () => {
|
|
1108
|
+
wake?.();
|
|
1109
|
+
wake = undefined;
|
|
1110
|
+
};
|
|
1111
|
+
const handle = await fileCall(remotePath, () => this.native.files.watch(remotePath, undefined, {
|
|
1112
|
+
recursive: params.recursive ?? false,
|
|
1113
|
+
events: [...new Set(types)],
|
|
1114
|
+
timeoutMs: params.timeoutMs ?? 0,
|
|
1115
|
+
onBatch: (events) => {
|
|
1116
|
+
for (const e of events)
|
|
1117
|
+
queue.push({
|
|
1118
|
+
eventType: e.type === "create"
|
|
1119
|
+
? "Create"
|
|
1120
|
+
: e.type === "remove"
|
|
1121
|
+
? "Remove"
|
|
1122
|
+
: e.type === "access"
|
|
1123
|
+
? "Access"
|
|
1124
|
+
: "Modify",
|
|
1125
|
+
paths: e.type === "rename" && e.oldPath ? [e.oldPath, e.path] : [e.path],
|
|
1126
|
+
});
|
|
1127
|
+
notify();
|
|
1128
|
+
},
|
|
1129
|
+
onExit: () => {
|
|
1130
|
+
ended = true;
|
|
1131
|
+
notify();
|
|
1132
|
+
},
|
|
1133
|
+
}));
|
|
1134
|
+
try {
|
|
1135
|
+
while (true) {
|
|
1136
|
+
const next = queue.shift();
|
|
1137
|
+
if (next)
|
|
1138
|
+
yield next;
|
|
1139
|
+
else if (ended)
|
|
1140
|
+
return;
|
|
1141
|
+
else
|
|
1142
|
+
await new Promise((resolve) => (wake = resolve));
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
finally {
|
|
1146
|
+
if (!ended)
|
|
1147
|
+
await handle.stop().catch(() => undefined);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
490
1150
|
}
|