@alchemy.run/archil 0.0.0 → 2.0.0-beta.86
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/LICENSE +201 -0
- package/README.md +57 -4
- package/lib/AWS/S3Disk.d.ts +68 -0
- package/lib/AWS/S3Disk.d.ts.map +1 -0
- package/lib/AWS/S3Disk.js +100 -0
- package/lib/AWS/S3Disk.js.map +1 -0
- package/lib/AWS/index.d.ts +2 -0
- package/lib/AWS/index.d.ts.map +1 -0
- package/lib/AWS/index.js +2 -0
- package/lib/AWS/index.js.map +1 -0
- package/lib/ApiToken.d.ts +63 -0
- package/lib/ApiToken.d.ts.map +1 -0
- package/lib/ApiToken.js +121 -0
- package/lib/ApiToken.js.map +1 -0
- package/lib/ArchilBindings.d.ts +65 -0
- package/lib/ArchilBindings.d.ts.map +1 -0
- package/lib/ArchilBindings.js +128 -0
- package/lib/ArchilBindings.js.map +1 -0
- package/lib/AuthProvider.d.ts +40 -0
- package/lib/AuthProvider.d.ts.map +1 -0
- package/lib/AuthProvider.js +87 -0
- package/lib/AuthProvider.js.map +1 -0
- package/lib/Bootstrap.d.ts +8 -0
- package/lib/Bootstrap.d.ts.map +1 -0
- package/lib/Bootstrap.js +27 -0
- package/lib/Bootstrap.js.map +1 -0
- package/lib/Branch.d.ts +101 -0
- package/lib/Branch.d.ts.map +1 -0
- package/lib/Branch.js +117 -0
- package/lib/Branch.js.map +1 -0
- package/lib/BranchDisk.d.ts +42 -0
- package/lib/BranchDisk.d.ts.map +1 -0
- package/lib/BranchDisk.js +14 -0
- package/lib/BranchDisk.js.map +1 -0
- package/lib/Checkpoint.d.ts +78 -0
- package/lib/Checkpoint.d.ts.map +1 -0
- package/lib/Checkpoint.js +95 -0
- package/lib/Checkpoint.js.map +1 -0
- package/lib/Clients.d.ts +369 -0
- package/lib/Clients.d.ts.map +1 -0
- package/lib/Clients.js +334 -0
- package/lib/Clients.js.map +1 -0
- package/lib/ConnectSandbox.d.ts +69 -0
- package/lib/ConnectSandbox.d.ts.map +1 -0
- package/lib/ConnectSandbox.js +21 -0
- package/lib/ConnectSandbox.js.map +1 -0
- package/lib/Contributions.d.ts +7 -0
- package/lib/Contributions.d.ts.map +1 -0
- package/lib/Contributions.js +6 -0
- package/lib/Contributions.js.map +1 -0
- package/lib/CreateDisk.d.ts +90 -0
- package/lib/CreateDisk.d.ts.map +1 -0
- package/lib/CreateDisk.js +47 -0
- package/lib/CreateDisk.js.map +1 -0
- package/lib/CreateImage.d.ts +42 -0
- package/lib/CreateImage.d.ts.map +1 -0
- package/lib/CreateImage.js +19 -0
- package/lib/CreateImage.js.map +1 -0
- package/lib/CreateSandbox.d.ts +104 -0
- package/lib/CreateSandbox.d.ts.map +1 -0
- package/lib/CreateSandbox.js +98 -0
- package/lib/CreateSandbox.js.map +1 -0
- package/lib/Credentials.d.ts +44 -0
- package/lib/Credentials.d.ts.map +1 -0
- package/lib/Credentials.js +57 -0
- package/lib/Credentials.js.map +1 -0
- package/lib/Disk.d.ts +283 -0
- package/lib/Disk.d.ts.map +1 -0
- package/lib/Disk.js +294 -0
- package/lib/Disk.js.map +1 -0
- package/lib/DiskObjectsHttp.d.ts +20 -0
- package/lib/DiskObjectsHttp.d.ts.map +1 -0
- package/lib/DiskObjectsHttp.js +51 -0
- package/lib/DiskObjectsHttp.js.map +1 -0
- package/lib/DiskUser.d.ts +189 -0
- package/lib/DiskUser.d.ts.map +1 -0
- package/lib/DiskUser.js +243 -0
- package/lib/DiskUser.js.map +1 -0
- package/lib/ExecDisk.d.ts +57 -0
- package/lib/ExecDisk.d.ts.map +1 -0
- package/lib/ExecDisk.js +3 -0
- package/lib/ExecDisk.js.map +1 -0
- package/lib/ExecDiskHttp.d.ts +5 -0
- package/lib/ExecDiskHttp.d.ts.map +1 -0
- package/lib/ExecDiskHttp.js +18 -0
- package/lib/ExecDiskHttp.js.map +1 -0
- package/lib/ExecDisks.d.ts +54 -0
- package/lib/ExecDisks.d.ts.map +1 -0
- package/lib/ExecDisks.js +37 -0
- package/lib/ExecDisks.js.map +1 -0
- package/lib/Image.d.ts +188 -0
- package/lib/Image.d.ts.map +1 -0
- package/lib/Image.js +191 -0
- package/lib/Image.js.map +1 -0
- package/lib/InferEnv.d.ts +26 -0
- package/lib/InferEnv.d.ts.map +1 -0
- package/lib/InferEnv.js +1 -0
- package/lib/InferEnv.js.map +1 -0
- package/lib/MountDisk.d.ts +190 -0
- package/lib/MountDisk.d.ts.map +1 -0
- package/lib/MountDisk.js +227 -0
- package/lib/MountDisk.js.map +1 -0
- package/lib/Providers.d.ts +25 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +48 -0
- package/lib/Providers.js.map +1 -0
- package/lib/ReadDisk.d.ts +55 -0
- package/lib/ReadDisk.d.ts.map +1 -0
- package/lib/ReadDisk.js +3 -0
- package/lib/ReadDisk.js.map +1 -0
- package/lib/ReadWriteDisk.d.ts +26 -0
- package/lib/ReadWriteDisk.d.ts.map +1 -0
- package/lib/ReadWriteDisk.js +3 -0
- package/lib/ReadWriteDisk.js.map +1 -0
- package/lib/Sandbox.d.ts +507 -0
- package/lib/Sandbox.d.ts.map +1 -0
- package/lib/Sandbox.js +729 -0
- package/lib/Sandbox.js.map +1 -0
- package/lib/SandboxOptions.d.ts +185 -0
- package/lib/SandboxOptions.d.ts.map +1 -0
- package/lib/SandboxOptions.js +102 -0
- package/lib/SandboxOptions.js.map +1 -0
- package/lib/SandboxPortToken.d.ts +64 -0
- package/lib/SandboxPortToken.d.ts.map +1 -0
- package/lib/SandboxPortToken.js +77 -0
- package/lib/SandboxPortToken.js.map +1 -0
- package/lib/WriteDisk.d.ts +37 -0
- package/lib/WriteDisk.d.ts.map +1 -0
- package/lib/WriteDisk.js +3 -0
- package/lib/WriteDisk.js.map +1 -0
- package/lib/index.d.ts +29 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +28 -0
- package/lib/index.js.map +1 -0
- package/lib/internal/DeployerKey.d.ts +21 -0
- package/lib/internal/DeployerKey.d.ts.map +1 -0
- package/lib/internal/DeployerKey.js +34 -0
- package/lib/internal/DeployerKey.js.map +1 -0
- package/lib/internal/archil.d.ts +87 -0
- package/lib/internal/archil.d.ts.map +1 -0
- package/lib/internal/archil.js +154 -0
- package/lib/internal/archil.js.map +1 -0
- package/lib/internal/hosted.d.ts +90 -0
- package/lib/internal/hosted.d.ts.map +1 -0
- package/lib/internal/hosted.js +291 -0
- package/lib/internal/hosted.js.map +1 -0
- package/package.json +79 -3
- package/src/AWS/S3Disk.ts +125 -0
- package/src/AWS/index.ts +1 -0
- package/src/ApiToken.ts +168 -0
- package/src/ArchilBindings.ts +179 -0
- package/src/AuthProvider.ts +122 -0
- package/src/Bootstrap.ts +54 -0
- package/src/Branch.ts +199 -0
- package/src/BranchDisk.ts +57 -0
- package/src/Checkpoint.ts +145 -0
- package/src/Clients.ts +954 -0
- package/src/ConnectSandbox.ts +91 -0
- package/src/Contributions.ts +9 -0
- package/src/CreateDisk.ts +165 -0
- package/src/CreateImage.ts +73 -0
- package/src/CreateSandbox.ts +258 -0
- package/src/Credentials.ts +101 -0
- package/src/Disk.ts +468 -0
- package/src/DiskObjectsHttp.ts +64 -0
- package/src/DiskUser.ts +369 -0
- package/src/ExecDisk.ts +62 -0
- package/src/ExecDiskHttp.ts +25 -0
- package/src/ExecDisks.ts +100 -0
- package/src/Image.ts +316 -0
- package/src/InferEnv.ts +27 -0
- package/src/MountDisk.ts +466 -0
- package/src/Providers.ts +75 -0
- package/src/ReadDisk.ts +61 -0
- package/src/ReadWriteDisk.ts +31 -0
- package/src/Sandbox.ts +1064 -0
- package/src/SandboxOptions.ts +230 -0
- package/src/SandboxPortToken.ts +125 -0
- package/src/WriteDisk.ts +42 -0
- package/src/globals.d.ts +2 -0
- package/src/index.ts +59 -0
- package/src/internal/DeployerKey.ts +63 -0
- package/src/internal/archil.ts +273 -0
- package/src/internal/hosted.ts +402 -0
package/src/Sandbox.ts
ADDED
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
import * as BuildPlan from "@alchemy.run/docker/BuildPlan";
|
|
2
|
+
import * as archil from "@distilled.cloud/archil";
|
|
3
|
+
import * as Process from "@distilled.cloud/archil/Process";
|
|
4
|
+
import { Unowned } from "alchemy/AdoptPolicy";
|
|
5
|
+
import * as Bundle from "alchemy/Bundle/Bundle";
|
|
6
|
+
import { deepEqual, isResolved } from "alchemy/Diff";
|
|
7
|
+
import {
|
|
8
|
+
checkMountSupport,
|
|
9
|
+
collectMountCapabilities,
|
|
10
|
+
mountEnv,
|
|
11
|
+
mountInstallLines,
|
|
12
|
+
type MountCapability,
|
|
13
|
+
} from "alchemy/Mount";
|
|
14
|
+
import { Platform, type Main, type PlatformProps } from "alchemy/Platform";
|
|
15
|
+
import * as Provider from "alchemy/Provider";
|
|
16
|
+
import type { LookupMethod, Resource, ResourceBinding } from "alchemy/Resource";
|
|
17
|
+
import {
|
|
18
|
+
createHostRuntimeContext,
|
|
19
|
+
type HostRuntimeContext,
|
|
20
|
+
type ServerHost,
|
|
21
|
+
} from "alchemy/Runtime/Process";
|
|
22
|
+
import { Stack } from "alchemy/Stack";
|
|
23
|
+
import { sha256Object } from "alchemy/Util/sha256";
|
|
24
|
+
import * as Data from "effect/Data";
|
|
25
|
+
import * as Effect from "effect/Effect";
|
|
26
|
+
import type * as Redacted from "effect/Redacted";
|
|
27
|
+
import type { Disk } from "./Disk.ts";
|
|
28
|
+
import {
|
|
29
|
+
capabilities as imageCapabilities,
|
|
30
|
+
ImageRequiresBuild,
|
|
31
|
+
publishPlan,
|
|
32
|
+
pullImage,
|
|
33
|
+
type ImagePush,
|
|
34
|
+
} from "./Image.ts";
|
|
35
|
+
import {
|
|
36
|
+
deletableSandboxStates,
|
|
37
|
+
findSandboxByName,
|
|
38
|
+
getSandbox,
|
|
39
|
+
isAlchemyName,
|
|
40
|
+
isRuntimeObjectName,
|
|
41
|
+
listSandboxes,
|
|
42
|
+
sandboxName,
|
|
43
|
+
waitForSandbox,
|
|
44
|
+
waitForSandboxGone,
|
|
45
|
+
waitForSandboxStopped,
|
|
46
|
+
} from "./internal/archil.ts";
|
|
47
|
+
import {
|
|
48
|
+
bundleProgram,
|
|
49
|
+
deployProgram,
|
|
50
|
+
removeProgram,
|
|
51
|
+
type ProgramBuildOptions,
|
|
52
|
+
type ProgramExtraFile,
|
|
53
|
+
} from "./internal/hosted.ts";
|
|
54
|
+
import type { Providers } from "./Providers.ts";
|
|
55
|
+
import {
|
|
56
|
+
egressIdentity,
|
|
57
|
+
MountDiskUnresolved,
|
|
58
|
+
mountDiskId,
|
|
59
|
+
mountIdentity,
|
|
60
|
+
splitArchilEgress,
|
|
61
|
+
toEgressPolicy,
|
|
62
|
+
toEnv,
|
|
63
|
+
toSandboxMount,
|
|
64
|
+
type DiskMount,
|
|
65
|
+
type DiskMountOptions,
|
|
66
|
+
type SandboxEgress,
|
|
67
|
+
type SandboxEndpoint,
|
|
68
|
+
} from "./SandboxOptions.ts";
|
|
69
|
+
|
|
70
|
+
/** A disk a binding mounts into a program sandbox (`MountDisk` inside one). */
|
|
71
|
+
|
|
72
|
+
/** A sandbox's `run` command exited non-zero. */
|
|
73
|
+
export class SandboxSetupFailed extends Data.TaggedError("Archil.SandboxSetupFailed")<{
|
|
74
|
+
readonly sandbox: string;
|
|
75
|
+
readonly command: string;
|
|
76
|
+
readonly exitCode: number;
|
|
77
|
+
readonly stderr: string;
|
|
78
|
+
}> {
|
|
79
|
+
override get message() {
|
|
80
|
+
return `\`${this.command}\` exited ${this.exitCode} in sandbox ${this.sandbox}: ${this.stderr.slice(-2000)}`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface SandboxMountBinding extends DiskMountOptions {
|
|
85
|
+
readonly diskId: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What bindings attach to a sandbox: its program's environment, disks it
|
|
90
|
+
* mounts natively (`SandboxMountBinding`), and filesystem clients other
|
|
91
|
+
* mount bindings install into its image (`MountCapability`, from
|
|
92
|
+
* `alchemy/Mount`).
|
|
93
|
+
*/
|
|
94
|
+
export interface SandboxBinding {
|
|
95
|
+
env?: Record<string, any>;
|
|
96
|
+
mounts?: ReadonlyArray<SandboxMountBinding | MountCapability>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface SandboxProps extends PlatformProps {
|
|
100
|
+
/**
|
|
101
|
+
* Sandbox name, unique within your account: lowercase letters, digits
|
|
102
|
+
* and `-`, at most 63 characters. If omitted, a unique name is generated
|
|
103
|
+
* from the stack, stage, and logical ID. Changing it replaces the sandbox.
|
|
104
|
+
*/
|
|
105
|
+
name?: string;
|
|
106
|
+
/**
|
|
107
|
+
* Image id from an {@link Image} (e.g. `image.imageId`), for a private
|
|
108
|
+
* image or a pinned digest. Exclusive with `baseImage`. Changing it
|
|
109
|
+
* replaces the sandbox.
|
|
110
|
+
*/
|
|
111
|
+
image?: string;
|
|
112
|
+
/**
|
|
113
|
+
* Public OCI image reference with a `linux/amd64` manifest. Exclusive
|
|
114
|
+
* with `image`. Changing it replaces the sandbox.
|
|
115
|
+
* @default "node:24-bookworm"
|
|
116
|
+
*/
|
|
117
|
+
baseImage?: string;
|
|
118
|
+
/** vCPUs, 1–32. Changing it replaces the sandbox. @default 1 */
|
|
119
|
+
vcpuCount?: number;
|
|
120
|
+
/** Memory in MiB, 256–65536. Changing it replaces the sandbox. @default 2048 */
|
|
121
|
+
memSizeMiB?: number;
|
|
122
|
+
/**
|
|
123
|
+
* TCP ports to serve publicly. Updated in place. When set, it is the
|
|
124
|
+
* whole list (plus the program's `port`): ports not in it are unexposed.
|
|
125
|
+
*/
|
|
126
|
+
ports?: number[];
|
|
127
|
+
/**
|
|
128
|
+
* Let services inside the sandbox publish their own ingress.
|
|
129
|
+
* Changing it replaces the sandbox. @default false
|
|
130
|
+
*/
|
|
131
|
+
enableServiceIngress?: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Environment variables. With `main`, they are the program's environment
|
|
134
|
+
* and a change restarts it. Without `main`, every process in the sandbox
|
|
135
|
+
* gets them, and a change replaces the sandbox.
|
|
136
|
+
*/
|
|
137
|
+
env?: Record<string, string | Redacted.Redacted<string>>;
|
|
138
|
+
/** Outbound network policy. Updated in place. */
|
|
139
|
+
network?: { egress?: SandboxEgress };
|
|
140
|
+
/**
|
|
141
|
+
* Disks to mount: a declared `Archil.Disk` (or the Effect that declares
|
|
142
|
+
* it) at a `path`. Updated in place: the sandbox stops, then starts with
|
|
143
|
+
* the new mounts, keeping its root filesystem.
|
|
144
|
+
*/
|
|
145
|
+
mounts?: DiskMount[];
|
|
146
|
+
/**
|
|
147
|
+
* Seconds a powered-on session may run before the sandbox pauses
|
|
148
|
+
* (at most 24 hours). Changing it replaces the sandbox.
|
|
149
|
+
*/
|
|
150
|
+
maxTtlSeconds?: number;
|
|
151
|
+
/**
|
|
152
|
+
* Seconds without a process connection before the sandbox pauses.
|
|
153
|
+
* `0` disables idle expiry. Updated in place.
|
|
154
|
+
*/
|
|
155
|
+
idleTtlSeconds?: number;
|
|
156
|
+
/** Concurrent processes allowed. Changing it replaces the sandbox. */
|
|
157
|
+
maxConcurrentExecs?: number;
|
|
158
|
+
/**
|
|
159
|
+
* Shell commands run in the sandbox, in order, once it is created: clone
|
|
160
|
+
* a repository, install packages, warm a cache. A non-zero exit fails the
|
|
161
|
+
* deploy with `SandboxSetupFailed`. Changing them replaces the sandbox,
|
|
162
|
+
* so it starts again from its image and runs the new commands. Fork the
|
|
163
|
+
* result at run time with `Archil.CreateSandbox(sandbox)`.
|
|
164
|
+
*/
|
|
165
|
+
run?: string[];
|
|
166
|
+
/**
|
|
167
|
+
* A program to run in the sandbox. In the Effect form, `import.meta.url`
|
|
168
|
+
* (the file is both the bundle entry and the program). In the async form,
|
|
169
|
+
* a path to a module whose default export has `fetch(request, env)`.
|
|
170
|
+
* A code change uploads it and restarts the program in place.
|
|
171
|
+
*/
|
|
172
|
+
main?: string;
|
|
173
|
+
/** Named export to load from `main`. @default "default" */
|
|
174
|
+
handler?: string;
|
|
175
|
+
/**
|
|
176
|
+
* TCP port the program listens on, passed to it as `PORT`. Alchemy
|
|
177
|
+
* exposes it publicly and returns its URL as `url`.
|
|
178
|
+
* @default 3000
|
|
179
|
+
*/
|
|
180
|
+
port?: number;
|
|
181
|
+
/**
|
|
182
|
+
* Bundler options for `main` (rolldown `input` / `output`), and
|
|
183
|
+
* `install`: packages to `npm install` next to the program instead of
|
|
184
|
+
* bundling them.
|
|
185
|
+
*/
|
|
186
|
+
build?: ProgramBuildOptions;
|
|
187
|
+
/**
|
|
188
|
+
* Host files or directories uploaded with the program, e.g. a site's
|
|
189
|
+
* built assets. With `extraFiles`, an async `main` is treated as a
|
|
190
|
+
* complete Node program and uploaded as built instead of bundled.
|
|
191
|
+
* Their content is part of `code.hash`, so a change redeploys.
|
|
192
|
+
*/
|
|
193
|
+
extraFiles?: ReadonlyArray<ProgramExtraFile>;
|
|
194
|
+
/**
|
|
195
|
+
* Where Alchemy pushes the sandbox's derived image, for Archil to pull.
|
|
196
|
+
* Only needed when a binding installs a filesystem client (an
|
|
197
|
+
* `alchemy/Mount` capability) into the program's image: Alchemy builds
|
|
198
|
+
* the base image plus those install lines with your local Docker.
|
|
199
|
+
*/
|
|
200
|
+
push?: ImagePush;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface SandboxAttributes {
|
|
204
|
+
/** Sandbox id. */
|
|
205
|
+
sandboxId: string;
|
|
206
|
+
/** Sandbox name. */
|
|
207
|
+
name: string;
|
|
208
|
+
/** Lifecycle state when last observed, e.g. `running` or `paused`. */
|
|
209
|
+
status: string;
|
|
210
|
+
/** The image reference the sandbox boots. */
|
|
211
|
+
baseImage: string;
|
|
212
|
+
/** Digest of the booted image. */
|
|
213
|
+
imageDigest: string | undefined;
|
|
214
|
+
vcpuCount: number;
|
|
215
|
+
memSizeMiB: number;
|
|
216
|
+
/** Public hostnames of the exposed ports. */
|
|
217
|
+
endpoints: SandboxEndpoint[];
|
|
218
|
+
/** `https://` URL of the program's port, or of the first exposed port. */
|
|
219
|
+
url: string | undefined;
|
|
220
|
+
/** The port the program listens on, when the sandbox runs one. */
|
|
221
|
+
port: number | undefined;
|
|
222
|
+
/** Content hash of the deployed program, when the sandbox runs one. */
|
|
223
|
+
code: { hash: string } | undefined;
|
|
224
|
+
/**
|
|
225
|
+
* The image Alchemy built for the sandbox (its base plus the install
|
|
226
|
+
* lines its bindings contribute), when it needed one.
|
|
227
|
+
*/
|
|
228
|
+
derivedImage: { imageId: string; hash: string } | undefined;
|
|
229
|
+
/** Hash of the `run` commands the sandbox was set up with. */
|
|
230
|
+
setupHash: string | undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export type Sandbox = Resource<
|
|
234
|
+
"Archil.Sandbox",
|
|
235
|
+
SandboxProps,
|
|
236
|
+
SandboxAttributes,
|
|
237
|
+
SandboxBinding,
|
|
238
|
+
Providers,
|
|
239
|
+
SandboxLookup
|
|
240
|
+
>;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Props for {@link Sandbox.lookup}: find a sandbox this stack
|
|
244
|
+
* doesn't manage by its name.
|
|
245
|
+
*/
|
|
246
|
+
export interface SandboxLookup {
|
|
247
|
+
/** Sandbox name. */
|
|
248
|
+
name: string;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export type SandboxShape = Main<ServerHost>;
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* An Archil sandbox: a Firecracker microVM with disks mounted, public
|
|
255
|
+
* ports, and an outbound network policy that can inject credentials at the
|
|
256
|
+
* boundary. It pauses when idle and resumes on the next connection,
|
|
257
|
+
* keeping its memory.
|
|
258
|
+
*
|
|
259
|
+
* Declare one as a box (a dev box or a preview environment that outlives
|
|
260
|
+
* deploys), or give it a `main` and it runs your program: Alchemy bundles
|
|
261
|
+
* it, uploads it, and registers it with Archil's service manager, which
|
|
262
|
+
* restarts it when it crashes and starts it whenever the sandbox boots. Its
|
|
263
|
+
* port gets a public `https://` URL, and a request to a stopped sandbox
|
|
264
|
+
* starts it again, typically in a few seconds. A code change uploads the
|
|
265
|
+
* program and restarts it in place.
|
|
266
|
+
*
|
|
267
|
+
* Short-lived sandboxes for one user or task are created at run time with
|
|
268
|
+
* {@link CreateSandbox}; act on a declared one with {@link ConnectSandbox}.
|
|
269
|
+
*
|
|
270
|
+
* :::caution
|
|
271
|
+
* Changing the image, size, timeouts, or the `env` of a sandbox without
|
|
272
|
+
* `main` replaces the sandbox, and a new one starts with an empty root
|
|
273
|
+
* filesystem. Keep data on a mounted disk. `mounts`, `ports`, `network`
|
|
274
|
+
* and the program update in place.
|
|
275
|
+
* :::
|
|
276
|
+
*
|
|
277
|
+
* :::caution
|
|
278
|
+
* An exposed port is public: anyone with the URL can call it. Check
|
|
279
|
+
* requests yourself, or hand clients an `Archil.SandboxPortToken`.
|
|
280
|
+
* :::
|
|
281
|
+
* @see https://docs.archil.com/compute/sandboxes/introduction
|
|
282
|
+
*
|
|
283
|
+
* ### Creating a Sandbox
|
|
284
|
+
* **Example:** A dev box with a disk mounted
|
|
285
|
+
* ```typescript
|
|
286
|
+
* export const Workspace = Archil.Disk("Workspace", {});
|
|
287
|
+
* export const DevBox = Archil.Sandbox("DevBox", {
|
|
288
|
+
* baseImage: "node:24-bookworm",
|
|
289
|
+
* vcpuCount: 2,
|
|
290
|
+
* memSizeMiB: 4096,
|
|
291
|
+
* mounts: [{ disk: Workspace, path: "/workspace" }],
|
|
292
|
+
* ports: [3000],
|
|
293
|
+
* idleTtlSeconds: 600,
|
|
294
|
+
* });
|
|
295
|
+
* ```
|
|
296
|
+
*
|
|
297
|
+
* **Example:** A bigger sandbox that never pauses while idle
|
|
298
|
+
* ```typescript
|
|
299
|
+
* export const Box = Archil.Sandbox("Box", {
|
|
300
|
+
* vcpuCount: 4,
|
|
301
|
+
* memSizeMiB: 8192,
|
|
302
|
+
* idleTtlSeconds: 0,
|
|
303
|
+
* });
|
|
304
|
+
* ```
|
|
305
|
+
*
|
|
306
|
+
* **Example:** Boot a private image
|
|
307
|
+
* ```typescript
|
|
308
|
+
* export const AgentImage = Archil.Image("AgentImage", {
|
|
309
|
+
* image: "ghcr.io/acme/agent:1",
|
|
310
|
+
* registry: { server: "ghcr.io", username: "acme", password: Config.Redacted("GHCR_TOKEN") },
|
|
311
|
+
* });
|
|
312
|
+
* export const Agent = Effect.gen(function* () {
|
|
313
|
+
* const image = yield* AgentImage;
|
|
314
|
+
* return yield* Archil.Sandbox("Agent", { image: image.imageId });
|
|
315
|
+
* });
|
|
316
|
+
* ```
|
|
317
|
+
*
|
|
318
|
+
* ### Setting Up at Deploy and Forking at Run Time
|
|
319
|
+
* **Example:** Clone a repository once, then fork the sandbox per task
|
|
320
|
+
* ```typescript
|
|
321
|
+
* export const Golden = Archil.Sandbox("Golden", {
|
|
322
|
+
* run: ["git clone --depth 1 https://github.com/sindresorhus/is /work/is"],
|
|
323
|
+
* });
|
|
324
|
+
*
|
|
325
|
+
* // in a Worker or program:
|
|
326
|
+
* const sandboxes = yield* Archil.CreateSandbox(Golden);
|
|
327
|
+
* const task = yield* sandboxes.create({ name: "task-1" });
|
|
328
|
+
* const result = yield* task.exec("git log -1", { cwd: "/work/is" });
|
|
329
|
+
* ```
|
|
330
|
+
*
|
|
331
|
+
* ### Running a Program
|
|
332
|
+
* **Example:** An Effect program that reads a mounted disk
|
|
333
|
+
* ```typescript
|
|
334
|
+
* // src/Api.ts
|
|
335
|
+
* import * as Archil from "@alchemy.run/archil";
|
|
336
|
+
* import * as Effect from "effect/Effect";
|
|
337
|
+
* import * as FileSystem from "effect/FileSystem";
|
|
338
|
+
* import * as HttpServerResponse from "effect/http/HttpServerResponse";
|
|
339
|
+
*
|
|
340
|
+
* export const Dataset = Archil.Disk("Dataset", {});
|
|
341
|
+
*
|
|
342
|
+
* export default class Api extends Archil.Sandbox<Api>()(
|
|
343
|
+
* "Api",
|
|
344
|
+
* { main: import.meta.url, port: 3000 },
|
|
345
|
+
* Effect.gen(function* () {
|
|
346
|
+
* const dataset = yield* Archil.MountDisk(Dataset, { path: "/data", mode: "read-only" });
|
|
347
|
+
* const fs = yield* FileSystem.FileSystem;
|
|
348
|
+
* return {
|
|
349
|
+
* fetch: Effect.gen(function* () {
|
|
350
|
+
* return yield* HttpServerResponse.json(yield* fs.readDirectory(dataset.path));
|
|
351
|
+
* }).pipe(Effect.orDie),
|
|
352
|
+
* };
|
|
353
|
+
* }).pipe(Effect.provide(Archil.MountDiskLive)),
|
|
354
|
+
* ) {}
|
|
355
|
+
* ```
|
|
356
|
+
*
|
|
357
|
+
* **Example:** A plain async handler with typed `env`
|
|
358
|
+
* ```typescript
|
|
359
|
+
* // src/infra.ts
|
|
360
|
+
* export const Hello = Archil.Sandbox("Hello", {
|
|
361
|
+
* main: "./src/hello.ts",
|
|
362
|
+
* env: { GREETING: "hello" },
|
|
363
|
+
* });
|
|
364
|
+
* export type HelloEnv = Archil.InferEnv<typeof Hello>;
|
|
365
|
+
* ```
|
|
366
|
+
*
|
|
367
|
+
* ```typescript
|
|
368
|
+
* // src/hello.ts
|
|
369
|
+
* import type { HelloEnv } from "./infra.ts";
|
|
370
|
+
*
|
|
371
|
+
* export default {
|
|
372
|
+
* async fetch(request: Request, env: HelloEnv) {
|
|
373
|
+
* return new Response(`${env.GREETING} from ${new URL(request.url).pathname}`);
|
|
374
|
+
* },
|
|
375
|
+
* };
|
|
376
|
+
* ```
|
|
377
|
+
*
|
|
378
|
+
* **Example:** Secrets in the program's environment
|
|
379
|
+
* ```typescript
|
|
380
|
+
* export const Billing = Archil.Sandbox("Billing", {
|
|
381
|
+
* main: "./src/billing.ts",
|
|
382
|
+
* env: { STRIPE_KEY: Config.Redacted("STRIPE_KEY") },
|
|
383
|
+
* });
|
|
384
|
+
* ```
|
|
385
|
+
*
|
|
386
|
+
* ### Mounting Disks
|
|
387
|
+
* Archil sandboxes mount disks natively. Each entry names the disk and a
|
|
388
|
+
* `path`, with Archil's own options: `mode` (`exclusive`, `conditional`
|
|
389
|
+
* or `read-only`), `subdirectory`, and `queueMs`.
|
|
390
|
+
*
|
|
391
|
+
* **Example:** Mount two disks, one read-only
|
|
392
|
+
* ```typescript
|
|
393
|
+
* export const Box = Archil.Sandbox("Box", {
|
|
394
|
+
* mounts: [
|
|
395
|
+
* { disk: Workspace, path: "/workspace" },
|
|
396
|
+
* { disk: Dataset, path: "/data", mode: "read-only" },
|
|
397
|
+
* ],
|
|
398
|
+
* });
|
|
399
|
+
* ```
|
|
400
|
+
*
|
|
401
|
+
* **Example:** Mount from the program with `MountDisk`
|
|
402
|
+
* ```typescript
|
|
403
|
+
* const uploads = yield* Archil.MountDisk(Uploads, { path: "/uploads", mode: "conditional" });
|
|
404
|
+
* // runtime: the disk is already mounted at uploads.path
|
|
405
|
+
* yield* fs.writeFileString(`${uploads.path}/${id}.txt`, body);
|
|
406
|
+
* ```
|
|
407
|
+
*
|
|
408
|
+
* ### Mount Clients From Other Bindings
|
|
409
|
+
* A binding that installs a filesystem client (an `alchemy/Mount`
|
|
410
|
+
* capability) into a program's image makes Alchemy build one: the
|
|
411
|
+
* sandbox's base plus the client's install lines, built with your local
|
|
412
|
+
* Docker and pushed to `push.repository` for Archil to pull. A different
|
|
413
|
+
* set of clients replaces the sandbox.
|
|
414
|
+
*
|
|
415
|
+
* **Example:** Give a program sandbox a repository for its derived image
|
|
416
|
+
* ```typescript
|
|
417
|
+
* export default class Indexer extends Archil.Sandbox<Indexer>()(
|
|
418
|
+
* "Indexer",
|
|
419
|
+
* {
|
|
420
|
+
* main: import.meta.url,
|
|
421
|
+
* push: {
|
|
422
|
+
* repository: "ghcr.io/acme/indexer",
|
|
423
|
+
* credentials: { server: "ghcr.io", username: "acme-bot", password: Alchemy.Secret("GHCR_TOKEN") },
|
|
424
|
+
* },
|
|
425
|
+
* },
|
|
426
|
+
* Effect.gen(function* () {
|
|
427
|
+
* const media = yield* Cloud.MountBucket(Media, { path: "/media" }); // installs a FUSE client
|
|
428
|
+
* return { fetch: Effect.succeed(HttpServerResponse.text(media.path)) };
|
|
429
|
+
* }),
|
|
430
|
+
* ) {}
|
|
431
|
+
* ```
|
|
432
|
+
*
|
|
433
|
+
* ### Packaging
|
|
434
|
+
* Alchemy bundles `main` with rolldown into one Node program. Packages
|
|
435
|
+
* that can't be bundled (native modules, or ones that read their own files
|
|
436
|
+
* at run time) go in `build.install` and are installed next to it with
|
|
437
|
+
* `npm install`.
|
|
438
|
+
*
|
|
439
|
+
* **Example:** Install a native package
|
|
440
|
+
* ```typescript
|
|
441
|
+
* export const Thumbs = Archil.Sandbox("Thumbs", {
|
|
442
|
+
* main: "./src/thumbs.ts",
|
|
443
|
+
* build: { install: ["sharp"] },
|
|
444
|
+
* });
|
|
445
|
+
* ```
|
|
446
|
+
*
|
|
447
|
+
* **Example:** Upload static files next to the program
|
|
448
|
+
* ```typescript
|
|
449
|
+
* export const Site = Archil.Sandbox("Site", {
|
|
450
|
+
* main: "./dist/server.mjs",
|
|
451
|
+
* extraFiles: [{ source: "./dist/client", destination: "client" }],
|
|
452
|
+
* });
|
|
453
|
+
* ```
|
|
454
|
+
*
|
|
455
|
+
* ### Ports
|
|
456
|
+
* **Example:** Serve a port and hand out a token for it
|
|
457
|
+
* ```typescript
|
|
458
|
+
* export const Preview = Archil.Sandbox("Preview", { ports: [8080] });
|
|
459
|
+
* export const PreviewToken = Effect.gen(function* () {
|
|
460
|
+
* const preview = yield* Preview;
|
|
461
|
+
* return yield* Archil.SandboxPortToken("PreviewToken", {
|
|
462
|
+
* sandbox: preview.sandboxId,
|
|
463
|
+
* port: 8080,
|
|
464
|
+
* ttl: "24h",
|
|
465
|
+
* });
|
|
466
|
+
* });
|
|
467
|
+
* ```
|
|
468
|
+
*
|
|
469
|
+
* ### Network Policy
|
|
470
|
+
* **Example:** Deny by default, inject a token for one API
|
|
471
|
+
* ```typescript
|
|
472
|
+
* export const Agent = Archil.Sandbox("Agent", {
|
|
473
|
+
* network: {
|
|
474
|
+
* egress: {
|
|
475
|
+
* default: "deny",
|
|
476
|
+
* allow: [
|
|
477
|
+
* "registry.npmjs.org",
|
|
478
|
+
* { target: "api.github.com", headers: { Authorization: Alchemy.Secret("GITHUB_AUTH") } },
|
|
479
|
+
* ],
|
|
480
|
+
* },
|
|
481
|
+
* },
|
|
482
|
+
* });
|
|
483
|
+
* ```
|
|
484
|
+
*
|
|
485
|
+
* **Example:** Forward an API's requests to a Worker gateway that adds the key
|
|
486
|
+
* ```typescript
|
|
487
|
+
* const gateway = yield* MyGateway;
|
|
488
|
+
* const sandbox = yield* Archil.Sandbox("MySandbox", {
|
|
489
|
+
* network: {
|
|
490
|
+
* egress: {
|
|
491
|
+
* default: "deny",
|
|
492
|
+
* allow: [
|
|
493
|
+
* {
|
|
494
|
+
* target: "api.example.com",
|
|
495
|
+
* // proves to the gateway that the request came through this policy
|
|
496
|
+
* headers: { "X-Gateway-Token": Config.Redacted("GATEWAY_TOKEN") },
|
|
497
|
+
* forwardUrl: gateway.url.as<string>(),
|
|
498
|
+
* },
|
|
499
|
+
* ],
|
|
500
|
+
* },
|
|
501
|
+
* },
|
|
502
|
+
* });
|
|
503
|
+
* ```
|
|
504
|
+
*
|
|
505
|
+
* ### Using a Sandbox at Run Time
|
|
506
|
+
* **Example:** Run a command and fork a preview from a Worker
|
|
507
|
+
* ```typescript
|
|
508
|
+
* const preview = yield* Archil.ConnectSandbox(Preview);
|
|
509
|
+
* const result = yield* preview.exec("pnpm build", { cwd: "/workspace" });
|
|
510
|
+
* const fork = yield* preview.fork({ name: "pr-42" });
|
|
511
|
+
* ```
|
|
512
|
+
*
|
|
513
|
+
* ### Looking up an existing sandbox
|
|
514
|
+
* `Sandbox.lookup` references a sandbox Alchemy doesn't manage (created in
|
|
515
|
+
* the Archil console or by another stack). It is read during plan/deploy,
|
|
516
|
+
* never created, updated or deleted.
|
|
517
|
+
* Its program fields are `undefined`: they come from the deploy that started
|
|
518
|
+
* the sandbox's program.
|
|
519
|
+
*
|
|
520
|
+
* **Example:** Reference an existing sandbox
|
|
521
|
+
* ```typescript
|
|
522
|
+
* const box = Archil.Sandbox.lookup({ name: "research" });
|
|
523
|
+
* ```
|
|
524
|
+
*
|
|
525
|
+
* @resource
|
|
526
|
+
* @product Sandbox
|
|
527
|
+
*/
|
|
528
|
+
export const Sandbox: Platform<Sandbox, ServerHost, SandboxShape, HostRuntimeContext> &
|
|
529
|
+
LookupMethod<Sandbox> = Platform("Archil.Sandbox", {
|
|
530
|
+
createRuntimeContext: createHostRuntimeContext("Archil.Sandbox"),
|
|
531
|
+
// `{ disk: Workspace }` at module scope is an Effect: yield it here so
|
|
532
|
+
// the disk is registered and `news.mounts[i].disk` resolves to its
|
|
533
|
+
// attributes.
|
|
534
|
+
transformProps: (_id, props) =>
|
|
535
|
+
Effect.gen(function* () {
|
|
536
|
+
if (globalThis.__ALCHEMY_RUNTIME__ || props?.mounts === undefined) return props;
|
|
537
|
+
const mounts: DiskMount[] = [];
|
|
538
|
+
for (const mount of props.mounts as DiskMount[]) {
|
|
539
|
+
mounts.push(
|
|
540
|
+
Effect.isEffect(mount.disk)
|
|
541
|
+
? { ...mount, disk: yield* mount.disk as Effect.Effect<Disk, never, Providers> }
|
|
542
|
+
: mount,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
return { ...props, mounts };
|
|
546
|
+
}),
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Map a sandbox and its exposed ports to attributes. Archil lists port
|
|
551
|
+
* hostnames separately; sandbox responses don't carry them. `program` is
|
|
552
|
+
* the port and code of the program the sandbox runs, if any.
|
|
553
|
+
*/
|
|
554
|
+
export const toSandboxAttributes = (
|
|
555
|
+
sandbox: archil.Sandbox,
|
|
556
|
+
ports: ReadonlyArray<{ port: number; hostname: string }>,
|
|
557
|
+
program?: { readonly port: number; readonly code: { hash: string } },
|
|
558
|
+
derivedImage?: { readonly imageId: string; readonly hash: string },
|
|
559
|
+
): SandboxAttributes => {
|
|
560
|
+
const endpoints = ports.map((e) => ({ port: e.port, hostname: e.hostname }));
|
|
561
|
+
const primary = (program && endpoints.find((e) => e.port === program.port)) ?? endpoints[0];
|
|
562
|
+
return {
|
|
563
|
+
sandboxId: sandbox.sandbox_id,
|
|
564
|
+
name: sandbox.name,
|
|
565
|
+
status: sandbox.status,
|
|
566
|
+
baseImage: sandbox.base_image,
|
|
567
|
+
imageDigest: sandbox.image_digest,
|
|
568
|
+
vcpuCount: sandbox.vcpu_count,
|
|
569
|
+
memSizeMiB: sandbox.mem_size_mib,
|
|
570
|
+
endpoints,
|
|
571
|
+
url: primary ? `https://${primary.hostname}` : undefined,
|
|
572
|
+
port: program?.port,
|
|
573
|
+
code: program?.code,
|
|
574
|
+
derivedImage: derivedImage && { imageId: derivedImage.imageId, hash: derivedImage.hash },
|
|
575
|
+
setupHash: undefined,
|
|
576
|
+
};
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
/** Read a sandbox's exposed ports and map it to attributes. */
|
|
580
|
+
export const observeSandbox = Effect.fn(function* (
|
|
581
|
+
sandbox: archil.Sandbox,
|
|
582
|
+
program?: { readonly port: number; readonly code: { hash: string } },
|
|
583
|
+
derivedImage?: { readonly imageId: string; readonly hash: string },
|
|
584
|
+
) {
|
|
585
|
+
const ports = yield* archil.listSandboxPorts({ sid: sandbox.sandbox_id }).pipe(
|
|
586
|
+
Effect.map(({ data }) => data.ports ?? []),
|
|
587
|
+
Effect.catchTag("NotFound", () => Effect.succeed([])),
|
|
588
|
+
);
|
|
589
|
+
return toSandboxAttributes(sandbox, ports, program, derivedImage);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
/** The create request for sandbox props, with mounts already on the wire. */
|
|
593
|
+
export const toCreateSandboxRequest = (
|
|
594
|
+
name: string | undefined,
|
|
595
|
+
props: Omit<SandboxProps, "name" | "mounts">,
|
|
596
|
+
mounts: ReadonlyArray<archil.SandboxMount> | undefined,
|
|
597
|
+
): archil.CreateSandboxRequest => ({
|
|
598
|
+
wait: true,
|
|
599
|
+
name,
|
|
600
|
+
image_id: props.image,
|
|
601
|
+
base_image: props.image ? undefined : props.baseImage,
|
|
602
|
+
vcpu_count: props.vcpuCount,
|
|
603
|
+
mem_size_mib: props.memSizeMiB,
|
|
604
|
+
ports: props.ports,
|
|
605
|
+
enable_service_ingress: props.enableServiceIngress,
|
|
606
|
+
env: toEnv(props.env),
|
|
607
|
+
network: props.network?.egress ? { egress: toEgressPolicy(props.network.egress) } : undefined,
|
|
608
|
+
mounts: mounts && mounts.length > 0 ? [...mounts] : undefined,
|
|
609
|
+
max_ttl_seconds: props.maxTtlSeconds,
|
|
610
|
+
idle_ttl_seconds: props.idleTtlSeconds,
|
|
611
|
+
max_concurrent_execs: props.maxConcurrentExecs,
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
/** Stop a live sandbox and wait until it has stopped. */
|
|
615
|
+
const stopSandbox = Effect.fn(function* (sid: string) {
|
|
616
|
+
const observed = yield* getSandbox(sid);
|
|
617
|
+
if (observed === undefined || deletableSandboxStates.has(observed.status)) return;
|
|
618
|
+
// Let a booting or pausing sandbox settle, then stop it.
|
|
619
|
+
yield* waitForSandbox(sid).pipe(Effect.catchTag("NotFound", () => Effect.undefined));
|
|
620
|
+
yield* archil.stopSandbox({ sid }).pipe(
|
|
621
|
+
Effect.asVoid,
|
|
622
|
+
Effect.catchTag("NotFound", () => Effect.void),
|
|
623
|
+
);
|
|
624
|
+
yield* waitForSandboxStopped(sid);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Delete a sandbox and wait until it is gone. Archil only deletes stopped,
|
|
629
|
+
* exited or failed sandboxes, so a live one is stopped first. Deleting a
|
|
630
|
+
* missing sandbox succeeds.
|
|
631
|
+
*/
|
|
632
|
+
export const deleteSandbox = Effect.fn(function* (sid: string) {
|
|
633
|
+
const observed = yield* getSandbox(sid);
|
|
634
|
+
if (observed === undefined) return;
|
|
635
|
+
yield* stopSandbox(sid);
|
|
636
|
+
yield* archil.deleteSandbox({ sid }).pipe(
|
|
637
|
+
Effect.asVoid,
|
|
638
|
+
Effect.catchTag("NotFound", () => Effect.void),
|
|
639
|
+
);
|
|
640
|
+
yield* waitForSandboxGone(sid);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Wire mounts for a list of entries whose disks resolved to attributes or
|
|
645
|
+
* handles. Fails when a disk has no id yet.
|
|
646
|
+
*/
|
|
647
|
+
export const toSandboxMounts = Effect.fn(function* (
|
|
648
|
+
owner: string,
|
|
649
|
+
mounts: ReadonlyArray<DiskMount>,
|
|
650
|
+
) {
|
|
651
|
+
const wire: archil.SandboxMount[] = [];
|
|
652
|
+
for (const mount of mounts) {
|
|
653
|
+
const diskId = mountDiskId(mount.disk);
|
|
654
|
+
if (diskId === undefined) {
|
|
655
|
+
return yield* new MountDiskUnresolved({
|
|
656
|
+
path: mount.path,
|
|
657
|
+
message: `${owner}: the disk mounted at ${mount.path} has no diskId. Pass an Archil.Disk or a DiskHandle.`,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
wire.push(yield* toSandboxMount(diskId, mount));
|
|
661
|
+
}
|
|
662
|
+
return wire;
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
const activeBindings = (bindings: ReadonlyArray<ResourceBinding<SandboxBinding>>) =>
|
|
666
|
+
bindings.filter(
|
|
667
|
+
(b: ResourceBinding<SandboxBinding> & { action?: string }) => b.action !== "delete",
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
/** The sandbox's mounts: the props' entries, then the ones bindings add. */
|
|
671
|
+
const desiredMounts = Effect.fn(function* (
|
|
672
|
+
id: string,
|
|
673
|
+
mounts: ReadonlyArray<DiskMount> | undefined,
|
|
674
|
+
bindings: ReadonlyArray<ResourceBinding<SandboxBinding>>,
|
|
675
|
+
) {
|
|
676
|
+
const wire = yield* toSandboxMounts(`Archil.Sandbox ${id}`, mounts ?? []);
|
|
677
|
+
for (const binding of activeBindings(bindings)) {
|
|
678
|
+
for (const mount of binding.data?.mounts ?? []) {
|
|
679
|
+
if (!isCapability(mount)) wire.push(yield* toSandboxMount(mount.diskId, mount));
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
// A binding and the props can name the same mount; keep one.
|
|
683
|
+
const seen = new Set<string>();
|
|
684
|
+
return wire.filter((mount) => {
|
|
685
|
+
const key = JSON.stringify(mountIdentity(mount));
|
|
686
|
+
if (seen.has(key)) return false;
|
|
687
|
+
seen.add(key);
|
|
688
|
+
return true;
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
const isCapability = (mount: SandboxMountBinding | MountCapability): mount is MountCapability =>
|
|
693
|
+
"install" in mount;
|
|
694
|
+
|
|
695
|
+
/** The filesystem clients bindings install into the sandbox's image. */
|
|
696
|
+
const boundCapabilities = (bindings: ReadonlyArray<ResourceBinding<SandboxBinding>>) =>
|
|
697
|
+
collectMountCapabilities(
|
|
698
|
+
activeBindings(bindings).map((b) => ({
|
|
699
|
+
data: { mounts: (b.data?.mounts ?? []).filter(isCapability) },
|
|
700
|
+
})),
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
/** The reference a derived image builds on: the `image`'s source, or `baseImage`. */
|
|
704
|
+
const baseReference = Effect.fn(function* (props: SandboxProps) {
|
|
705
|
+
if (props.image === undefined) return props.baseImage ?? "node:24-bookworm";
|
|
706
|
+
const { data } = yield* archil.getImage({ image_id: props.image });
|
|
707
|
+
return data.canonical_source ?? data.source;
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* The plan for a sandbox's derived image: its base plus the install lines
|
|
712
|
+
* of the capabilities bound onto it. `undefined` when nothing is bound, so
|
|
713
|
+
* the sandbox boots its base directly. Archil sandboxes run as root with
|
|
714
|
+
* `/dev/fuse` and NFS, so every requirement is met.
|
|
715
|
+
*/
|
|
716
|
+
const planDerivedImage = Effect.fn(function* (
|
|
717
|
+
id: string,
|
|
718
|
+
props: SandboxProps,
|
|
719
|
+
capabilities: ReadonlyArray<MountCapability>,
|
|
720
|
+
) {
|
|
721
|
+
if (capabilities.length === 0) return undefined;
|
|
722
|
+
const unsupported = checkMountSupport(`Archil.Sandbox ${id}`, capabilities, {
|
|
723
|
+
fuse: true,
|
|
724
|
+
nfs: true,
|
|
725
|
+
});
|
|
726
|
+
if (unsupported) return yield* unsupported;
|
|
727
|
+
return yield* BuildPlan.plan(
|
|
728
|
+
{ image: yield* baseReference(props) },
|
|
729
|
+
{ capabilities: imageCapabilities, install: mountInstallLines(capabilities) },
|
|
730
|
+
);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
/** Build, push and pull a derived image, or keep the one `output` already has. */
|
|
734
|
+
const ensureDerivedImage = Effect.fn(function* (
|
|
735
|
+
id: string,
|
|
736
|
+
plan: BuildPlan.BuildPlan,
|
|
737
|
+
push: ImagePush | undefined,
|
|
738
|
+
existing: { readonly imageId: string; readonly hash: string } | undefined,
|
|
739
|
+
) {
|
|
740
|
+
if (existing?.hash === plan.hash) return existing;
|
|
741
|
+
if (push === undefined) {
|
|
742
|
+
return yield* new ImageRequiresBuild({
|
|
743
|
+
message: `Archil.Sandbox ${id}: a binding installs a filesystem client into its image, so Alchemy has to build one. Set \`push: { repository, credentials }\` for Archil to pull it from.`,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
const published = yield* publishPlan(plan, push);
|
|
747
|
+
const image = yield* pullImage(published.source, published.credentials, plan.hash);
|
|
748
|
+
return { imageId: image.imageId, hash: plan.hash };
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
const sortedMounts = (mounts: ReadonlyArray<archil.SandboxMount>) =>
|
|
752
|
+
mounts.map(mountIdentity).sort((a, b) => a.path.localeCompare(b.path));
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Converge a sandbox's mounts. Archil only changes mounts when a stopped
|
|
756
|
+
* sandbox starts, so a change stops it and starts it with the new ones;
|
|
757
|
+
* its root filesystem is kept.
|
|
758
|
+
*/
|
|
759
|
+
const syncMounts = Effect.fn(function* (
|
|
760
|
+
sandbox: archil.Sandbox,
|
|
761
|
+
desired: ReadonlyArray<archil.SandboxMount>,
|
|
762
|
+
) {
|
|
763
|
+
if (deepEqual(sortedMounts(sandbox.mounts ?? []), sortedMounts(desired))) return false;
|
|
764
|
+
const sid = sandbox.sandbox_id;
|
|
765
|
+
yield* stopSandbox(sid);
|
|
766
|
+
yield* archil.startSandbox({ sid, wait: true, mounts: [...desired] });
|
|
767
|
+
return true;
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
/** Converge a sandbox's exposed ports. */
|
|
771
|
+
const syncPorts = Effect.fn(function* (sid: string, desired: number[] | undefined) {
|
|
772
|
+
if (desired === undefined) return;
|
|
773
|
+
const { data } = yield* archil.listSandboxPorts({ sid });
|
|
774
|
+
const observed = new Set((data.ports ?? []).map((p) => p.port));
|
|
775
|
+
const want = new Set(desired);
|
|
776
|
+
for (const port of want) {
|
|
777
|
+
if (!observed.has(port)) yield* archil.exposeSandboxPort({ sid, port });
|
|
778
|
+
}
|
|
779
|
+
for (const port of observed) {
|
|
780
|
+
if (!want.has(port)) yield* archil.deleteSandboxPort({ sid, port });
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
/** Converge a sandbox's egress policy. */
|
|
785
|
+
const syncNetwork = Effect.fn(function* (sid: string, egress: SandboxEgress | undefined) {
|
|
786
|
+
if (egress === undefined) return;
|
|
787
|
+
const desired = toEgressPolicy(egress);
|
|
788
|
+
const { data } = yield* archil.getSandboxNetwork({ sid });
|
|
789
|
+
const { managed, policy } = splitArchilEgress(data.egress);
|
|
790
|
+
if (!deepEqual(policy, desired)) {
|
|
791
|
+
yield* archil.updateSandboxNetwork({
|
|
792
|
+
sid,
|
|
793
|
+
egress: { ...desired, allow: [...(desired.allow ?? []), ...managed] },
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
/** Props that can't change on a running sandbox. */
|
|
799
|
+
const createOnly = (props: SandboxProps | undefined) => ({
|
|
800
|
+
image: props?.image,
|
|
801
|
+
baseImage: props?.baseImage,
|
|
802
|
+
vcpuCount: props?.vcpuCount,
|
|
803
|
+
memSizeMiB: props?.memSizeMiB,
|
|
804
|
+
enableServiceIngress: props?.enableServiceIngress,
|
|
805
|
+
// A program's environment is the program's: it restarts, the box stays.
|
|
806
|
+
env: props?.main === undefined ? toEnv(props?.env) : undefined,
|
|
807
|
+
maxTtlSeconds: props?.maxTtlSeconds,
|
|
808
|
+
maxConcurrentExecs: props?.maxConcurrentExecs,
|
|
809
|
+
run: props?.run ?? [],
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
/** Exclusive mounts in props whose disks are known: one sandbox at a time holds each. */
|
|
813
|
+
const exclusiveDisks = (props: SandboxProps | undefined) =>
|
|
814
|
+
new Set(
|
|
815
|
+
(props?.mounts ?? [])
|
|
816
|
+
.filter((m) => (m.mode ?? "exclusive") === "exclusive")
|
|
817
|
+
.map((m) => mountDiskId(m.disk))
|
|
818
|
+
.filter((id) => id !== undefined),
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
const DEFAULT_PORT = 3000;
|
|
822
|
+
|
|
823
|
+
/** The name of the program's service inside its sandbox. */
|
|
824
|
+
const PROGRAM = "program";
|
|
825
|
+
|
|
826
|
+
/** The public hostname of `port`, exposing it when it isn't yet. */
|
|
827
|
+
const exposePort = Effect.fn(function* (sid: string, port: number) {
|
|
828
|
+
const { data } = yield* archil.listSandboxPorts({ sid });
|
|
829
|
+
const existing = (data.ports ?? []).find((p) => p.port === port);
|
|
830
|
+
if (existing) return existing.hostname;
|
|
831
|
+
const { data: exposed } = yield* archil.exposeSandboxPort({ sid, port });
|
|
832
|
+
return exposed.hostname;
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
export const SandboxProvider = () =>
|
|
836
|
+
Provider.effect(
|
|
837
|
+
Sandbox,
|
|
838
|
+
Effect.gen(function* () {
|
|
839
|
+
const stack = yield* Stack;
|
|
840
|
+
const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
|
|
841
|
+
const alchemyEnv = {
|
|
842
|
+
ALCHEMY_STACK_NAME: stack.name,
|
|
843
|
+
ALCHEMY_STAGE: stack.stage,
|
|
844
|
+
ALCHEMY_PHASE: "runtime",
|
|
845
|
+
HOST: "0.0.0.0",
|
|
846
|
+
};
|
|
847
|
+
return Sandbox.Provider.of({
|
|
848
|
+
stables: ["sandboxId", "name"],
|
|
849
|
+
nuke: { dependsOn: ["Archil.Disk"] },
|
|
850
|
+
list: Effect.fn(function* () {
|
|
851
|
+
return (yield* listSandboxes())
|
|
852
|
+
.filter(
|
|
853
|
+
(s) =>
|
|
854
|
+
(isAlchemyName(s.name) || isRuntimeObjectName(s.name)) && s.status !== "deleted",
|
|
855
|
+
)
|
|
856
|
+
.map((sandbox) => toSandboxAttributes(sandbox, []));
|
|
857
|
+
}),
|
|
858
|
+
diff: Effect.fn(function* ({ id, olds, news, output, newBindings }) {
|
|
859
|
+
if (!isResolved(news)) {
|
|
860
|
+
// A program class's props hold its implementation (`exports`),
|
|
861
|
+
// unresolved at plan. The bundle depends only on `main`,
|
|
862
|
+
// `handler` and `build`, which are plain, so a code change still
|
|
863
|
+
// redeploys the program in place.
|
|
864
|
+
const program = news as SandboxProps;
|
|
865
|
+
if (typeof program.main === "string" && output !== undefined) {
|
|
866
|
+
const { hash } = yield* bundleProgram(
|
|
867
|
+
program as SandboxProps & { main: string },
|
|
868
|
+
virtualEntryPlugin,
|
|
869
|
+
);
|
|
870
|
+
if (hash !== output.code?.hash) return { action: "update" } as const;
|
|
871
|
+
}
|
|
872
|
+
return undefined;
|
|
873
|
+
}
|
|
874
|
+
const name = output?.name ?? (yield* sandboxName(id, olds?.name));
|
|
875
|
+
if (news.name !== undefined && news.name !== name) {
|
|
876
|
+
return { action: "replace" } as const;
|
|
877
|
+
}
|
|
878
|
+
if (olds !== undefined && !deepEqual(createOnly(news), createOnly(olds))) {
|
|
879
|
+
// A disk mounted for exclusive writes can only be held by one
|
|
880
|
+
// sandbox, so the old one has to let go before the new one boots.
|
|
881
|
+
const held = exclusiveDisks(olds);
|
|
882
|
+
const deleteFirst = [...exclusiveDisks(news)].some((d) => held.has(d));
|
|
883
|
+
return { action: "replace", deleteFirst } as const;
|
|
884
|
+
}
|
|
885
|
+
// A new set of installed clients builds a new image: a new sandbox.
|
|
886
|
+
if (output !== undefined && isResolved(newBindings)) {
|
|
887
|
+
const plan = yield* planDerivedImage(
|
|
888
|
+
id,
|
|
889
|
+
news,
|
|
890
|
+
boundCapabilities(newBindings as ReadonlyArray<ResourceBinding<SandboxBinding>>),
|
|
891
|
+
);
|
|
892
|
+
if (plan?.hash !== output.derivedImage?.hash) {
|
|
893
|
+
const held = exclusiveDisks(olds);
|
|
894
|
+
const deleteFirst = [...exclusiveDisks(news)].some((d) => held.has(d));
|
|
895
|
+
return { action: "replace", deleteFirst } as const;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
if (news.main !== undefined) {
|
|
899
|
+
const { hash } = yield* bundleProgram(
|
|
900
|
+
news as SandboxProps & { main: string },
|
|
901
|
+
virtualEntryPlugin,
|
|
902
|
+
);
|
|
903
|
+
if (hash !== output?.code?.hash || (news.port ?? DEFAULT_PORT) !== output?.port) {
|
|
904
|
+
return { action: "update" } as const;
|
|
905
|
+
}
|
|
906
|
+
} else if (output?.code !== undefined) {
|
|
907
|
+
return { action: "update" } as const;
|
|
908
|
+
}
|
|
909
|
+
if (
|
|
910
|
+
!deepEqual(egressIdentity(news.network?.egress), egressIdentity(olds?.network?.egress))
|
|
911
|
+
) {
|
|
912
|
+
return { action: "update" } as const;
|
|
913
|
+
}
|
|
914
|
+
return undefined;
|
|
915
|
+
}),
|
|
916
|
+
lookup: Effect.fn(function* ({ name }) {
|
|
917
|
+
const sandbox = yield* findSandboxByName(name);
|
|
918
|
+
return sandbox ? yield* observeSandbox(sandbox) : undefined;
|
|
919
|
+
}),
|
|
920
|
+
|
|
921
|
+
read: Effect.fn(function* ({ id, olds, output }) {
|
|
922
|
+
const name = output?.name ?? (yield* sandboxName(id, olds?.name));
|
|
923
|
+
const sandbox =
|
|
924
|
+
(output ? yield* getSandbox(output.sandboxId) : undefined) ??
|
|
925
|
+
(yield* findSandboxByName(name));
|
|
926
|
+
if (!sandbox) return undefined;
|
|
927
|
+
const program =
|
|
928
|
+
output?.port !== undefined && output.code !== undefined
|
|
929
|
+
? { port: output.port, code: output.code }
|
|
930
|
+
: undefined;
|
|
931
|
+
const attrs = {
|
|
932
|
+
...(yield* observeSandbox(sandbox, program, output?.derivedImage)),
|
|
933
|
+
setupHash: output?.setupHash,
|
|
934
|
+
};
|
|
935
|
+
if (output === undefined && olds?.name !== undefined && !isAlchemyName(sandbox.name)) {
|
|
936
|
+
return Unowned(attrs);
|
|
937
|
+
}
|
|
938
|
+
return attrs;
|
|
939
|
+
}),
|
|
940
|
+
reconcile: Effect.fn(function* ({ id, news, output, bindings, session }) {
|
|
941
|
+
const name = output?.name ?? (yield* sandboxName(id, news.name));
|
|
942
|
+
const mounts = yield* desiredMounts(id, news.mounts, bindings);
|
|
943
|
+
const capabilities = boundCapabilities(bindings);
|
|
944
|
+
const hosted = news.main !== undefined;
|
|
945
|
+
// Observe
|
|
946
|
+
let sandbox =
|
|
947
|
+
(output ? yield* getSandbox(output.sandboxId) : undefined) ??
|
|
948
|
+
(yield* findSandboxByName(name));
|
|
949
|
+
// Ensure
|
|
950
|
+
const derivedPlan = yield* planDerivedImage(id, news, capabilities);
|
|
951
|
+
let derivedImage = output?.derivedImage;
|
|
952
|
+
if (derivedPlan !== undefined) {
|
|
953
|
+
if (derivedPlan.hash !== derivedImage?.hash) {
|
|
954
|
+
yield* session.note(`Building the image for sandbox ${name}...`);
|
|
955
|
+
}
|
|
956
|
+
derivedImage = yield* ensureDerivedImage(id, derivedPlan, news.push, derivedImage);
|
|
957
|
+
} else {
|
|
958
|
+
derivedImage = undefined;
|
|
959
|
+
}
|
|
960
|
+
if (!sandbox) {
|
|
961
|
+
yield* session.note(`Creating sandbox ${name}...`);
|
|
962
|
+
const request = toCreateSandboxRequest(
|
|
963
|
+
name,
|
|
964
|
+
{
|
|
965
|
+
...news,
|
|
966
|
+
image: derivedImage?.imageId ?? news.image,
|
|
967
|
+
env: hosted ? undefined : news.env,
|
|
968
|
+
},
|
|
969
|
+
mounts,
|
|
970
|
+
);
|
|
971
|
+
sandbox = yield* archil.createSandbox(request).pipe(
|
|
972
|
+
Effect.map(({ data }) => data),
|
|
973
|
+
Effect.catchTag("SandboxNameConflict", (conflict) =>
|
|
974
|
+
findSandboxByName(name).pipe(
|
|
975
|
+
Effect.flatMap((found) =>
|
|
976
|
+
found ? Effect.succeed(found) : Effect.fail(conflict),
|
|
977
|
+
),
|
|
978
|
+
),
|
|
979
|
+
),
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
const sid = sandbox.sandbox_id;
|
|
983
|
+
sandbox = yield* waitForSandbox(sid);
|
|
984
|
+
// Sync
|
|
985
|
+
if (yield* syncMounts(sandbox, mounts)) {
|
|
986
|
+
yield* session.note(`Remounted disks on sandbox ${name}`);
|
|
987
|
+
sandbox = yield* waitForSandbox(sid);
|
|
988
|
+
}
|
|
989
|
+
yield* syncNetwork(sid, news.network?.egress);
|
|
990
|
+
const port = news.port ?? DEFAULT_PORT;
|
|
991
|
+
yield* syncPorts(
|
|
992
|
+
sid,
|
|
993
|
+
news.ports === undefined ? undefined : hosted ? [...news.ports, port] : news.ports,
|
|
994
|
+
);
|
|
995
|
+
if (
|
|
996
|
+
news.idleTtlSeconds !== undefined &&
|
|
997
|
+
news.idleTtlSeconds !== sandbox.idle_ttl_seconds
|
|
998
|
+
) {
|
|
999
|
+
yield* archil.setSandboxTimeout({ sid, idle_ttl_seconds: news.idleTtlSeconds });
|
|
1000
|
+
}
|
|
1001
|
+
// Setup: run once, on the sandbox as created. A change replaces the
|
|
1002
|
+
// sandbox (see `createOnly`), so the commands always start clean.
|
|
1003
|
+
const run = news.run ?? [];
|
|
1004
|
+
const setupHash = run.length > 0 ? yield* sha256Object(run) : undefined;
|
|
1005
|
+
if (setupHash !== undefined && setupHash !== output?.setupHash) {
|
|
1006
|
+
for (const command of run) {
|
|
1007
|
+
yield* session.note(`Running \`${command}\` in sandbox ${name}...`);
|
|
1008
|
+
const result = yield* Process.exec(sid, command);
|
|
1009
|
+
if (result.exitCode !== 0) {
|
|
1010
|
+
return yield* new SandboxSetupFailed({
|
|
1011
|
+
sandbox: name,
|
|
1012
|
+
command,
|
|
1013
|
+
exitCode: result.exitCode ?? -1,
|
|
1014
|
+
stderr: result.stderr,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
// Flush to disk, so a fork or a cold start sees what the commands wrote.
|
|
1019
|
+
yield* Process.exec(sid, "sync");
|
|
1020
|
+
}
|
|
1021
|
+
let program: { port: number; code: { hash: string } } | undefined;
|
|
1022
|
+
if (hosted) {
|
|
1023
|
+
const bindingEnv = activeBindings(bindings).reduce<Record<string, unknown>>(
|
|
1024
|
+
(acc, b) => ({ ...acc, ...b.data?.env }),
|
|
1025
|
+
{},
|
|
1026
|
+
);
|
|
1027
|
+
const bundled = yield* bundleProgram(
|
|
1028
|
+
news as SandboxProps & { main: string },
|
|
1029
|
+
virtualEntryPlugin,
|
|
1030
|
+
);
|
|
1031
|
+
// A moved port leaves the old one exposed; take it down.
|
|
1032
|
+
if (output?.port !== undefined && output.port !== port) {
|
|
1033
|
+
yield* archil
|
|
1034
|
+
.deleteSandboxPort({ sid, port: output.port })
|
|
1035
|
+
.pipe(Effect.catchTag("NotFound", () => Effect.void));
|
|
1036
|
+
}
|
|
1037
|
+
yield* deployProgram({
|
|
1038
|
+
sid,
|
|
1039
|
+
name: PROGRAM,
|
|
1040
|
+
port,
|
|
1041
|
+
program: bundled,
|
|
1042
|
+
env: { ...mountEnv(capabilities), ...bindingEnv, ...alchemyEnv, ...news.env },
|
|
1043
|
+
});
|
|
1044
|
+
yield* exposePort(sid, port);
|
|
1045
|
+
program = { port, code: { hash: bundled.hash } };
|
|
1046
|
+
} else if (output?.code !== undefined) {
|
|
1047
|
+
// `main` was removed: the box stays, the program goes.
|
|
1048
|
+
yield* removeProgram(sid, PROGRAM);
|
|
1049
|
+
if (output.port !== undefined && !(news.ports ?? []).includes(output.port)) {
|
|
1050
|
+
yield* archil
|
|
1051
|
+
.deleteSandboxPort({ sid, port: output.port })
|
|
1052
|
+
.pipe(Effect.catchTag("NotFound", () => Effect.void));
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
// Return
|
|
1056
|
+
const final = yield* getSandbox(sid);
|
|
1057
|
+
return { ...(yield* observeSandbox(final ?? sandbox, program, derivedImage)), setupHash };
|
|
1058
|
+
}),
|
|
1059
|
+
delete: Effect.fn(function* ({ output }) {
|
|
1060
|
+
yield* deleteSandbox(output.sandboxId);
|
|
1061
|
+
}),
|
|
1062
|
+
});
|
|
1063
|
+
}),
|
|
1064
|
+
);
|