@molecule/api-code-sandbox-flyio 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Fly.io Machines code-sandbox provider for molecule.dev.
3
+ *
4
+ * Runs each sandbox as a Fly Machine — a Firecracker microVM — managed over the
5
+ * Fly Machines API. The reason to pick this over the Docker bond is
6
+ * SCALE-TO-ZERO: `sleep()` maps to Fly's `suspend`, which snapshots the microVM's
7
+ * memory to disk, and a suspended Machine bills for storage only. Waking is a
8
+ * resume from that snapshot rather than a cold boot.
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * import { bond } from '@molecule/api-bond'
13
+ * import { createProvider } from '@molecule/api-code-sandbox-flyio'
14
+ *
15
+ * // The API token is read from FLY_API_TOKEN (or FLY_ACCESS_TOKEN) unless you
16
+ * // pass `apiToken` explicitly. Every option below has an env fallback too.
17
+ * bond(
18
+ * 'code-sandbox',
19
+ * createProvider({
20
+ * orgSlug: 'my-org',
21
+ * region: 'iad',
22
+ * baseImage: 'registry.fly.io/molecule-sandbox:latest',
23
+ * }),
24
+ * )
25
+ *
26
+ * // Elsewhere, through the core interface:
27
+ * import { requireProvider } from '@molecule/api-code-sandbox'
28
+ *
29
+ * const sandbox = await requireProvider().create({
30
+ * projectId: 'a3f1c0de-0000-4000-8000-000000000001',
31
+ * volumeName: 'mol-a3f1c0de',
32
+ * resources: { cpu: 2, memoryMB: 2048, diskMB: 10240 },
33
+ * })
34
+ *
35
+ * await sandbox.exec('npm install', { timeout: 600_000 })
36
+ * await sandbox.sleep() // Fly suspend — memory snapshot, storage-only billing
37
+ * await sandbox.wake() // Fly start — resumes from the snapshot
38
+ *
39
+ * // Warm start. Capture the prepared filesystem once…
40
+ * const provider = requireProvider()
41
+ * await provider.commitTemplate?.({
42
+ * sandboxId: sandbox.id,
43
+ * templateId: 'react-postgres-v3',
44
+ * // REQUIRED here: the archive of these paths IS the template.
45
+ * capturePaths: ['/workspace'],
46
+ * })
47
+ *
48
+ * // …then every later boot of the same configuration restores it instead of
49
+ * // re-running `mlcl create` + `npm install`. A missing template THROWS.
50
+ * const warm = await provider.create({
51
+ * projectId: 'b7d2…',
52
+ * volumeName: 'mol-b7d2',
53
+ * templateId: 'react-postgres-v3',
54
+ * })
55
+ * ```
56
+ *
57
+ * @remarks
58
+ * **Sources.** Every endpoint, payload field and documented behaviour used here
59
+ * was checked against Fly's own material, not recalled:
60
+ * the OpenAPI specification (https://docs.machines.dev/openapi.json,
61
+ * `servers: https://api.machines.dev/v1`), the Machines resource reference
62
+ * (https://fly.io/docs/machines/api/machines-resource/), Working with the
63
+ * Machines API (https://fly.io/docs/machines/api/working-with-machines-api/),
64
+ * Machine states (https://fly.io/docs/machines/machine-states/), Suspend and
65
+ * Resume (https://fly.io/docs/reference/suspend-resume/), Custom private
66
+ * networks (https://fly.io/docs/networking/custom-private-networks/), Private
67
+ * networking (https://fly.io/docs/networking/private-networking/), Fly Volumes
68
+ * (https://fly.io/docs/volumes/overview/), Network Policies
69
+ * (https://fly.io/docs/machines/guides-examples/network-policies/ and its
70
+ * announcement https://community.fly.io/t/new-feature-network-policies/19173),
71
+ * Egress IP addresses (https://fly.io/docs/networking/egress-ips/), rate limits
72
+ * (https://fly.io/docs/machines/api/working-with-machines-api/), and the exec
73
+ * timeout ceiling
74
+ * (https://community.fly.io/t/extending-timeout-of-execute-command-machines-api-endpoint/26074).
75
+ * For the template capability: Fly Volume snapshots
76
+ * (https://fly.io/docs/volumes/snapshots/), Tigris object storage on Fly
77
+ * (https://fly.io/docs/tigris/) and its S3 API coverage
78
+ * (https://www.tigrisdata.com/docs/api/s3/), presigned URLs
79
+ * (https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html),
80
+ * the 5 GB single-`PUT` ceiling
81
+ * (https://docs.aws.amazon.com/AmazonS3/latest/userguide/upload-objects.html),
82
+ * and the AWS SDK's checksum defaults
83
+ * (https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html).
84
+ * The `tar` behaviours the restore's containment rests on — a `..` member
85
+ * refused, a leading `/` stripped, a write through a symlink refused, and
86
+ * setuid/setgid dropped by `--no-same-permissions` — were verified by running
87
+ * GNU tar 1.35 against crafted archives rather than inferred from its manual.
88
+ *
89
+ * **`sleep()` is suspend, not stop — that is the point of this bond.**
90
+ * `sleep()` calls `POST /v1/apps/{app}/machines/{id}/suspend`, which "uses
91
+ * Firecracker snapshots to capture the entire VM state: CPU registers, memory
92
+ * contents, open file handles"; `wake()` calls `start`, which restores it —
93
+ * "Resume from suspend: a few hundred ms" versus "Cold start: ~2+ seconds".
94
+ * Fly documents that "Suspended machines cost the same as stopped machines:
95
+ * storage only. There are no CPU/RAM charges." Two caveats that bite: suspend is
96
+ * discouraged above 2 GB of guest memory, and calling `stop()` on a SUSPENDED
97
+ * Machine invalidates its snapshot, forcing a cold boot on the next `wake()` —
98
+ * so do not "tidy up" a sleeping sandbox with a stop.
99
+ *
100
+ * **`exec` is capped at 60 seconds by Fly, and this provider works around it.**
101
+ * The exec endpoint rejects a `timeout` above 60 s outright. Any command whose
102
+ * budget exceeds that is therefore run DETACHED inside the Machine — script file
103
+ * plus redirected stdout/stderr plus an exit-status file — and polled to
104
+ * completion with short execs. The caller still gets one `ExecResult`. The
105
+ * observable differences: output is not streamed, and a command still running
106
+ * when the budget expires returns `exitCode: -1` (indeterminate) with whatever
107
+ * output existed at that moment, exactly like the Docker bond's convention.
108
+ * Captured output is capped at 5 MB per stream.
109
+ *
110
+ * **`spawn()` is NOT implemented.** It is optional in the core interface, and
111
+ * Fly's exec is strictly request/response — no connection upgrade, no streaming
112
+ * stdout, and stdin only as a single up-front string. There is no honest way to
113
+ * back a `SpawnHandle` with it, so the method is absent rather than faked.
114
+ * Callers must feature-detect (`sandbox.spawn?.(…)`), which the interface
115
+ * already requires.
116
+ *
117
+ * **`onFileChange()` is NOT implemented** — it registers nothing and returns a
118
+ * real no-op unsubscribe, and warns once. The Machines API exposes no
119
+ * filesystem-event channel, and the only alternative is polling through the
120
+ * rate-limited exec endpoint, which would spend the whole API budget to deliver
121
+ * changes seconds late. Poll `readDir`/`readFile` yourself if you need it.
122
+ *
123
+ * **Tenant isolation: one app per project, each on its own 6PN.** Every Machine
124
+ * in a Fly app shares one 6PN private network and can reach every other Machine
125
+ * in that app by private IPv6 — the same cross-tenant exposure the Docker bond's
126
+ * shared `bridge` network has. So by default this provider creates one Fly app
127
+ * per project (`<prefix>-<projectId>`) with `network` set to a custom 6PN, which
128
+ * Fly documents as the tenant-isolation mechanism: "Apps on separate 6PNs can
129
+ * never communicate unless explicitly configured to do so." Shared-app mode
130
+ * (`appPerProject: false`) exists for single-tenant deployments and is REFUSED
131
+ * in production. An app's network cannot be changed after creation, so an app
132
+ * that already exists is used as-is rather than silently "fixed".
133
+ *
134
+ * **Egress: `verifyEgress()` OBSERVES, and Fly's only lever is ports.** 6PN
135
+ * isolation answers "can app A reach app B", not "can a sandbox reach the
136
+ * internet". For that, the one mechanism Fly documents is a **network policy** —
137
+ * app-scoped `allow` rules where "Once you create a rule for a given direction,
138
+ * the default for that direction becomes drop." Set `egressAllowedPorts` (or
139
+ * `FLY_SANDBOX_EGRESS_ALLOWED_PORTS=tcp:3128,udp:53`) and this provider applies
140
+ * one to every app it provisions, BEFORE creating the Machine — Fly applies a
141
+ * policy at boot ("restart or redeploy the Machines for changes to take
142
+ * effect"), so a policy that lands afterwards does not cover the running
143
+ * Machine. A failure to apply it FAILS the boot rather than quietly starting an
144
+ * unfiltered sandbox. Three limits are load-bearing and cannot be designed
145
+ * around: rules match **protocol and port only** (no host, no CIDR, no ranges),
146
+ * so a policy can never be a host allowlist — `tcp:443` lets a sandbox reach
147
+ * every host on the internet that listens on 443; policies **do not apply to Fly
148
+ * Proxy traffic**; and the endpoints, while documented in the guide and the
149
+ * announcement, are **absent from Fly's OpenAPI specification**, so the LIST
150
+ * response shape is parsed defensively. To get host-level control, allow only
151
+ * the port of an egress proxy you run and route sandbox traffic through it.
152
+ *
153
+ * **Crossing the 6PN: `privateServices` is what makes a database work.** The
154
+ * per-project 6PN that stops tenants reaching each other also stops a sandbox
155
+ * reaching the tenant Postgres cluster and the control plane's egress proxy —
156
+ * both of which the product REQUIRES. Fly documents three ways across a 6PN
157
+ * boundary (https://fly.io/docs/networking/custom-private-networks/): a public
158
+ * service IP, `fly-replay`, and a **Flycast** private address allocated into the
159
+ * calling network. Only the third grants ONE directed edge with nothing exposed
160
+ * publicly, so it is what this bond implements. Declare the targets —
161
+ * `privateServices: [{ app: 'molecule-pg-tenant', port: 5432 }]`, or
162
+ * `FLY_SANDBOX_PRIVATE_SERVICES=molecule-pg-tenant:5432,molecule-api:3129` — and
163
+ * `create()` allocates one address per target INTO the project's own network
164
+ * (`POST /apps/{target}/ip_assignments`, the REST form of
165
+ * `fly ips allocate-v6 --private --network <project-net>`), records them in the
166
+ * Machine's metadata, and `destroy()` releases them. The sandbox then dials
167
+ * `<target>.flycast:<port>`.
168
+ *
169
+ * Four properties of that are worth knowing before you deploy it:
170
+ *
171
+ * - **Isolation is unchanged.** An allocation is a one-way grant from ONE
172
+ * project's network to ONE shared app. No sandbox app ever receives one, so
173
+ * no sandbox becomes reachable from anywhere it was not already.
174
+ * - **The declared port is unioned into the egress network policy**, because a
175
+ * policy is deny-by-default once any rule exists — declaring
176
+ * `molecule-pg-tenant:5432` under a `tcp:3128` policy would drop every
177
+ * database connection in the fleet. Whether the union is even load-bearing is
178
+ * UNVERIFIED: Fly says policies "do not affect traffic routed through the Fly
179
+ * Proxy" and Flycast IS Fly Proxy, but says nothing about a Machine's EGRESS
180
+ * toward a Flycast address. An inert entry costs a port; a missing one costs
181
+ * the fleet.
182
+ * - **Any `.flycast` URL in a sandbox's environment must have a matching
183
+ * declaration**, or `create()` throws. That check is what turns "the route was
184
+ * silently never created" — a healthy sandbox that cannot reach its own
185
+ * database — into a startup error, and it is also the guard on the
186
+ * control-plane cluster: reaching it would require an operator to declare it
187
+ * by name.
188
+ * - **The target app needs an `[http_service]`/`[services]` section and must
189
+ * bind `0.0.0.0`** (https://fly.io/docs/networking/flycast/). This bond cannot
190
+ * do that for you. **Fly documents no limit on how many private addresses one
191
+ * app may hold**, in either direction, so the shared target apps accumulate
192
+ * one address per live project — reconcile with `fly ips list --app <target>`.
193
+ *
194
+ * `verifyEgress()` then PROVES the result instead of reporting the
195
+ * configuration: it boots a throwaway Machine through the same `ensureApp` path
196
+ * every sandbox uses, attempts raw TCP connects to literal public IPs with proxy
197
+ * environment blanked, and maps connected → `open`, refused/timed-out →
198
+ * `filtered`, anything else → `inconclusive`. It costs one Machine boot (and, in
199
+ * per-project mode, one app create/delete) per call, so cache the verdict.
200
+ *
201
+ * **The preview URL is a template, and the isolation choice constrains it.**
202
+ * `getPreviewUrl()` renders `previewUrlTemplate` (default
203
+ * `https://{app}.fly.dev`) with `{app}`, `{machineId}` and `{port}`. The
204
+ * private form `http://{machineId}.vm.{app}.internal:{port}` reaches a Machine
205
+ * over 6PN with no public exposure at all — but only from the SAME 6PN, which
206
+ * per-project apps deliberately prevent. So a control plane that wants private
207
+ * previews must reach each app another way (Flycast, or a public router app
208
+ * using `fly-replay`), and a control plane off Fly entirely must use the public
209
+ * form. Public serving additionally requires an allocated IP: set
210
+ * `assignSharedIpv4: true` to request one at app-creation time. The accepted
211
+ * `type` values for `POST /v1/apps/{app}/ip_assignments` are NOT enumerated in
212
+ * Fly's OpenAPI specification — `shared_v4` is this provider's default and is
213
+ * configurable via `ipAssignmentType`, but treat that value as UNVERIFIED.
214
+ *
215
+ * **Volumes are provisioned by `create()`, not by `createVolume()`.** The
216
+ * optional core methods `createVolume`/`removeVolume`/`volumeExists` are
217
+ * deliberately NOT implemented: they receive only a name, while a Fly volume
218
+ * needs an app, a region and a size, and a volume created in the wrong app can
219
+ * never be mounted. So passing `SandboxConfig.volumeName` provisions the volume
220
+ * inside the project's app, mounted at `/workspace`; `resources.diskMB` is
221
+ * rounded UP to whole GB. Fly documents that "a volume can be attached to only
222
+ * one Machine", which matches one sandbox per project. Fly does not document the
223
+ * legal character set for volume names, so names are conservatively reduced to
224
+ * `[A-Za-z0-9_]` — an assumption, flagged as such.
225
+ *
226
+ * **Warm start: templates are tar archives in object storage.** Fly cannot
227
+ * `commit` a running Machine — it PULLS images from a registry — and the
228
+ * Machines API has no file-transfer endpoint at all, so a template here is a
229
+ * `tar.gz` of the caller-named `capturePaths` in an S3-compatible bucket
230
+ * (Tigris on Fly: `fly storage create` exports exactly the variables this bond
231
+ * reads). The bytes move DIRECTLY between the sandbox and the store over
232
+ * presigned URLs — the control plane only handles the URL and the manifest —
233
+ * because pushing hundreds of megabytes through `exec`'s JSON response is not a
234
+ * slower transfer, it is a different order of magnitude. `commitTemplate`
235
+ * REQUIRES `capturePaths`: unlike Docker, where the container's own image
236
+ * carries the filesystem, the archive here IS the template, so committing none
237
+ * would store an empty template that later boots successfully into an empty
238
+ * workspace. Configure it with `templateBucket` + `templateAccessKeyId` +
239
+ * `templateSecretAccessKey` (+ `templateEndpoint` for a non-AWS store); without
240
+ * them the template methods throw an actionable error naming the settings
241
+ * rather than pretending the capability is absent, the same call the Docker
242
+ * bond makes for `templateRegistry`.
243
+ *
244
+ * The **volume-snapshot** alternative was checked and rejected on Fly's own
245
+ * documentation, not on preference: `POST /v1/apps/{app}/volumes` does accept
246
+ * `snapshot_id` and `source_volume_id`, but "Every Fly Volume belongs to a Fly
247
+ * App and you can't share a volume between apps"
248
+ * (https://fly.io/docs/volumes/overview/) — and this bond gives every project
249
+ * its own app. A snapshot could serve a per-project restore point and never the
250
+ * cross-project warm start the capability exists for.
251
+ *
252
+ * **Booting from a template FAILS when the template is gone**, rather than
253
+ * falling back to the base image, which would hand back a healthy-looking
254
+ * sandbox whose filesystem is not the one that was named. One wording in the
255
+ * core cannot be honored literally: `templateId` does NOT override `image`
256
+ * here, because a Fly template is a filesystem rather than an image — the image
257
+ * still selects the OS and toolchain, and the template supplies the captured
258
+ * paths on top of it. A restore that fails DESTROYS the Machine before throwing.
259
+ *
260
+ * **The tenant boundary is the restore, not the capture.** A capture runs inside
261
+ * a sandbox the tenant controls, so nothing done there is a security control.
262
+ * The restore runs in a fresh Machine before anyone holds a handle to it, and
263
+ * extracts ONLY the paths recorded in the control-plane-written manifest (they
264
+ * are `tar` member selectors), with `--no-same-owner --no-same-permissions`,
265
+ * followed by a `find -perm /6000 -exec chmod a-s` sweep and then a second
266
+ * `find` that FAILS the restore if any setuid/setgid file survived. GNU tar's
267
+ * own refusals — a `..` member, an absolute member, a write through a symlink —
268
+ * all exit non-zero and therefore fail the restore too.
269
+ *
270
+ * **`inUse` means "a restore is in flight", and cannot mean more than that.** On
271
+ * Docker a template is the image a container runs, so deleting it destroys the
272
+ * container. Here the archive is COPIED into the sandbox at boot and never
273
+ * referenced again, so removing a template cannot destroy a running sandbox —
274
+ * the only window is a restore still reading it, which is tracked with a lease
275
+ * object in the same store (so a second control plane sees it) and ages out
276
+ * after the transfer budget. A lease whose timestamp cannot be read counts as
277
+ * live, and any failure to LIST or read the store throws rather than reporting
278
+ * "absent" or "not in use".
279
+ *
280
+ * **`publishTemplate`/`fetchTemplate` are NOT implemented, deliberately.** They
281
+ * exist for providers whose templates are host-local; object storage is already
282
+ * the shared store, so there is nothing to publish or fetch.
283
+ *
284
+ * **`exportFiles`/`importFiles` are NOT implemented.** The core says a provider
285
+ * with no bulk transfer should leave them unimplemented rather than emulate one,
286
+ * and Fly's only channel is the `exec` endpoint's JSON response — a file-by-file
287
+ * or base64 emulation would look supported and take hours. The template path
288
+ * moves bulk data without them precisely because it never routes the bytes
289
+ * through the control plane.
290
+ *
291
+ * **Sandbox ids are composite: `"<app>:<machineId>"`.** A Fly Machine id is only
292
+ * unique within its app and every endpoint is `/v1/apps/{app}/machines/{id}`, so
293
+ * a bare Machine id cannot be addressed. Keep treating the id as opaque.
294
+ *
295
+ * **The image must be pullable BY FLY.** Fly pulls the image itself, so a local
296
+ * `molecule-sandbox:latest` is invisible to it — push to the org's
297
+ * `registry.fly.io` repository or a public registry first. Unlike the Docker
298
+ * bond, nothing here can pre-pull on a host you control.
299
+ *
300
+ * **Rate limits are handled in the transport, but they also shape throughput.**
301
+ * Fly documents the Machines API at "1 request, per second, per action … with a
302
+ * short-term burst limit up to 3 req/s, per action", scoped "per-action,
303
+ * per-machine … That might be Machine ID or App ID, depending on the type of
304
+ * request", with Get Machine at 5 req/s and app deletions capped at 100 per
305
+ * minute. `429` and `5xx` are retried with `Retry-After`-aware backoff; other
306
+ * `4xx` responses are real answers and are never retried. The consequence to
307
+ * plan for is that EVERY file operation is an exec API call, so one sandbox's
308
+ * reads and writes serialize at roughly one per second and a multi-megabyte
309
+ * `writeFile` (chunked at ~45 KB decoded) takes tens of seconds. An agent that
310
+ * edits files in a tight loop will feel this; batch where you can.
311
+ *
312
+ * **`create()` launches the Machine, and `start()`/`wake()` WAIT for it.** Unlike
313
+ * Docker's create-then-start, the Machines API starts a Machine as part of
314
+ * creation. `POST .../start` only means Fly accepted the request, so
315
+ * `start()`/`wake()` then block on `GET .../wait?state=started` (Fly blocks
316
+ * server-side for up to 60 s, tunable with `startTimeoutSeconds`) and throw if
317
+ * the Machine never runs — without that, the caller's next `exec` would land on
318
+ * a Machine that is still booting.
319
+ *
320
+ * **Statuses are lossy.** Fly documents seventeen Machine states and the core
321
+ * has four. `failed`/`launch_failed` both map to `stopped` because the core
322
+ * union has no error status.
323
+ *
324
+ * **No per-sandbox disk quota beyond the volume.** `resources.diskMB` sizes the
325
+ * mounted volume; without `volumeName` it is not applied at all, and the rootfs
326
+ * size is Fly's default.
327
+ *
328
+ * @module
329
+ */
330
+ export * from './api.js';
331
+ export * from './browser-guard.js';
332
+ export * from './egress.js';
333
+ export * from './exec.js';
334
+ export * from './flycast.js';
335
+ export * from './ids.js';
336
+ export * from './provider.js';
337
+ export * from './storage.js';
338
+ export * from './templates.js';
339
+ export * from './types.js';
340
+ export * from './utilities.js';
341
+ import type { SandboxProvider } from '@molecule/api-code-sandbox';
342
+ /**
343
+ * The provider implementation.
344
+ */
345
+ export declare const provider: SandboxProvider;
346
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwUG;AAEH,cAAc,UAAU,CAAA;AACxB,cAAc,oBAAoB,CAAA;AAClC,cAAc,aAAa,CAAA;AAC3B,cAAc,WAAW,CAAA;AACzB,cAAc,cAAc,CAAA;AAC5B,cAAc,UAAU,CAAA;AACxB,cAAc,eAAe,CAAA;AAC7B,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,YAAY,CAAA;AAC1B,cAAc,gBAAgB,CAAA;AAE9B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AAMjE;;GAEG;AACH,eAAO,MAAM,QAAQ,EAAE,eAUrB,CAAA"}