@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/lib/Sandbox.js
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
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 { checkMountSupport, collectMountCapabilities, mountEnv, mountInstallLines, } from "alchemy/Mount";
|
|
8
|
+
import { Platform } from "alchemy/Platform";
|
|
9
|
+
import * as Provider from "alchemy/Provider";
|
|
10
|
+
import { createHostRuntimeContext, } from "alchemy/Runtime/Process";
|
|
11
|
+
import { Stack } from "alchemy/Stack";
|
|
12
|
+
import { sha256Object } from "alchemy/Util/sha256";
|
|
13
|
+
import * as Data from "effect/Data";
|
|
14
|
+
import * as Effect from "effect/Effect";
|
|
15
|
+
import { capabilities as imageCapabilities, ImageRequiresBuild, publishPlan, pullImage, } from "./Image.js";
|
|
16
|
+
import { deletableSandboxStates, findSandboxByName, getSandbox, isAlchemyName, isRuntimeObjectName, listSandboxes, sandboxName, waitForSandbox, waitForSandboxGone, waitForSandboxStopped, } from "./internal/archil.js";
|
|
17
|
+
import { bundleProgram, deployProgram, removeProgram, } from "./internal/hosted.js";
|
|
18
|
+
import { egressIdentity, MountDiskUnresolved, mountDiskId, mountIdentity, splitArchilEgress, toEgressPolicy, toEnv, toSandboxMount, } from "./SandboxOptions.js";
|
|
19
|
+
/** A disk a binding mounts into a program sandbox (`MountDisk` inside one). */
|
|
20
|
+
/** A sandbox's `run` command exited non-zero. */
|
|
21
|
+
export class SandboxSetupFailed extends Data.TaggedError("Archil.SandboxSetupFailed") {
|
|
22
|
+
get message() {
|
|
23
|
+
return `\`${this.command}\` exited ${this.exitCode} in sandbox ${this.sandbox}: ${this.stderr.slice(-2000)}`;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* An Archil sandbox: a Firecracker microVM with disks mounted, public
|
|
28
|
+
* ports, and an outbound network policy that can inject credentials at the
|
|
29
|
+
* boundary. It pauses when idle and resumes on the next connection,
|
|
30
|
+
* keeping its memory.
|
|
31
|
+
*
|
|
32
|
+
* Declare one as a box (a dev box or a preview environment that outlives
|
|
33
|
+
* deploys), or give it a `main` and it runs your program: Alchemy bundles
|
|
34
|
+
* it, uploads it, and registers it with Archil's service manager, which
|
|
35
|
+
* restarts it when it crashes and starts it whenever the sandbox boots. Its
|
|
36
|
+
* port gets a public `https://` URL, and a request to a stopped sandbox
|
|
37
|
+
* starts it again, typically in a few seconds. A code change uploads the
|
|
38
|
+
* program and restarts it in place.
|
|
39
|
+
*
|
|
40
|
+
* Short-lived sandboxes for one user or task are created at run time with
|
|
41
|
+
* {@link CreateSandbox}; act on a declared one with {@link ConnectSandbox}.
|
|
42
|
+
*
|
|
43
|
+
* :::caution
|
|
44
|
+
* Changing the image, size, timeouts, or the `env` of a sandbox without
|
|
45
|
+
* `main` replaces the sandbox, and a new one starts with an empty root
|
|
46
|
+
* filesystem. Keep data on a mounted disk. `mounts`, `ports`, `network`
|
|
47
|
+
* and the program update in place.
|
|
48
|
+
* :::
|
|
49
|
+
*
|
|
50
|
+
* :::caution
|
|
51
|
+
* An exposed port is public: anyone with the URL can call it. Check
|
|
52
|
+
* requests yourself, or hand clients an `Archil.SandboxPortToken`.
|
|
53
|
+
* :::
|
|
54
|
+
* @see https://docs.archil.com/compute/sandboxes/introduction
|
|
55
|
+
*
|
|
56
|
+
* ### Creating a Sandbox
|
|
57
|
+
* **Example:** A dev box with a disk mounted
|
|
58
|
+
* ```typescript
|
|
59
|
+
* export const Workspace = Archil.Disk("Workspace", {});
|
|
60
|
+
* export const DevBox = Archil.Sandbox("DevBox", {
|
|
61
|
+
* baseImage: "node:24-bookworm",
|
|
62
|
+
* vcpuCount: 2,
|
|
63
|
+
* memSizeMiB: 4096,
|
|
64
|
+
* mounts: [{ disk: Workspace, path: "/workspace" }],
|
|
65
|
+
* ports: [3000],
|
|
66
|
+
* idleTtlSeconds: 600,
|
|
67
|
+
* });
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* **Example:** A bigger sandbox that never pauses while idle
|
|
71
|
+
* ```typescript
|
|
72
|
+
* export const Box = Archil.Sandbox("Box", {
|
|
73
|
+
* vcpuCount: 4,
|
|
74
|
+
* memSizeMiB: 8192,
|
|
75
|
+
* idleTtlSeconds: 0,
|
|
76
|
+
* });
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* **Example:** Boot a private image
|
|
80
|
+
* ```typescript
|
|
81
|
+
* export const AgentImage = Archil.Image("AgentImage", {
|
|
82
|
+
* image: "ghcr.io/acme/agent:1",
|
|
83
|
+
* registry: { server: "ghcr.io", username: "acme", password: Config.Redacted("GHCR_TOKEN") },
|
|
84
|
+
* });
|
|
85
|
+
* export const Agent = Effect.gen(function* () {
|
|
86
|
+
* const image = yield* AgentImage;
|
|
87
|
+
* return yield* Archil.Sandbox("Agent", { image: image.imageId });
|
|
88
|
+
* });
|
|
89
|
+
* ```
|
|
90
|
+
*
|
|
91
|
+
* ### Setting Up at Deploy and Forking at Run Time
|
|
92
|
+
* **Example:** Clone a repository once, then fork the sandbox per task
|
|
93
|
+
* ```typescript
|
|
94
|
+
* export const Golden = Archil.Sandbox("Golden", {
|
|
95
|
+
* run: ["git clone --depth 1 https://github.com/sindresorhus/is /work/is"],
|
|
96
|
+
* });
|
|
97
|
+
*
|
|
98
|
+
* // in a Worker or program:
|
|
99
|
+
* const sandboxes = yield* Archil.CreateSandbox(Golden);
|
|
100
|
+
* const task = yield* sandboxes.create({ name: "task-1" });
|
|
101
|
+
* const result = yield* task.exec("git log -1", { cwd: "/work/is" });
|
|
102
|
+
* ```
|
|
103
|
+
*
|
|
104
|
+
* ### Running a Program
|
|
105
|
+
* **Example:** An Effect program that reads a mounted disk
|
|
106
|
+
* ```typescript
|
|
107
|
+
* // src/Api.ts
|
|
108
|
+
* import * as Archil from "@alchemy.run/archil";
|
|
109
|
+
* import * as Effect from "effect/Effect";
|
|
110
|
+
* import * as FileSystem from "effect/FileSystem";
|
|
111
|
+
* import * as HttpServerResponse from "effect/http/HttpServerResponse";
|
|
112
|
+
*
|
|
113
|
+
* export const Dataset = Archil.Disk("Dataset", {});
|
|
114
|
+
*
|
|
115
|
+
* export default class Api extends Archil.Sandbox<Api>()(
|
|
116
|
+
* "Api",
|
|
117
|
+
* { main: import.meta.url, port: 3000 },
|
|
118
|
+
* Effect.gen(function* () {
|
|
119
|
+
* const dataset = yield* Archil.MountDisk(Dataset, { path: "/data", mode: "read-only" });
|
|
120
|
+
* const fs = yield* FileSystem.FileSystem;
|
|
121
|
+
* return {
|
|
122
|
+
* fetch: Effect.gen(function* () {
|
|
123
|
+
* return yield* HttpServerResponse.json(yield* fs.readDirectory(dataset.path));
|
|
124
|
+
* }).pipe(Effect.orDie),
|
|
125
|
+
* };
|
|
126
|
+
* }).pipe(Effect.provide(Archil.MountDiskLive)),
|
|
127
|
+
* ) {}
|
|
128
|
+
* ```
|
|
129
|
+
*
|
|
130
|
+
* **Example:** A plain async handler with typed `env`
|
|
131
|
+
* ```typescript
|
|
132
|
+
* // src/infra.ts
|
|
133
|
+
* export const Hello = Archil.Sandbox("Hello", {
|
|
134
|
+
* main: "./src/hello.ts",
|
|
135
|
+
* env: { GREETING: "hello" },
|
|
136
|
+
* });
|
|
137
|
+
* export type HelloEnv = Archil.InferEnv<typeof Hello>;
|
|
138
|
+
* ```
|
|
139
|
+
*
|
|
140
|
+
* ```typescript
|
|
141
|
+
* // src/hello.ts
|
|
142
|
+
* import type { HelloEnv } from "./infra.ts";
|
|
143
|
+
*
|
|
144
|
+
* export default {
|
|
145
|
+
* async fetch(request: Request, env: HelloEnv) {
|
|
146
|
+
* return new Response(`${env.GREETING} from ${new URL(request.url).pathname}`);
|
|
147
|
+
* },
|
|
148
|
+
* };
|
|
149
|
+
* ```
|
|
150
|
+
*
|
|
151
|
+
* **Example:** Secrets in the program's environment
|
|
152
|
+
* ```typescript
|
|
153
|
+
* export const Billing = Archil.Sandbox("Billing", {
|
|
154
|
+
* main: "./src/billing.ts",
|
|
155
|
+
* env: { STRIPE_KEY: Config.Redacted("STRIPE_KEY") },
|
|
156
|
+
* });
|
|
157
|
+
* ```
|
|
158
|
+
*
|
|
159
|
+
* ### Mounting Disks
|
|
160
|
+
* Archil sandboxes mount disks natively. Each entry names the disk and a
|
|
161
|
+
* `path`, with Archil's own options: `mode` (`exclusive`, `conditional`
|
|
162
|
+
* or `read-only`), `subdirectory`, and `queueMs`.
|
|
163
|
+
*
|
|
164
|
+
* **Example:** Mount two disks, one read-only
|
|
165
|
+
* ```typescript
|
|
166
|
+
* export const Box = Archil.Sandbox("Box", {
|
|
167
|
+
* mounts: [
|
|
168
|
+
* { disk: Workspace, path: "/workspace" },
|
|
169
|
+
* { disk: Dataset, path: "/data", mode: "read-only" },
|
|
170
|
+
* ],
|
|
171
|
+
* });
|
|
172
|
+
* ```
|
|
173
|
+
*
|
|
174
|
+
* **Example:** Mount from the program with `MountDisk`
|
|
175
|
+
* ```typescript
|
|
176
|
+
* const uploads = yield* Archil.MountDisk(Uploads, { path: "/uploads", mode: "conditional" });
|
|
177
|
+
* // runtime: the disk is already mounted at uploads.path
|
|
178
|
+
* yield* fs.writeFileString(`${uploads.path}/${id}.txt`, body);
|
|
179
|
+
* ```
|
|
180
|
+
*
|
|
181
|
+
* ### Mount Clients From Other Bindings
|
|
182
|
+
* A binding that installs a filesystem client (an `alchemy/Mount`
|
|
183
|
+
* capability) into a program's image makes Alchemy build one: the
|
|
184
|
+
* sandbox's base plus the client's install lines, built with your local
|
|
185
|
+
* Docker and pushed to `push.repository` for Archil to pull. A different
|
|
186
|
+
* set of clients replaces the sandbox.
|
|
187
|
+
*
|
|
188
|
+
* **Example:** Give a program sandbox a repository for its derived image
|
|
189
|
+
* ```typescript
|
|
190
|
+
* export default class Indexer extends Archil.Sandbox<Indexer>()(
|
|
191
|
+
* "Indexer",
|
|
192
|
+
* {
|
|
193
|
+
* main: import.meta.url,
|
|
194
|
+
* push: {
|
|
195
|
+
* repository: "ghcr.io/acme/indexer",
|
|
196
|
+
* credentials: { server: "ghcr.io", username: "acme-bot", password: Alchemy.Secret("GHCR_TOKEN") },
|
|
197
|
+
* },
|
|
198
|
+
* },
|
|
199
|
+
* Effect.gen(function* () {
|
|
200
|
+
* const media = yield* Cloud.MountBucket(Media, { path: "/media" }); // installs a FUSE client
|
|
201
|
+
* return { fetch: Effect.succeed(HttpServerResponse.text(media.path)) };
|
|
202
|
+
* }),
|
|
203
|
+
* ) {}
|
|
204
|
+
* ```
|
|
205
|
+
*
|
|
206
|
+
* ### Packaging
|
|
207
|
+
* Alchemy bundles `main` with rolldown into one Node program. Packages
|
|
208
|
+
* that can't be bundled (native modules, or ones that read their own files
|
|
209
|
+
* at run time) go in `build.install` and are installed next to it with
|
|
210
|
+
* `npm install`.
|
|
211
|
+
*
|
|
212
|
+
* **Example:** Install a native package
|
|
213
|
+
* ```typescript
|
|
214
|
+
* export const Thumbs = Archil.Sandbox("Thumbs", {
|
|
215
|
+
* main: "./src/thumbs.ts",
|
|
216
|
+
* build: { install: ["sharp"] },
|
|
217
|
+
* });
|
|
218
|
+
* ```
|
|
219
|
+
*
|
|
220
|
+
* **Example:** Upload static files next to the program
|
|
221
|
+
* ```typescript
|
|
222
|
+
* export const Site = Archil.Sandbox("Site", {
|
|
223
|
+
* main: "./dist/server.mjs",
|
|
224
|
+
* extraFiles: [{ source: "./dist/client", destination: "client" }],
|
|
225
|
+
* });
|
|
226
|
+
* ```
|
|
227
|
+
*
|
|
228
|
+
* ### Ports
|
|
229
|
+
* **Example:** Serve a port and hand out a token for it
|
|
230
|
+
* ```typescript
|
|
231
|
+
* export const Preview = Archil.Sandbox("Preview", { ports: [8080] });
|
|
232
|
+
* export const PreviewToken = Effect.gen(function* () {
|
|
233
|
+
* const preview = yield* Preview;
|
|
234
|
+
* return yield* Archil.SandboxPortToken("PreviewToken", {
|
|
235
|
+
* sandbox: preview.sandboxId,
|
|
236
|
+
* port: 8080,
|
|
237
|
+
* ttl: "24h",
|
|
238
|
+
* });
|
|
239
|
+
* });
|
|
240
|
+
* ```
|
|
241
|
+
*
|
|
242
|
+
* ### Network Policy
|
|
243
|
+
* **Example:** Deny by default, inject a token for one API
|
|
244
|
+
* ```typescript
|
|
245
|
+
* export const Agent = Archil.Sandbox("Agent", {
|
|
246
|
+
* network: {
|
|
247
|
+
* egress: {
|
|
248
|
+
* default: "deny",
|
|
249
|
+
* allow: [
|
|
250
|
+
* "registry.npmjs.org",
|
|
251
|
+
* { target: "api.github.com", headers: { Authorization: Alchemy.Secret("GITHUB_AUTH") } },
|
|
252
|
+
* ],
|
|
253
|
+
* },
|
|
254
|
+
* },
|
|
255
|
+
* });
|
|
256
|
+
* ```
|
|
257
|
+
*
|
|
258
|
+
* **Example:** Forward an API's requests to a Worker gateway that adds the key
|
|
259
|
+
* ```typescript
|
|
260
|
+
* const gateway = yield* MyGateway;
|
|
261
|
+
* const sandbox = yield* Archil.Sandbox("MySandbox", {
|
|
262
|
+
* network: {
|
|
263
|
+
* egress: {
|
|
264
|
+
* default: "deny",
|
|
265
|
+
* allow: [
|
|
266
|
+
* {
|
|
267
|
+
* target: "api.example.com",
|
|
268
|
+
* // proves to the gateway that the request came through this policy
|
|
269
|
+
* headers: { "X-Gateway-Token": Config.Redacted("GATEWAY_TOKEN") },
|
|
270
|
+
* forwardUrl: gateway.url.as<string>(),
|
|
271
|
+
* },
|
|
272
|
+
* ],
|
|
273
|
+
* },
|
|
274
|
+
* },
|
|
275
|
+
* });
|
|
276
|
+
* ```
|
|
277
|
+
*
|
|
278
|
+
* ### Using a Sandbox at Run Time
|
|
279
|
+
* **Example:** Run a command and fork a preview from a Worker
|
|
280
|
+
* ```typescript
|
|
281
|
+
* const preview = yield* Archil.ConnectSandbox(Preview);
|
|
282
|
+
* const result = yield* preview.exec("pnpm build", { cwd: "/workspace" });
|
|
283
|
+
* const fork = yield* preview.fork({ name: "pr-42" });
|
|
284
|
+
* ```
|
|
285
|
+
*
|
|
286
|
+
* ### Looking up an existing sandbox
|
|
287
|
+
* `Sandbox.lookup` references a sandbox Alchemy doesn't manage (created in
|
|
288
|
+
* the Archil console or by another stack). It is read during plan/deploy,
|
|
289
|
+
* never created, updated or deleted.
|
|
290
|
+
* Its program fields are `undefined`: they come from the deploy that started
|
|
291
|
+
* the sandbox's program.
|
|
292
|
+
*
|
|
293
|
+
* **Example:** Reference an existing sandbox
|
|
294
|
+
* ```typescript
|
|
295
|
+
* const box = Archil.Sandbox.lookup({ name: "research" });
|
|
296
|
+
* ```
|
|
297
|
+
*
|
|
298
|
+
* @resource
|
|
299
|
+
* @product Sandbox
|
|
300
|
+
*/
|
|
301
|
+
export const Sandbox = Platform("Archil.Sandbox", {
|
|
302
|
+
createRuntimeContext: createHostRuntimeContext("Archil.Sandbox"),
|
|
303
|
+
// `{ disk: Workspace }` at module scope is an Effect: yield it here so
|
|
304
|
+
// the disk is registered and `news.mounts[i].disk` resolves to its
|
|
305
|
+
// attributes.
|
|
306
|
+
transformProps: (_id, props) => Effect.gen(function* () {
|
|
307
|
+
if (globalThis.__ALCHEMY_RUNTIME__ || props?.mounts === undefined)
|
|
308
|
+
return props;
|
|
309
|
+
const mounts = [];
|
|
310
|
+
for (const mount of props.mounts) {
|
|
311
|
+
mounts.push(Effect.isEffect(mount.disk)
|
|
312
|
+
? { ...mount, disk: yield* mount.disk }
|
|
313
|
+
: mount);
|
|
314
|
+
}
|
|
315
|
+
return { ...props, mounts };
|
|
316
|
+
}),
|
|
317
|
+
});
|
|
318
|
+
/**
|
|
319
|
+
* Map a sandbox and its exposed ports to attributes. Archil lists port
|
|
320
|
+
* hostnames separately; sandbox responses don't carry them. `program` is
|
|
321
|
+
* the port and code of the program the sandbox runs, if any.
|
|
322
|
+
*/
|
|
323
|
+
export const toSandboxAttributes = (sandbox, ports, program, derivedImage) => {
|
|
324
|
+
const endpoints = ports.map((e) => ({ port: e.port, hostname: e.hostname }));
|
|
325
|
+
const primary = (program && endpoints.find((e) => e.port === program.port)) ?? endpoints[0];
|
|
326
|
+
return {
|
|
327
|
+
sandboxId: sandbox.sandbox_id,
|
|
328
|
+
name: sandbox.name,
|
|
329
|
+
status: sandbox.status,
|
|
330
|
+
baseImage: sandbox.base_image,
|
|
331
|
+
imageDigest: sandbox.image_digest,
|
|
332
|
+
vcpuCount: sandbox.vcpu_count,
|
|
333
|
+
memSizeMiB: sandbox.mem_size_mib,
|
|
334
|
+
endpoints,
|
|
335
|
+
url: primary ? `https://${primary.hostname}` : undefined,
|
|
336
|
+
port: program?.port,
|
|
337
|
+
code: program?.code,
|
|
338
|
+
derivedImage: derivedImage && { imageId: derivedImage.imageId, hash: derivedImage.hash },
|
|
339
|
+
setupHash: undefined,
|
|
340
|
+
};
|
|
341
|
+
};
|
|
342
|
+
/** Read a sandbox's exposed ports and map it to attributes. */
|
|
343
|
+
export const observeSandbox = Effect.fn(function* (sandbox, program, derivedImage) {
|
|
344
|
+
const ports = yield* archil.listSandboxPorts({ sid: sandbox.sandbox_id }).pipe(Effect.map(({ data }) => data.ports ?? []), Effect.catchTag("NotFound", () => Effect.succeed([])));
|
|
345
|
+
return toSandboxAttributes(sandbox, ports, program, derivedImage);
|
|
346
|
+
});
|
|
347
|
+
/** The create request for sandbox props, with mounts already on the wire. */
|
|
348
|
+
export const toCreateSandboxRequest = (name, props, mounts) => ({
|
|
349
|
+
wait: true,
|
|
350
|
+
name,
|
|
351
|
+
image_id: props.image,
|
|
352
|
+
base_image: props.image ? undefined : props.baseImage,
|
|
353
|
+
vcpu_count: props.vcpuCount,
|
|
354
|
+
mem_size_mib: props.memSizeMiB,
|
|
355
|
+
ports: props.ports,
|
|
356
|
+
enable_service_ingress: props.enableServiceIngress,
|
|
357
|
+
env: toEnv(props.env),
|
|
358
|
+
network: props.network?.egress ? { egress: toEgressPolicy(props.network.egress) } : undefined,
|
|
359
|
+
mounts: mounts && mounts.length > 0 ? [...mounts] : undefined,
|
|
360
|
+
max_ttl_seconds: props.maxTtlSeconds,
|
|
361
|
+
idle_ttl_seconds: props.idleTtlSeconds,
|
|
362
|
+
max_concurrent_execs: props.maxConcurrentExecs,
|
|
363
|
+
});
|
|
364
|
+
/** Stop a live sandbox and wait until it has stopped. */
|
|
365
|
+
const stopSandbox = Effect.fn(function* (sid) {
|
|
366
|
+
const observed = yield* getSandbox(sid);
|
|
367
|
+
if (observed === undefined || deletableSandboxStates.has(observed.status))
|
|
368
|
+
return;
|
|
369
|
+
// Let a booting or pausing sandbox settle, then stop it.
|
|
370
|
+
yield* waitForSandbox(sid).pipe(Effect.catchTag("NotFound", () => Effect.undefined));
|
|
371
|
+
yield* archil.stopSandbox({ sid }).pipe(Effect.asVoid, Effect.catchTag("NotFound", () => Effect.void));
|
|
372
|
+
yield* waitForSandboxStopped(sid);
|
|
373
|
+
});
|
|
374
|
+
/**
|
|
375
|
+
* Delete a sandbox and wait until it is gone. Archil only deletes stopped,
|
|
376
|
+
* exited or failed sandboxes, so a live one is stopped first. Deleting a
|
|
377
|
+
* missing sandbox succeeds.
|
|
378
|
+
*/
|
|
379
|
+
export const deleteSandbox = Effect.fn(function* (sid) {
|
|
380
|
+
const observed = yield* getSandbox(sid);
|
|
381
|
+
if (observed === undefined)
|
|
382
|
+
return;
|
|
383
|
+
yield* stopSandbox(sid);
|
|
384
|
+
yield* archil.deleteSandbox({ sid }).pipe(Effect.asVoid, Effect.catchTag("NotFound", () => Effect.void));
|
|
385
|
+
yield* waitForSandboxGone(sid);
|
|
386
|
+
});
|
|
387
|
+
/**
|
|
388
|
+
* Wire mounts for a list of entries whose disks resolved to attributes or
|
|
389
|
+
* handles. Fails when a disk has no id yet.
|
|
390
|
+
*/
|
|
391
|
+
export const toSandboxMounts = Effect.fn(function* (owner, mounts) {
|
|
392
|
+
const wire = [];
|
|
393
|
+
for (const mount of mounts) {
|
|
394
|
+
const diskId = mountDiskId(mount.disk);
|
|
395
|
+
if (diskId === undefined) {
|
|
396
|
+
return yield* new MountDiskUnresolved({
|
|
397
|
+
path: mount.path,
|
|
398
|
+
message: `${owner}: the disk mounted at ${mount.path} has no diskId. Pass an Archil.Disk or a DiskHandle.`,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
wire.push(yield* toSandboxMount(diskId, mount));
|
|
402
|
+
}
|
|
403
|
+
return wire;
|
|
404
|
+
});
|
|
405
|
+
const activeBindings = (bindings) => bindings.filter((b) => b.action !== "delete");
|
|
406
|
+
/** The sandbox's mounts: the props' entries, then the ones bindings add. */
|
|
407
|
+
const desiredMounts = Effect.fn(function* (id, mounts, bindings) {
|
|
408
|
+
const wire = yield* toSandboxMounts(`Archil.Sandbox ${id}`, mounts ?? []);
|
|
409
|
+
for (const binding of activeBindings(bindings)) {
|
|
410
|
+
for (const mount of binding.data?.mounts ?? []) {
|
|
411
|
+
if (!isCapability(mount))
|
|
412
|
+
wire.push(yield* toSandboxMount(mount.diskId, mount));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// A binding and the props can name the same mount; keep one.
|
|
416
|
+
const seen = new Set();
|
|
417
|
+
return wire.filter((mount) => {
|
|
418
|
+
const key = JSON.stringify(mountIdentity(mount));
|
|
419
|
+
if (seen.has(key))
|
|
420
|
+
return false;
|
|
421
|
+
seen.add(key);
|
|
422
|
+
return true;
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
const isCapability = (mount) => "install" in mount;
|
|
426
|
+
/** The filesystem clients bindings install into the sandbox's image. */
|
|
427
|
+
const boundCapabilities = (bindings) => collectMountCapabilities(activeBindings(bindings).map((b) => ({
|
|
428
|
+
data: { mounts: (b.data?.mounts ?? []).filter(isCapability) },
|
|
429
|
+
})));
|
|
430
|
+
/** The reference a derived image builds on: the `image`'s source, or `baseImage`. */
|
|
431
|
+
const baseReference = Effect.fn(function* (props) {
|
|
432
|
+
if (props.image === undefined)
|
|
433
|
+
return props.baseImage ?? "node:24-bookworm";
|
|
434
|
+
const { data } = yield* archil.getImage({ image_id: props.image });
|
|
435
|
+
return data.canonical_source ?? data.source;
|
|
436
|
+
});
|
|
437
|
+
/**
|
|
438
|
+
* The plan for a sandbox's derived image: its base plus the install lines
|
|
439
|
+
* of the capabilities bound onto it. `undefined` when nothing is bound, so
|
|
440
|
+
* the sandbox boots its base directly. Archil sandboxes run as root with
|
|
441
|
+
* `/dev/fuse` and NFS, so every requirement is met.
|
|
442
|
+
*/
|
|
443
|
+
const planDerivedImage = Effect.fn(function* (id, props, capabilities) {
|
|
444
|
+
if (capabilities.length === 0)
|
|
445
|
+
return undefined;
|
|
446
|
+
const unsupported = checkMountSupport(`Archil.Sandbox ${id}`, capabilities, {
|
|
447
|
+
fuse: true,
|
|
448
|
+
nfs: true,
|
|
449
|
+
});
|
|
450
|
+
if (unsupported)
|
|
451
|
+
return yield* unsupported;
|
|
452
|
+
return yield* BuildPlan.plan({ image: yield* baseReference(props) }, { capabilities: imageCapabilities, install: mountInstallLines(capabilities) });
|
|
453
|
+
});
|
|
454
|
+
/** Build, push and pull a derived image, or keep the one `output` already has. */
|
|
455
|
+
const ensureDerivedImage = Effect.fn(function* (id, plan, push, existing) {
|
|
456
|
+
if (existing?.hash === plan.hash)
|
|
457
|
+
return existing;
|
|
458
|
+
if (push === undefined) {
|
|
459
|
+
return yield* new ImageRequiresBuild({
|
|
460
|
+
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.`,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
const published = yield* publishPlan(plan, push);
|
|
464
|
+
const image = yield* pullImage(published.source, published.credentials, plan.hash);
|
|
465
|
+
return { imageId: image.imageId, hash: plan.hash };
|
|
466
|
+
});
|
|
467
|
+
const sortedMounts = (mounts) => mounts.map(mountIdentity).sort((a, b) => a.path.localeCompare(b.path));
|
|
468
|
+
/**
|
|
469
|
+
* Converge a sandbox's mounts. Archil only changes mounts when a stopped
|
|
470
|
+
* sandbox starts, so a change stops it and starts it with the new ones;
|
|
471
|
+
* its root filesystem is kept.
|
|
472
|
+
*/
|
|
473
|
+
const syncMounts = Effect.fn(function* (sandbox, desired) {
|
|
474
|
+
if (deepEqual(sortedMounts(sandbox.mounts ?? []), sortedMounts(desired)))
|
|
475
|
+
return false;
|
|
476
|
+
const sid = sandbox.sandbox_id;
|
|
477
|
+
yield* stopSandbox(sid);
|
|
478
|
+
yield* archil.startSandbox({ sid, wait: true, mounts: [...desired] });
|
|
479
|
+
return true;
|
|
480
|
+
});
|
|
481
|
+
/** Converge a sandbox's exposed ports. */
|
|
482
|
+
const syncPorts = Effect.fn(function* (sid, desired) {
|
|
483
|
+
if (desired === undefined)
|
|
484
|
+
return;
|
|
485
|
+
const { data } = yield* archil.listSandboxPorts({ sid });
|
|
486
|
+
const observed = new Set((data.ports ?? []).map((p) => p.port));
|
|
487
|
+
const want = new Set(desired);
|
|
488
|
+
for (const port of want) {
|
|
489
|
+
if (!observed.has(port))
|
|
490
|
+
yield* archil.exposeSandboxPort({ sid, port });
|
|
491
|
+
}
|
|
492
|
+
for (const port of observed) {
|
|
493
|
+
if (!want.has(port))
|
|
494
|
+
yield* archil.deleteSandboxPort({ sid, port });
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
/** Converge a sandbox's egress policy. */
|
|
498
|
+
const syncNetwork = Effect.fn(function* (sid, egress) {
|
|
499
|
+
if (egress === undefined)
|
|
500
|
+
return;
|
|
501
|
+
const desired = toEgressPolicy(egress);
|
|
502
|
+
const { data } = yield* archil.getSandboxNetwork({ sid });
|
|
503
|
+
const { managed, policy } = splitArchilEgress(data.egress);
|
|
504
|
+
if (!deepEqual(policy, desired)) {
|
|
505
|
+
yield* archil.updateSandboxNetwork({
|
|
506
|
+
sid,
|
|
507
|
+
egress: { ...desired, allow: [...(desired.allow ?? []), ...managed] },
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
/** Props that can't change on a running sandbox. */
|
|
512
|
+
const createOnly = (props) => ({
|
|
513
|
+
image: props?.image,
|
|
514
|
+
baseImage: props?.baseImage,
|
|
515
|
+
vcpuCount: props?.vcpuCount,
|
|
516
|
+
memSizeMiB: props?.memSizeMiB,
|
|
517
|
+
enableServiceIngress: props?.enableServiceIngress,
|
|
518
|
+
// A program's environment is the program's: it restarts, the box stays.
|
|
519
|
+
env: props?.main === undefined ? toEnv(props?.env) : undefined,
|
|
520
|
+
maxTtlSeconds: props?.maxTtlSeconds,
|
|
521
|
+
maxConcurrentExecs: props?.maxConcurrentExecs,
|
|
522
|
+
run: props?.run ?? [],
|
|
523
|
+
});
|
|
524
|
+
/** Exclusive mounts in props whose disks are known: one sandbox at a time holds each. */
|
|
525
|
+
const exclusiveDisks = (props) => new Set((props?.mounts ?? [])
|
|
526
|
+
.filter((m) => (m.mode ?? "exclusive") === "exclusive")
|
|
527
|
+
.map((m) => mountDiskId(m.disk))
|
|
528
|
+
.filter((id) => id !== undefined));
|
|
529
|
+
const DEFAULT_PORT = 3000;
|
|
530
|
+
/** The name of the program's service inside its sandbox. */
|
|
531
|
+
const PROGRAM = "program";
|
|
532
|
+
/** The public hostname of `port`, exposing it when it isn't yet. */
|
|
533
|
+
const exposePort = Effect.fn(function* (sid, port) {
|
|
534
|
+
const { data } = yield* archil.listSandboxPorts({ sid });
|
|
535
|
+
const existing = (data.ports ?? []).find((p) => p.port === port);
|
|
536
|
+
if (existing)
|
|
537
|
+
return existing.hostname;
|
|
538
|
+
const { data: exposed } = yield* archil.exposeSandboxPort({ sid, port });
|
|
539
|
+
return exposed.hostname;
|
|
540
|
+
});
|
|
541
|
+
export const SandboxProvider = () => Provider.effect(Sandbox, Effect.gen(function* () {
|
|
542
|
+
const stack = yield* Stack;
|
|
543
|
+
const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
|
|
544
|
+
const alchemyEnv = {
|
|
545
|
+
ALCHEMY_STACK_NAME: stack.name,
|
|
546
|
+
ALCHEMY_STAGE: stack.stage,
|
|
547
|
+
ALCHEMY_PHASE: "runtime",
|
|
548
|
+
HOST: "0.0.0.0",
|
|
549
|
+
};
|
|
550
|
+
return Sandbox.Provider.of({
|
|
551
|
+
stables: ["sandboxId", "name"],
|
|
552
|
+
nuke: { dependsOn: ["Archil.Disk"] },
|
|
553
|
+
list: Effect.fn(function* () {
|
|
554
|
+
return (yield* listSandboxes())
|
|
555
|
+
.filter((s) => (isAlchemyName(s.name) || isRuntimeObjectName(s.name)) && s.status !== "deleted")
|
|
556
|
+
.map((sandbox) => toSandboxAttributes(sandbox, []));
|
|
557
|
+
}),
|
|
558
|
+
diff: Effect.fn(function* ({ id, olds, news, output, newBindings }) {
|
|
559
|
+
if (!isResolved(news)) {
|
|
560
|
+
// A program class's props hold its implementation (`exports`),
|
|
561
|
+
// unresolved at plan. The bundle depends only on `main`,
|
|
562
|
+
// `handler` and `build`, which are plain, so a code change still
|
|
563
|
+
// redeploys the program in place.
|
|
564
|
+
const program = news;
|
|
565
|
+
if (typeof program.main === "string" && output !== undefined) {
|
|
566
|
+
const { hash } = yield* bundleProgram(program, virtualEntryPlugin);
|
|
567
|
+
if (hash !== output.code?.hash)
|
|
568
|
+
return { action: "update" };
|
|
569
|
+
}
|
|
570
|
+
return undefined;
|
|
571
|
+
}
|
|
572
|
+
const name = output?.name ?? (yield* sandboxName(id, olds?.name));
|
|
573
|
+
if (news.name !== undefined && news.name !== name) {
|
|
574
|
+
return { action: "replace" };
|
|
575
|
+
}
|
|
576
|
+
if (olds !== undefined && !deepEqual(createOnly(news), createOnly(olds))) {
|
|
577
|
+
// A disk mounted for exclusive writes can only be held by one
|
|
578
|
+
// sandbox, so the old one has to let go before the new one boots.
|
|
579
|
+
const held = exclusiveDisks(olds);
|
|
580
|
+
const deleteFirst = [...exclusiveDisks(news)].some((d) => held.has(d));
|
|
581
|
+
return { action: "replace", deleteFirst };
|
|
582
|
+
}
|
|
583
|
+
// A new set of installed clients builds a new image: a new sandbox.
|
|
584
|
+
if (output !== undefined && isResolved(newBindings)) {
|
|
585
|
+
const plan = yield* planDerivedImage(id, news, boundCapabilities(newBindings));
|
|
586
|
+
if (plan?.hash !== output.derivedImage?.hash) {
|
|
587
|
+
const held = exclusiveDisks(olds);
|
|
588
|
+
const deleteFirst = [...exclusiveDisks(news)].some((d) => held.has(d));
|
|
589
|
+
return { action: "replace", deleteFirst };
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (news.main !== undefined) {
|
|
593
|
+
const { hash } = yield* bundleProgram(news, virtualEntryPlugin);
|
|
594
|
+
if (hash !== output?.code?.hash || (news.port ?? DEFAULT_PORT) !== output?.port) {
|
|
595
|
+
return { action: "update" };
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
else if (output?.code !== undefined) {
|
|
599
|
+
return { action: "update" };
|
|
600
|
+
}
|
|
601
|
+
if (!deepEqual(egressIdentity(news.network?.egress), egressIdentity(olds?.network?.egress))) {
|
|
602
|
+
return { action: "update" };
|
|
603
|
+
}
|
|
604
|
+
return undefined;
|
|
605
|
+
}),
|
|
606
|
+
lookup: Effect.fn(function* ({ name }) {
|
|
607
|
+
const sandbox = yield* findSandboxByName(name);
|
|
608
|
+
return sandbox ? yield* observeSandbox(sandbox) : undefined;
|
|
609
|
+
}),
|
|
610
|
+
read: Effect.fn(function* ({ id, olds, output }) {
|
|
611
|
+
const name = output?.name ?? (yield* sandboxName(id, olds?.name));
|
|
612
|
+
const sandbox = (output ? yield* getSandbox(output.sandboxId) : undefined) ??
|
|
613
|
+
(yield* findSandboxByName(name));
|
|
614
|
+
if (!sandbox)
|
|
615
|
+
return undefined;
|
|
616
|
+
const program = output?.port !== undefined && output.code !== undefined
|
|
617
|
+
? { port: output.port, code: output.code }
|
|
618
|
+
: undefined;
|
|
619
|
+
const attrs = {
|
|
620
|
+
...(yield* observeSandbox(sandbox, program, output?.derivedImage)),
|
|
621
|
+
setupHash: output?.setupHash,
|
|
622
|
+
};
|
|
623
|
+
if (output === undefined && olds?.name !== undefined && !isAlchemyName(sandbox.name)) {
|
|
624
|
+
return Unowned(attrs);
|
|
625
|
+
}
|
|
626
|
+
return attrs;
|
|
627
|
+
}),
|
|
628
|
+
reconcile: Effect.fn(function* ({ id, news, output, bindings, session }) {
|
|
629
|
+
const name = output?.name ?? (yield* sandboxName(id, news.name));
|
|
630
|
+
const mounts = yield* desiredMounts(id, news.mounts, bindings);
|
|
631
|
+
const capabilities = boundCapabilities(bindings);
|
|
632
|
+
const hosted = news.main !== undefined;
|
|
633
|
+
// Observe
|
|
634
|
+
let sandbox = (output ? yield* getSandbox(output.sandboxId) : undefined) ??
|
|
635
|
+
(yield* findSandboxByName(name));
|
|
636
|
+
// Ensure
|
|
637
|
+
const derivedPlan = yield* planDerivedImage(id, news, capabilities);
|
|
638
|
+
let derivedImage = output?.derivedImage;
|
|
639
|
+
if (derivedPlan !== undefined) {
|
|
640
|
+
if (derivedPlan.hash !== derivedImage?.hash) {
|
|
641
|
+
yield* session.note(`Building the image for sandbox ${name}...`);
|
|
642
|
+
}
|
|
643
|
+
derivedImage = yield* ensureDerivedImage(id, derivedPlan, news.push, derivedImage);
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
derivedImage = undefined;
|
|
647
|
+
}
|
|
648
|
+
if (!sandbox) {
|
|
649
|
+
yield* session.note(`Creating sandbox ${name}...`);
|
|
650
|
+
const request = toCreateSandboxRequest(name, {
|
|
651
|
+
...news,
|
|
652
|
+
image: derivedImage?.imageId ?? news.image,
|
|
653
|
+
env: hosted ? undefined : news.env,
|
|
654
|
+
}, mounts);
|
|
655
|
+
sandbox = yield* archil.createSandbox(request).pipe(Effect.map(({ data }) => data), Effect.catchTag("SandboxNameConflict", (conflict) => findSandboxByName(name).pipe(Effect.flatMap((found) => found ? Effect.succeed(found) : Effect.fail(conflict)))));
|
|
656
|
+
}
|
|
657
|
+
const sid = sandbox.sandbox_id;
|
|
658
|
+
sandbox = yield* waitForSandbox(sid);
|
|
659
|
+
// Sync
|
|
660
|
+
if (yield* syncMounts(sandbox, mounts)) {
|
|
661
|
+
yield* session.note(`Remounted disks on sandbox ${name}`);
|
|
662
|
+
sandbox = yield* waitForSandbox(sid);
|
|
663
|
+
}
|
|
664
|
+
yield* syncNetwork(sid, news.network?.egress);
|
|
665
|
+
const port = news.port ?? DEFAULT_PORT;
|
|
666
|
+
yield* syncPorts(sid, news.ports === undefined ? undefined : hosted ? [...news.ports, port] : news.ports);
|
|
667
|
+
if (news.idleTtlSeconds !== undefined &&
|
|
668
|
+
news.idleTtlSeconds !== sandbox.idle_ttl_seconds) {
|
|
669
|
+
yield* archil.setSandboxTimeout({ sid, idle_ttl_seconds: news.idleTtlSeconds });
|
|
670
|
+
}
|
|
671
|
+
// Setup: run once, on the sandbox as created. A change replaces the
|
|
672
|
+
// sandbox (see `createOnly`), so the commands always start clean.
|
|
673
|
+
const run = news.run ?? [];
|
|
674
|
+
const setupHash = run.length > 0 ? yield* sha256Object(run) : undefined;
|
|
675
|
+
if (setupHash !== undefined && setupHash !== output?.setupHash) {
|
|
676
|
+
for (const command of run) {
|
|
677
|
+
yield* session.note(`Running \`${command}\` in sandbox ${name}...`);
|
|
678
|
+
const result = yield* Process.exec(sid, command);
|
|
679
|
+
if (result.exitCode !== 0) {
|
|
680
|
+
return yield* new SandboxSetupFailed({
|
|
681
|
+
sandbox: name,
|
|
682
|
+
command,
|
|
683
|
+
exitCode: result.exitCode ?? -1,
|
|
684
|
+
stderr: result.stderr,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
// Flush to disk, so a fork or a cold start sees what the commands wrote.
|
|
689
|
+
yield* Process.exec(sid, "sync");
|
|
690
|
+
}
|
|
691
|
+
let program;
|
|
692
|
+
if (hosted) {
|
|
693
|
+
const bindingEnv = activeBindings(bindings).reduce((acc, b) => ({ ...acc, ...b.data?.env }), {});
|
|
694
|
+
const bundled = yield* bundleProgram(news, virtualEntryPlugin);
|
|
695
|
+
// A moved port leaves the old one exposed; take it down.
|
|
696
|
+
if (output?.port !== undefined && output.port !== port) {
|
|
697
|
+
yield* archil
|
|
698
|
+
.deleteSandboxPort({ sid, port: output.port })
|
|
699
|
+
.pipe(Effect.catchTag("NotFound", () => Effect.void));
|
|
700
|
+
}
|
|
701
|
+
yield* deployProgram({
|
|
702
|
+
sid,
|
|
703
|
+
name: PROGRAM,
|
|
704
|
+
port,
|
|
705
|
+
program: bundled,
|
|
706
|
+
env: { ...mountEnv(capabilities), ...bindingEnv, ...alchemyEnv, ...news.env },
|
|
707
|
+
});
|
|
708
|
+
yield* exposePort(sid, port);
|
|
709
|
+
program = { port, code: { hash: bundled.hash } };
|
|
710
|
+
}
|
|
711
|
+
else if (output?.code !== undefined) {
|
|
712
|
+
// `main` was removed: the box stays, the program goes.
|
|
713
|
+
yield* removeProgram(sid, PROGRAM);
|
|
714
|
+
if (output.port !== undefined && !(news.ports ?? []).includes(output.port)) {
|
|
715
|
+
yield* archil
|
|
716
|
+
.deleteSandboxPort({ sid, port: output.port })
|
|
717
|
+
.pipe(Effect.catchTag("NotFound", () => Effect.void));
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// Return
|
|
721
|
+
const final = yield* getSandbox(sid);
|
|
722
|
+
return { ...(yield* observeSandbox(final ?? sandbox, program, derivedImage)), setupHash };
|
|
723
|
+
}),
|
|
724
|
+
delete: Effect.fn(function* ({ output }) {
|
|
725
|
+
yield* deleteSandbox(output.sandboxId);
|
|
726
|
+
}),
|
|
727
|
+
});
|
|
728
|
+
}));
|
|
729
|
+
//# sourceMappingURL=Sandbox.js.map
|