@molecule/api-code-sandbox-flyio 1.3.6 → 1.3.9
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/README.md +359 -163
- package/dist/api.d.ts +17 -2
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +24 -3
- package/dist/ids.d.ts +9 -10
- package/dist/ids.d.ts.map +1 -1
- package/dist/ids.js +32 -18
- package/dist/index.d.ts +33 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +33 -4
- package/dist/lifecycle.d.ts +142 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +14 -0
- package/dist/provider.d.ts +2 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +1484 -306
- package/dist/types.d.ts +33 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -10
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@ AUTO-GENERATED — DO NOT EDIT THIS FILE.
|
|
|
3
3
|
Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
|
|
4
4
|
Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
|
|
5
5
|
To change this document, edit the module-level JSDoc in src/index.ts.
|
|
6
|
-
Generated: 2026-
|
|
6
|
+
Generated: 2026-11-04T14:19:55.149Z
|
|
7
7
|
-->
|
|
8
8
|
|
|
9
9
|
# @molecule/api-code-sandbox-flyio
|
|
@@ -16,6 +16,10 @@ SCALE-TO-ZERO: `sleep()` maps to Fly's `suspend`, which snapshots the microVM's
|
|
|
16
16
|
memory to disk, and a suspended Machine bills for storage only. Waking is a
|
|
17
17
|
resume from that snapshot rather than a cold boot.
|
|
18
18
|
|
|
19
|
+
`FLY_REGION` is the API Machine's own location and is IGNORED for tenant
|
|
20
|
+
placement. Set the provider's `region` default, or pass one create's
|
|
21
|
+
`SandboxConfig.region` for a project whose placement was recorded at creation.
|
|
22
|
+
|
|
19
23
|
> **Auto-generated, AI-first package reference** for the [molecule.dev](https://molecule.dev) ecosystem.
|
|
20
24
|
> It is written to be read by coding agents as much as by people, and is generated from this
|
|
21
25
|
> package's source — edit `src/index.ts` JSDoc, not this file.
|
|
@@ -87,13 +91,15 @@ npm install @molecule/api-code-sandbox-flyio @aws-sdk/client-s3 @aws-sdk/s3-requ
|
|
|
87
91
|
Options for a single Fly API request.
|
|
88
92
|
|
|
89
93
|
```typescript
|
|
90
|
-
interface FlyRequestOptions {
|
|
94
|
+
interface FlyRequestOptions<T = unknown> {
|
|
91
95
|
/** HTTP method. Defaults to `GET`. */
|
|
92
96
|
method?: string
|
|
93
97
|
/** JSON request body. Omitted entirely when undefined. */
|
|
94
98
|
body?: unknown
|
|
95
99
|
/** Per-request timeout in ms. Defaults to the client's configured timeout. */
|
|
96
100
|
timeoutMs?: number
|
|
101
|
+
/** Cancels in-flight provisioning when the owning lifecycle lock is lost. */
|
|
102
|
+
signal?: AbortSignal
|
|
97
103
|
/** Max attempts for a retryable failure. Defaults to 4. Pass 1 to disable retries. */
|
|
98
104
|
attempts?: number
|
|
99
105
|
/**
|
|
@@ -108,6 +114,19 @@ interface FlyRequestOptions {
|
|
|
108
114
|
* under exec-burst load, not a real absence, so it is worth repeating.
|
|
109
115
|
*/
|
|
110
116
|
retryStatuses?: number[]
|
|
117
|
+
/**
|
|
118
|
+
* Restricts retries opted into by `retryStatuses` using the response error.
|
|
119
|
+
* Return false to surface that response immediately. The client's standard
|
|
120
|
+
* transport, 429 and 5xx retries are unaffected.
|
|
121
|
+
*/
|
|
122
|
+
retryWhen?: (error: FlyApiError) => boolean
|
|
123
|
+
/**
|
|
124
|
+
* Reconciles a retryable failure before the client backs off or throws.
|
|
125
|
+
* Returning a value completes the request; `undefined` keeps the normal
|
|
126
|
+
* retry/exhaustion path. This lets an ambiguous write look for the resource
|
|
127
|
+
* it may have created without adding a second POST retry loop.
|
|
128
|
+
*/
|
|
129
|
+
reconcile?: (error: FlyApiError) => Promise<T | undefined>
|
|
111
130
|
}
|
|
112
131
|
```
|
|
113
132
|
|
|
@@ -176,6 +195,183 @@ interface EgressProbeContext {
|
|
|
176
195
|
}
|
|
177
196
|
```
|
|
178
197
|
|
|
198
|
+
#### `FlyCreateVolumeIntent`
|
|
199
|
+
|
|
200
|
+
A volume name generated for one attempt, or an existing durable workspace.
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
interface FlyCreateVolumeIntent {
|
|
204
|
+
/** Fly's resource ID. Once known, this—not the name—is the ownership key. */
|
|
205
|
+
id?: string
|
|
206
|
+
/** The current Fly name, unique for a newly-created attempt volume. */
|
|
207
|
+
name: string
|
|
208
|
+
/** Caller-level workspace identity, stable across create attempts. */
|
|
209
|
+
logicalName?: string
|
|
210
|
+
/** Once bound, only explicit destruction or a reference-checked failed-create sweep may delete it. */
|
|
211
|
+
workspace: boolean
|
|
212
|
+
/** False until an ambiguous create is adopted or definitely rejected. */
|
|
213
|
+
resolved?: boolean
|
|
214
|
+
/** Recorded before this attempt's create POST; automatic deletion also requires a resolved Fly ID. */
|
|
215
|
+
createdByAttemptId?: string
|
|
216
|
+
/** Recorded when this attempt adopted an existing/listed volume; never automatic-delete authority. */
|
|
217
|
+
adoptedByAttemptId?: string
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
#### `FlyCreateAddressIntent`
|
|
222
|
+
|
|
223
|
+
An address on another Fly app which must be released before forgetting intent.
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
interface FlyCreateAddressIntent {
|
|
227
|
+
targetApp: string
|
|
228
|
+
network: string
|
|
229
|
+
ip?: string
|
|
230
|
+
/** True only after Fly returned this allocation to this attempt. */
|
|
231
|
+
owned: boolean
|
|
232
|
+
/** False while a lost response leaves allocation ownership unknown. */
|
|
233
|
+
resolved: boolean
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
#### `FlyCreateAttempt`
|
|
238
|
+
|
|
239
|
+
The complete durable responsibility for one create attempt.
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
interface FlyCreateAttempt {
|
|
243
|
+
attemptId: string
|
|
244
|
+
projectId: string
|
|
245
|
+
appName: string
|
|
246
|
+
state: FlyCreateAttemptState
|
|
247
|
+
/** Monotonic fencing token, incremented whenever lifecycle ownership is reacquired. */
|
|
248
|
+
generation?: number
|
|
249
|
+
sandboxId?: string
|
|
250
|
+
/** Fly definitively rejected the Machine POST; an unknown outcome is never marked. */
|
|
251
|
+
machineCreateFailed?: boolean
|
|
252
|
+
/**
|
|
253
|
+
* Durable Machine POST lifecycle. New attempts start at not-started; pending is
|
|
254
|
+
* written before dispatch. NULL is a legacy unknown, never proof of no POST.
|
|
255
|
+
*/
|
|
256
|
+
machineCreateOutcome?: 'not-started' | 'pending' | 'rejected' | 'unknown' | 'accepted'
|
|
257
|
+
/** The exact Machine ID returned by this attempt's accepted POST response, or an observation without delete provenance. */
|
|
258
|
+
machineId?: string
|
|
259
|
+
/** Accepted-POST provenance; name, metadata, sandboxId and adoption never supply it. */
|
|
260
|
+
machineCreatedByAttemptId?: string
|
|
261
|
+
/** This attempt's app-create intent, recorded before the POST. */
|
|
262
|
+
appCreatedByAttemptId?: string
|
|
263
|
+
/** True only when the app POST itself was accepted; a pending/unknown create cannot be deleted. */
|
|
264
|
+
appCreateResolved?: boolean
|
|
265
|
+
/** True when explicit destruction identity-validated and adopted a pre-journal Machine. */
|
|
266
|
+
machineAdopted?: boolean
|
|
267
|
+
volumes: FlyCreateVolumeIntent[]
|
|
268
|
+
addresses: FlyCreateAddressIntent[]
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
#### `FlyCreateAttemptJournal`
|
|
273
|
+
|
|
274
|
+
Application-supplied durable journal used by create and its reconciler.
|
|
275
|
+
A journal-aware writer that ignores provenance must never run beside this
|
|
276
|
+
contract; {@link assertCreateAllowed} is the durable rollout boundary.
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
interface FlyCreateAttemptJournal {
|
|
280
|
+
begin(
|
|
281
|
+
attempt: Pick<FlyCreateAttempt, 'attemptId' | 'projectId' | 'appName'> & {
|
|
282
|
+
ownerProjectId?: string
|
|
283
|
+
/** Legacy adoption persists its observed serving state and identity in the INSERT. */
|
|
284
|
+
state?: FlyCreateAttemptState
|
|
285
|
+
sandboxId?: string
|
|
286
|
+
machineAdopted?: boolean
|
|
287
|
+
},
|
|
288
|
+
): Promise<void>
|
|
289
|
+
/** Refuse provisioning until pre-journal and provenance-ignoring writers have drained. */
|
|
290
|
+
assertCreateAllowed(): Promise<void>
|
|
291
|
+
/** Re-read one attempt, including after a lifecycle lock has been acquired. */
|
|
292
|
+
get(attemptId: string): Promise<FlyCreateAttempt | null>
|
|
293
|
+
/** Re-read every durable attempt for an app, including creating/serving attempts. */
|
|
294
|
+
forApp(appName: string): Promise<FlyCreateAttempt[]>
|
|
295
|
+
/** Find the durable workspace binding for a logical caller workspace. */
|
|
296
|
+
findWorkspace(projectId: string, logicalName: string): Promise<FlyCreateVolumeIntent | null>
|
|
297
|
+
/** Record app-create intent/acceptance, or clear it when the app was adopted. */
|
|
298
|
+
recordAppProvenance(
|
|
299
|
+
attemptId: string,
|
|
300
|
+
generation: number,
|
|
301
|
+
outcome: 'pending' | 'created' | 'adopted',
|
|
302
|
+
): Promise<void>
|
|
303
|
+
/** Record an intent or transfer a Fly-ID binding to this attempt. */
|
|
304
|
+
recordVolume(attemptId: string, volume: FlyCreateVolumeIntent): Promise<void>
|
|
305
|
+
markVolumeWorkspace(attemptId: string, name: string, workspace?: boolean): Promise<void>
|
|
306
|
+
recordAddress(attemptId: string, address: FlyCreateAddressIntent): Promise<void>
|
|
307
|
+
markState(attemptId: string, state: FlyCreateAttemptState, sandboxId?: string): Promise<void>
|
|
308
|
+
/** Record that explicit destruction adopted this pre-journal Machine. */
|
|
309
|
+
markMachineAdopted(attemptId: string): Promise<void>
|
|
310
|
+
/** Record POST intent before dispatch and an ambiguous outcome before recovery reads. */
|
|
311
|
+
recordMachineCreateOutcome(
|
|
312
|
+
attemptId: string,
|
|
313
|
+
generation: number,
|
|
314
|
+
outcome: 'pending' | 'unknown',
|
|
315
|
+
): Promise<void>
|
|
316
|
+
/** Persist a recovered exact ID as observation only, never accepted-POST authority. */
|
|
317
|
+
recordMachineObservation(attemptId: string, generation: number, machineId: string): Promise<void>
|
|
318
|
+
/** Record only the exact ID from this generation's accepted Machine POST response. */
|
|
319
|
+
recordMachineProvenance(attemptId: string, generation: number, machineId: string): Promise<void>
|
|
320
|
+
/** Record a definitive rejection only while this create generation still owns the attempt. */
|
|
321
|
+
markMachineCreateFailed(attemptId: string, generation: number): Promise<void>
|
|
322
|
+
/** Throw unless this generation is still the attempt's durable fencing token. */
|
|
323
|
+
assertGeneration(attemptId: string, generation: number): Promise<void>
|
|
324
|
+
/** Atomically retire this exact generation while its lifecycle lock session is live. */
|
|
325
|
+
retire(attemptId: string, generation: number): Promise<void>
|
|
326
|
+
/** Re-authorize one destructive request against the retired row on the lock session. */
|
|
327
|
+
authorizeDestruction(attemptId: string, generation: number): Promise<void>
|
|
328
|
+
/** Failed/abandoned attempts, plus creating attempts whose updatedAt is at/before cutoff. */
|
|
329
|
+
reclaimable(cutoff: Date): Promise<FlyCreateAttempt[]>
|
|
330
|
+
/** Acquire the same lifecycle lock the application uses for create/destroy. */
|
|
331
|
+
withLock?<T>(
|
|
332
|
+
attempt: FlyCreateAttempt,
|
|
333
|
+
body: (signal: AbortSignal, generation: number) => Promise<T>,
|
|
334
|
+
): Promise<T>
|
|
335
|
+
forget(attemptId: string, generation?: number): Promise<void>
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
#### `FlyCreateReconcileSummary`
|
|
340
|
+
|
|
341
|
+
Attempts forgotten after completed cleanup, and attempts retained after errors.
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
interface FlyCreateReconcileSummary {
|
|
345
|
+
deleted: number
|
|
346
|
+
failed: number
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
#### `FlyCreateAttemptProvider`
|
|
351
|
+
|
|
352
|
+
Fly-specific lifecycle capabilities exposed by the provider.
|
|
353
|
+
|
|
354
|
+
```typescript
|
|
355
|
+
interface FlyCreateAttemptProvider {
|
|
356
|
+
/** Explicit retirement is fenced by the caller's live lifecycle lock. */
|
|
357
|
+
destroy(id: string, signal: AbortSignal): Promise<void>
|
|
358
|
+
/** Deliberately retire exact recorded resources for one shared-app attempt. */
|
|
359
|
+
destroyAttempt(attemptId: string, signal: AbortSignal): Promise<void>
|
|
360
|
+
/** Per-project app retirement is fenced by the caller's live lifecycle lock. */
|
|
361
|
+
destroyApp(
|
|
362
|
+
projectId: string,
|
|
363
|
+
expectedAppName: string | undefined,
|
|
364
|
+
signal: AbortSignal,
|
|
365
|
+
): Promise<void>
|
|
366
|
+
reconcileCreateAttempts(options?: {
|
|
367
|
+
now?: number
|
|
368
|
+
createTimeoutMs?: number
|
|
369
|
+
/** Restrict a pass to one durable attempt. Every attempt is still locked. */
|
|
370
|
+
attemptId?: string
|
|
371
|
+
}): Promise<FlyCreateReconcileSummary>
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
179
375
|
#### `StoredObject`
|
|
180
376
|
|
|
181
377
|
One object as this bond needs to see it.
|
|
@@ -288,167 +484,95 @@ Every option is also readable from an environment variable (see
|
|
|
288
484
|
|
|
289
485
|
```typescript
|
|
290
486
|
interface FlyioConfig {
|
|
291
|
-
/**
|
|
292
|
-
* Fly API token (sent as `Authorization: Bearer <token>`). Falls back to
|
|
293
|
-
* `FLY_API_TOKEN`, then `FLY_ACCESS_TOKEN`. Required — the provider throws a
|
|
294
|
-
* named error on the first API call if none is resolvable.
|
|
295
|
-
*/
|
|
487
|
+
/** Fly API token (sent as `Authorization: Bearer <token>`). */
|
|
296
488
|
apiToken?: string
|
|
297
|
-
/**
|
|
298
|
-
* Machines API base URL, INCLUDING the `/v1` path segment. Defaults to
|
|
299
|
-
* `FLY_API_HOSTNAME` (with `/v1` appended when the value has no path) or
|
|
300
|
-
* `https://api.machines.dev/v1`. From inside a Fly private network the
|
|
301
|
-
* documented internal endpoint is `http://_api.internal:4280/v1`.
|
|
302
|
-
*/
|
|
489
|
+
/** Machines API base URL, INCLUDING the `/v1` path segment. */
|
|
303
490
|
apiUrl?: string
|
|
304
|
-
/**
|
|
305
|
-
* Fly organization slug that owns the sandbox apps. Falls back to
|
|
306
|
-
* `FLY_ORG_SLUG`, then `personal`. Required for app creation and for
|
|
307
|
-
* {@link FlyioConfig.appPerProject} listing.
|
|
308
|
-
*/
|
|
491
|
+
/** Fly organization slug that owns the sandbox apps. */
|
|
309
492
|
orgSlug?: string
|
|
310
|
-
/**
|
|
311
|
-
* Shared Fly app that holds every sandbox Machine. Only used when
|
|
312
|
-
* `appPerProject` is `false`. Falls back to `FLY_SANDBOX_APP`.
|
|
313
|
-
*
|
|
314
|
-
* A shared app puts every tenant on ONE 6PN private network, where each
|
|
315
|
-
* sandbox can reach every other sandbox's dev-server ports by private IPv6.
|
|
316
|
-
* That is the same cross-tenant exposure the Docker bond's `bridge` network
|
|
317
|
-
* has, so this mode is REFUSED in production.
|
|
318
|
-
*/
|
|
493
|
+
/** Shared Fly app that holds every sandbox Machine. */
|
|
319
494
|
appName?: string
|
|
320
|
-
/**
|
|
321
|
-
* Name prefix for the per-project Fly app when `appPerProject` is `true`
|
|
322
|
-
* (default). The app is `<appPrefix>-<sanitized projectId>`. Falls back to
|
|
323
|
-
* `FLY_SANDBOX_APP_PREFIX`, then `mol-sandbox`.
|
|
324
|
-
*/
|
|
495
|
+
/** Name prefix for the per-project Fly app when `appPerProject` is `true` (default). */
|
|
325
496
|
appPrefix?: string
|
|
326
|
-
/**
|
|
327
|
-
* Create one Fly app — on its own custom 6PN private network — per project,
|
|
328
|
-
* so tenants are network-isolated from each other. Defaults to `true`.
|
|
329
|
-
* Set `false` (or `FLY_SANDBOX_APP_PER_PROJECT=false`) only for a
|
|
330
|
-
* single-tenant deployment; it is refused in production.
|
|
331
|
-
*/
|
|
497
|
+
/** Create one Fly app — on its own custom 6PN private network — per project, so tenants are network-isolated from each other. */
|
|
332
498
|
appPerProject?: boolean
|
|
333
|
-
/**
|
|
334
|
-
* Custom 6PN network name for a per-project app. Defaults to the app name, so
|
|
335
|
-
* every project lands on its own private network. Ignored when
|
|
336
|
-
* `appPerProject` is `false`. A Fly app's network CANNOT be changed after
|
|
337
|
-
* creation.
|
|
338
|
-
*/
|
|
499
|
+
/** Custom 6PN network name for a per-project app. */
|
|
339
500
|
network?: string
|
|
340
|
-
/** Fly region for Machines and volumes (e.g. `iad`).
|
|
501
|
+
/** Default Fly region for Machines and volumes (e.g. `iad`). */
|
|
341
502
|
region?: string
|
|
342
|
-
/**
|
|
343
|
-
* OCI image every sandbox Machine runs. Must be pullable by Fly — a tag in
|
|
344
|
-
* the org's `registry.fly.io` repository or a public registry. A LOCAL
|
|
345
|
-
* `molecule-sandbox:latest` is not reachable by Fly; push it first. Falls
|
|
346
|
-
* back to `FLY_SANDBOX_IMAGE`, then `registry.fly.io/molecule-sandbox:latest`.
|
|
347
|
-
*/
|
|
503
|
+
/** OCI image every sandbox Machine runs. */
|
|
348
504
|
baseImage?: string
|
|
349
|
-
/** Default vCPU count (`guest.cpus`).
|
|
505
|
+
/** Default vCPU count (`guest.cpus`). */
|
|
350
506
|
defaultCpu?: number
|
|
351
|
-
/** Default `guest.cpu_kind` — `shared` or `performance`.
|
|
507
|
+
/** Default `guest.cpu_kind` — `shared` or `performance`. */
|
|
352
508
|
defaultCpuKind?: string
|
|
353
|
-
/** Default memory in MB (`guest.memory_mb`).
|
|
509
|
+
/** Default memory in MB (`guest.memory_mb`). */
|
|
354
510
|
defaultMemoryMB?: number
|
|
355
|
-
/**
|
|
356
|
-
* Size in GB of the volume created for a sandbox's `/workspace` when
|
|
357
|
-
* `SandboxConfig.volumeName` is set. Fly volumes are sized in whole GB, so the
|
|
358
|
-
* core's `resources.diskMB` is rounded UP to the next GB. Defaults to 10.
|
|
359
|
-
*/
|
|
511
|
+
/** Size in GB of the volume created for a sandbox's `/workspace` when `SandboxConfig.volumeName` is set. */
|
|
360
512
|
defaultVolumeGB?: number
|
|
361
|
-
/** Internal port the preview service forwards to (the Vite dev server).
|
|
513
|
+
/** Internal port the preview service forwards to (the Vite dev server). */
|
|
362
514
|
previewPort?: number
|
|
363
|
-
/**
|
|
364
|
-
* Preview URL template. Placeholders `{app}`, `{machineId}` and `{port}` are
|
|
365
|
-
* all replaced globally. Defaults to `https://{app}.fly.dev`.
|
|
366
|
-
*
|
|
367
|
-
* For a control plane that runs on the SAME Fly 6PN, the private form
|
|
368
|
-
* `http://{machineId}.vm.{app}.internal:{port}` reaches the Machine with no
|
|
369
|
-
* public exposure at all — but it does NOT work across custom 6PNs, which is
|
|
370
|
-
* exactly what `appPerProject` creates. See the module `@remarks`.
|
|
371
|
-
*/
|
|
515
|
+
/** Preview URL template. */
|
|
372
516
|
previewUrlTemplate?: string
|
|
373
|
-
/**
|
|
374
|
-
* Attach a public Fly Proxy service (`80` → http, `443` → tls+http) forwarding
|
|
375
|
-
* to {@link FlyioConfig.previewPort}. Defaults to `true`. Set `false` for a
|
|
376
|
-
* fully private sandbox reached only over 6PN/Flycast.
|
|
377
|
-
*/
|
|
517
|
+
/** Attach a public Fly Proxy service (`80` → http, `443` → tls+http) forwarding to {@link FlyioConfig.previewPort}. */
|
|
378
518
|
publicService?: boolean
|
|
379
|
-
/**
|
|
380
|
-
* Fly Proxy idle behaviour for the preview service. Defaults to `'off'` —
|
|
381
|
-
* never idle the Machine — because the autostop timer counts the PROXY
|
|
382
|
-
* service's idle time, not the workload's, so a sandbox with nobody looking at
|
|
383
|
-
* its preview is suspended out from under a build that is still running.
|
|
384
|
-
* `suspend` is the scale-to-zero mapping (resume from a memory snapshot on the
|
|
385
|
-
* next request) and `stop` is a full stop with a cold boot on wake; opt into
|
|
386
|
-
* either deliberately.
|
|
387
|
-
*
|
|
388
|
-
* This is the provider's STEADY-STATE policy, reported as
|
|
389
|
-
* `SandboxProvider.defaultIdleAction`. A caller can override it for one sandbox
|
|
390
|
-
* with `SandboxConfig.idleAction` and restore this value afterwards with
|
|
391
|
-
* `Sandbox.setIdleAction` — which is what a production deploy does so its
|
|
392
|
-
* destination cannot suspend mid-copy.
|
|
393
|
-
*/
|
|
519
|
+
/** Fly Proxy idle behaviour for the preview service. */
|
|
394
520
|
autostop?: 'off' | 'stop' | 'suspend'
|
|
395
|
-
/**
|
|
396
|
-
* Allocate a shared Anycast IPv4 for a newly-created app so `<app>.fly.dev`
|
|
397
|
-
* serves traffic. Defaults to `false` (opt-in). See the module `@remarks` —
|
|
398
|
-
* the accepted `type` values for the IP-assignment endpoint are NOT enumerated
|
|
399
|
-
* in Fly's OpenAPI specification.
|
|
400
|
-
*/
|
|
521
|
+
/** Allocate a shared Anycast IPv4 for a newly-created app so `<app>.fly.dev` serves traffic. */
|
|
401
522
|
assignSharedIpv4?: boolean
|
|
402
|
-
/** IP type requested when {@link FlyioConfig.assignSharedIpv4} is on.
|
|
523
|
+
/** IP type requested when {@link FlyioConfig.assignSharedIpv4} is on. */
|
|
403
524
|
ipAssignmentType?: string
|
|
404
|
-
/** Prefix for the Machine metadata keys this provider owns.
|
|
525
|
+
/** Prefix for the Machine metadata keys this provider owns. */
|
|
405
526
|
metadataPrefix?: string
|
|
406
|
-
/**
|
|
527
|
+
/** Durable create-attempt ownership. */
|
|
528
|
+
createAttemptJournal?: FlyCreateAttemptJournal
|
|
529
|
+
/** Timeout for a single Machines API request, in ms. */
|
|
407
530
|
requestTimeoutMs?: number
|
|
408
|
-
/**
|
|
409
|
-
* TOTAL budget, in seconds, that `create()`/`start()`/`wake()` block waiting
|
|
410
|
-
* for the Machine to actually reach `started`. Fly's `GET .../wait` blocks
|
|
411
|
-
* for at most 60 seconds per call, so a larger budget is spent as consecutive
|
|
412
|
-
* wait rounds. Defaults to 180 — a Machine whose image is not yet cached on
|
|
413
|
-
* its host (every first boot after an image push) pulls it before starting,
|
|
414
|
-
* and that alone can exceed a single 60 s round.
|
|
415
|
-
*
|
|
416
|
-
* Without this wait, `start()` resolves the moment Fly ACCEPTS the request,
|
|
417
|
-
* and the caller's very next `exec` hits a Machine that is not running yet.
|
|
418
|
-
*/
|
|
531
|
+
/** TOTAL budget, in seconds, that `create()`/`start()`/`wake()` block waiting for the Machine to actually reach `started`. */
|
|
419
532
|
startTimeoutSeconds?: number
|
|
420
|
-
/**
|
|
421
|
-
* Ports a sandbox Machine may open outbound connections on. Setting this makes
|
|
422
|
-
* the provider apply a Fly **network policy** to every app it provisions;
|
|
423
|
-
* leaving it unset applies no policy at all, and `verifyEgress()` will then
|
|
424
|
-
* observe (correctly) that egress is `open`. Falls back to
|
|
425
|
-
* `FLY_SANDBOX_EGRESS_ALLOWED_PORTS` (`tcp:3128,udp:53`).
|
|
426
|
-
*
|
|
427
|
-
* Read {@link https://fly.io/docs/machines/guides-examples/network-policies/}
|
|
428
|
-
* before choosing a value, because the mechanism is narrower than it looks:
|
|
429
|
-
* rules match protocol and port ONLY — no host, no CIDR, no ranges — so this
|
|
430
|
-
* can never be a host allowlist. `tcp:443` lets a sandbox reach EVERY host on
|
|
431
|
-
* the internet that listens on 443. To get a host allowlist, allow only the
|
|
432
|
-
* port of an egress proxy you control and route sandbox traffic through it.
|
|
433
|
-
*
|
|
434
|
-
* An empty array is rejected rather than treated as "deny all": Fly documents
|
|
435
|
-
* the deny default as a consequence of an `allow` rule existing, and says
|
|
436
|
-
* nothing about a rule with no ports.
|
|
437
|
-
*/
|
|
533
|
+
/** Ports a sandbox Machine may open outbound connections on. */
|
|
438
534
|
egressAllowedPorts?: FlyNetworkPolicyPort[]
|
|
439
|
-
/** Name of the egress policy this provider owns on each app.
|
|
535
|
+
/** Name of the egress policy this provider owns on each app. */
|
|
440
536
|
egressPolicyName?: string
|
|
441
|
-
/**
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
537
|
+
/** Apps every sandbox must be able to reach ACROSS its per-project 6PN — the tenant Postgres cluster and the control plane's egress proxy. */
|
|
538
|
+
privateServices?: FlyPrivateService[]
|
|
539
|
+
/** Per-create regional overrides for {@link FlyioConfig.privateServices}. */
|
|
540
|
+
privateServicesByRegion?: Record<string, FlyPrivateService[]>
|
|
541
|
+
/** `type` sent to `POST /apps/{app}/ip_assignments` when allocating a Flycast address. */
|
|
542
|
+
privateIpAssignmentType?: string
|
|
543
|
+
/** Literal `ip:port` targets `verifyEgress()` attempts raw TCP connections to. */
|
|
544
|
+
egressProbeTargets?: string[]
|
|
545
|
+
/** Per-connection timeout for the egress probe, in ms. */
|
|
546
|
+
egressProbeTimeoutMs?: number
|
|
547
|
+
/** Image the throwaway `verifyEgress()` probe Machine runs. */
|
|
548
|
+
egressProbeImage?: string
|
|
549
|
+
/** Bucket holding sandbox templates. */
|
|
550
|
+
templateBucket?: string
|
|
551
|
+
/** S3 endpoint for {@link FlyioConfig.templateBucket}, e.g. Tigris's `https://t3.storage.dev`. */
|
|
552
|
+
templateEndpoint?: string
|
|
553
|
+
/** Region used to sign template-store requests. */
|
|
554
|
+
templateRegion?: string
|
|
555
|
+
/** Access key for the template store. */
|
|
556
|
+
templateAccessKeyId?: string
|
|
557
|
+
/** Secret key for the template store. */
|
|
558
|
+
templateSecretAccessKey?: string
|
|
559
|
+
/** Session token for temporary template-store credentials. */
|
|
560
|
+
templateSessionToken?: string
|
|
561
|
+
/** Key prefix every template object lives under. */
|
|
562
|
+
templatePrefix?: string
|
|
563
|
+
/** Address the template bucket path-style (`https://endpoint/bucket/key`) instead of virtual-host style. */
|
|
564
|
+
templateForcePathStyle?: boolean
|
|
565
|
+
/** Lifetime of the presigned capture/restore URL handed to a sandbox, in seconds. */
|
|
566
|
+
templateUrlExpirySeconds?: number
|
|
567
|
+
/** Wall-clock budget for one capture or restore transfer, in ms. */
|
|
568
|
+
templateTransferTimeoutMs?: number
|
|
569
|
+
/** Largest template archive this provider will store, in bytes. */
|
|
570
|
+
templateMaxArchiveBytes?: number
|
|
571
|
+
}
|
|
450
572
|
```
|
|
451
573
|
|
|
574
|
+
_(Member comments are shortened to their first sentence to fit; the full declaration is in `node_modules/@molecule/api-code-sandbox-flyio/dist/types.d.ts`.)_
|
|
575
|
+
|
|
452
576
|
#### `ProcessEnv`
|
|
453
577
|
|
|
454
578
|
Environment variables the Fly.io sandbox provider reads. Every one is
|
|
@@ -470,7 +594,11 @@ interface ProcessEnv {
|
|
|
470
594
|
FLY_SANDBOX_APP_PREFIX?: string
|
|
471
595
|
/** `false` disables per-project apps (refused in production). */
|
|
472
596
|
FLY_SANDBOX_APP_PER_PROJECT?: string
|
|
473
|
-
/**
|
|
597
|
+
/**
|
|
598
|
+
* The API Machine's own region, not a tenant-placement setting; ignored.
|
|
599
|
+
*
|
|
600
|
+
* @deprecated Use `FlyioConfig.region` or one create's `SandboxConfig.region`.
|
|
601
|
+
*/
|
|
474
602
|
FLY_REGION?: string
|
|
475
603
|
/** OCI image for sandbox Machines. */
|
|
476
604
|
FLY_SANDBOX_IMAGE?: string
|
|
@@ -555,16 +683,20 @@ interface FlyPrivateService {
|
|
|
555
683
|
An IP assignment, as returned by `POST`/`GET /v1/apps/{app}/ip_assignments`
|
|
556
684
|
(`IPAssignment` in https://docs.machines.dev/openapi.json).
|
|
557
685
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
addresses it allocated in the sandbox Machine's metadata instead of
|
|
562
|
-
rediscovering them.
|
|
686
|
+
Current Fly schema includes the assignment's network identity, allowing a
|
|
687
|
+
lost allocation response to be resolved by target app and 6PN. Durable
|
|
688
|
+
records remain authoritative for what this provider must release.
|
|
563
689
|
|
|
564
690
|
```typescript
|
|
565
691
|
interface FlyIpAssignment {
|
|
566
692
|
/** The allocated address. Required for a later `DELETE .../ip_assignments/{ip}`. */
|
|
567
693
|
ip?: string
|
|
694
|
+
/**
|
|
695
|
+
* Private 6PN identity. GET returns `{name, org_slug}` (null for non-Flycast
|
|
696
|
+
* assignments); only the POST request body uses separate string fields.
|
|
697
|
+
* The string form remains accepted for older cached/test responses.
|
|
698
|
+
*/
|
|
699
|
+
network?: string | { name?: string | null; org_slug?: string | null } | null
|
|
568
700
|
/** Region the address was allocated in, when Fly reports one. */
|
|
569
701
|
region?: string
|
|
570
702
|
/** Service the address is bound to, when the assignment names one. */
|
|
@@ -776,6 +908,8 @@ interface FlyVolume {
|
|
|
776
908
|
size_gb?: number
|
|
777
909
|
region?: string
|
|
778
910
|
attached_machine_id?: string
|
|
911
|
+
/** RFC 3339 creation time; required as proof before an old attempt is reaped. */
|
|
912
|
+
created_at?: string
|
|
779
913
|
}
|
|
780
914
|
```
|
|
781
915
|
|
|
@@ -802,6 +936,14 @@ Runs one exec call against a Machine. Injected so this module stays transport-fr
|
|
|
802
936
|
type RawExec = (command: string[], timeoutSeconds: number) => Promise<FlyExecResponse>
|
|
803
937
|
```
|
|
804
938
|
|
|
939
|
+
#### `FlyCreateAttemptState`
|
|
940
|
+
|
|
941
|
+
Durable lifecycle state; only failed/abandoned attempts are automatically reclaimed.
|
|
942
|
+
|
|
943
|
+
```typescript
|
|
944
|
+
type FlyCreateAttemptState = 'creating' | 'serving' | 'failed' | 'abandoned'
|
|
945
|
+
```
|
|
946
|
+
|
|
805
947
|
#### `FlyMachineState`
|
|
806
948
|
|
|
807
949
|
A Fly Machine state, as documented at https://fly.io/docs/machines/machine-states/.
|
|
@@ -924,7 +1066,7 @@ class FlyApiClient {
|
|
|
924
1066
|
* @throws {FlyApiError} On a non-2xx status that is not listed in `nullOn`,
|
|
925
1067
|
* or when every retryable attempt is exhausted.
|
|
926
1068
|
*/
|
|
927
|
-
async request<T>(path: string, options: FlyRequestOptions = {}): Promise<T | null> {
|
|
1069
|
+
async request<T>(path: string, options: FlyRequestOptions<T> = {}): Promise<T | null> {
|
|
928
1070
|
const method = options.method ?? 'GET'
|
|
929
1071
|
const attempts = options.attempts ?? DEFAULT_ATTEMPTS
|
|
930
1072
|
const token = this.token()
|
|
@@ -939,9 +1081,13 @@ class FlyApiClient {
|
|
|
939
1081
|
|
|
940
1082
|
let lastError: FlyApiError | undefined
|
|
941
1083
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
1084
|
+
if (options.signal?.aborted) throw options.signal.reason
|
|
942
1085
|
// Pace EVERY network call (including retries) through the process-wide
|
|
943
1086
|
// limiter so a boot's burst of exec calls never trips Fly's account 429.
|
|
944
1087
|
await paceFlyRequest(this.sleep)
|
|
1088
|
+
// Pacing is cancellable too: ownership protection starts only when this
|
|
1089
|
+
// request really enters fetch, never while it is still waiting for a slot.
|
|
1090
|
+
if (options.signal?.aborted) throw options.signal.reason
|
|
945
1091
|
const controller = new AbortController()
|
|
946
1092
|
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? this.timeoutMs)
|
|
947
1093
|
// Initialized to 0 ("no answer received"), which is the value that
|
|
@@ -949,18 +1095,22 @@ class FlyApiClient {
|
|
|
949
1095
|
let status = 0
|
|
950
1096
|
let retryAfter: string | null = null
|
|
951
1097
|
try {
|
|
952
|
-
const
|
|
1098
|
+
const pendingResponse = this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
953
1099
|
method,
|
|
954
1100
|
headers: {
|
|
955
1101
|
Authorization: `Bearer ${token}`,
|
|
956
1102
|
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
957
1103
|
},
|
|
958
1104
|
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
|
959
|
-
signal:
|
|
1105
|
+
signal: options.signal
|
|
1106
|
+
? AbortSignal.any([controller.signal, options.signal])
|
|
1107
|
+
: controller.signal,
|
|
960
1108
|
})
|
|
1109
|
+
const response = await pendingResponse
|
|
961
1110
|
status = response.status
|
|
962
1111
|
retryAfter = response.headers?.get?.('retry-after') ?? null
|
|
963
1112
|
const text = await response.text()
|
|
1113
|
+
if (options.signal?.aborted) throw options.signal.reason
|
|
964
1114
|
|
|
965
1115
|
if (status >= 200 && status < 300) {
|
|
966
1116
|
if (!text.trim()) return null
|
|
@@ -976,6 +1126,7 @@ class FlyApiClient {
|
|
|
976
1126
|
if (options.nullOn?.includes(status)) return null
|
|
977
1127
|
lastError = new FlyApiError(method, path, status, text.slice(0, 500))
|
|
978
1128
|
} catch (error) {
|
|
1129
|
+
if (options.signal?.aborted) throw options.signal.reason
|
|
979
1130
|
if (error instanceof FlyApiError) throw error
|
|
980
1131
|
// Transport-level failure (abort/timeout, DNS, connection reset) — no
|
|
981
1132
|
// status was ever received, so it is modelled as status 0 and retried.
|
|
@@ -990,8 +1141,16 @@ class FlyApiClient {
|
|
|
990
1141
|
clearTimeout(timer)
|
|
991
1142
|
}
|
|
992
1143
|
|
|
993
|
-
const
|
|
994
|
-
|
|
1144
|
+
const extraRetry = Boolean(
|
|
1145
|
+
options.retryStatuses?.includes(status) &&
|
|
1146
|
+
lastError &&
|
|
1147
|
+
(options.retryWhen?.(lastError) ?? true),
|
|
1148
|
+
)
|
|
1149
|
+
const retryable = isRetryableStatus(status) || extraRetry
|
|
1150
|
+
if (retryable && lastError && options.reconcile) {
|
|
1151
|
+
const recovered = await options.reconcile(lastError)
|
|
1152
|
+
if (recovered !== undefined) return recovered
|
|
1153
|
+
}
|
|
995
1154
|
if (!retryable || attempt === attempts) break
|
|
996
1155
|
const delay = retryDelayMs(attempt, retryAfter)
|
|
997
1156
|
logger.warn(`Fly API transient failure on ${method} ${path} — retrying`, {
|
|
@@ -1372,8 +1531,15 @@ its own 6PN: under a policy allowing only `udp:53`, `molecule-pg-tenant.flycast`
|
|
|
1372
1531
|
and `molecule-api.flycast` both RESOLVED and every TCP connect to them was
|
|
1373
1532
|
dropped; re-applying the policy with `tcp:443` opened 443 and nothing else.
|
|
1374
1533
|
Fly's sentence is about INGRESS — a Machine's egress TOWARD a Flycast address
|
|
1534
|
+
is filtered like any other. Drop a declared service's port and every database
|
|
1535
|
+
connection in the fleet goes with it.
|
|
1375
1536
|
|
|
1376
|
-
|
|
1537
|
+
The cost of each derived port, stated plainly because a Fly policy has no
|
|
1538
|
+
destination field of any kind (also measured — see the `egress.ts` module
|
|
1539
|
+
description): the port is opened to EVERY host on the internet, not only to
|
|
1540
|
+
the private service it was derived for. That residual cannot be closed at this
|
|
1541
|
+
layer; it is named in `verifyEgress()`'s verdict and in
|
|
1542
|
+
`docs/sandbox-egress-enforcement.md`.
|
|
1377
1543
|
|
|
1378
1544
|
```typescript
|
|
1379
1545
|
function mergeEgressPorts(
|
|
@@ -1465,24 +1631,30 @@ function parseSandboxId(id: string): { app: string; machineId: string }
|
|
|
1465
1631
|
|
|
1466
1632
|
**Returns:** The app name and Machine id.
|
|
1467
1633
|
|
|
1468
|
-
#### `
|
|
1634
|
+
#### `appNameNamespace(prefix)`
|
|
1635
|
+
|
|
1636
|
+
The one namespace mapping shared by app naming and fleet discovery.
|
|
1469
1637
|
|
|
1470
|
-
|
|
1638
|
+
```typescript
|
|
1639
|
+
function appNameNamespace(prefix: string): {
|
|
1640
|
+
prefix: string
|
|
1641
|
+
hashedPrefix: string
|
|
1642
|
+
searchPrefixes: string[]
|
|
1643
|
+
}
|
|
1644
|
+
```
|
|
1471
1645
|
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
to spare; longer ids are truncated rather than rejected, and the trailing
|
|
1476
|
-
hyphen that truncation can leave is stripped.
|
|
1646
|
+
#### `appNameForProject(prefix, projectId)`
|
|
1647
|
+
|
|
1648
|
+
Derive the collision-free app name for a complete prefix/project identity.
|
|
1477
1649
|
|
|
1478
1650
|
```typescript
|
|
1479
1651
|
function appNameForProject(prefix: string, projectId: string): string
|
|
1480
1652
|
```
|
|
1481
1653
|
|
|
1482
1654
|
- `prefix` — Configured app-name prefix.
|
|
1483
|
-
- `projectId` —
|
|
1655
|
+
- `projectId` — Complete project or deploy identity.
|
|
1484
1656
|
|
|
1485
|
-
**Returns:** A
|
|
1657
|
+
**Returns:** A DNS-safe app name no longer than 63 characters.
|
|
1486
1658
|
|
|
1487
1659
|
#### `mapMachineState(state)`
|
|
1488
1660
|
|
|
@@ -1588,7 +1760,7 @@ function createProvider(
|
|
|
1588
1760
|
config?: FlyioConfig,
|
|
1589
1761
|
client?: FlyApiClient,
|
|
1590
1762
|
store?: ObjectStore | null,
|
|
1591
|
-
): SandboxProvider
|
|
1763
|
+
): SandboxProvider & FlyCreateAttemptProvider
|
|
1592
1764
|
```
|
|
1593
1765
|
|
|
1594
1766
|
- `config` — Optional Fly configuration: API token/URL, org slug, app naming and isolation mode, region, base image, guest sizing, preview service, and the preview URL template. Every field has an env fallback.
|
|
@@ -2252,6 +2424,21 @@ refused, a leading `/` stripped, a write through a symlink refused, and
|
|
|
2252
2424
|
setuid/setgid dropped by `--no-same-permissions` — were verified by running
|
|
2253
2425
|
GNU tar 1.35 against crafted archives rather than inferred from its manual.
|
|
2254
2426
|
|
|
2427
|
+
**Lifecycle ownership.** Create NEVER deletes a Machine, volume, app or
|
|
2428
|
+
address. Configure `createAttemptJournal` to record every intended resource
|
|
2429
|
+
before its Fly write. Workspace ownership is durable immediately: an
|
|
2430
|
+
ambiguous create retains it for a later create or an explicit sweep, never
|
|
2431
|
+
infers deletion from a missing listing or an uncertain dispatch. The
|
|
2432
|
+
reconciler settles only retired attempt generations and skips serving
|
|
2433
|
+
Machines, attached volumes and bound workspaces. The one automatic exception
|
|
2434
|
+
is a durably recorded, definitive Machine-create rejection: under the same
|
|
2435
|
+
lifecycle lock, a fresh listing must show no Machine reference (and no
|
|
2436
|
+
Machines or untracked volumes in a per-project app) before the fenced sweep
|
|
2437
|
+
deletes the failed attempt's volume. The empty app remains KEEP until
|
|
2438
|
+
deliberate retirement through `destroyApp()`. Ambiguous outcomes and
|
|
2439
|
+
referenced resources stay KEPT and are reported. Deployed callers must
|
|
2440
|
+
serialize lifecycle operations per app through their lifecycle-lock adapter.
|
|
2441
|
+
|
|
2255
2442
|
**Absence vs failure: `get()` only answers `null` for a REAL absence.**
|
|
2256
2443
|
`get()` (and each read shaped like it — `list()`, `find()`, `getTemplate()`)
|
|
2257
2444
|
reserves `null`/`[]` for a positive not-found answer — Fly answered 404 for
|
|
@@ -2395,10 +2582,19 @@ deliberately NOT implemented: they receive only a name, while a Fly volume
|
|
|
2395
2582
|
needs an app, a region and a size, and a volume created in the wrong app can
|
|
2396
2583
|
never be mounted. So passing `SandboxConfig.volumeName` provisions the volume
|
|
2397
2584
|
inside the project's app, mounted at `/workspace`; `resources.diskMB` is
|
|
2398
|
-
rounded UP to whole GB.
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2585
|
+
rounded UP to whole GB. Creation sends the Machine's exact guest as Fly's
|
|
2586
|
+
`compute` placement hint, so Fly can choose a volume host that can run that
|
|
2587
|
+
Machine. If Machine creation still answers `412 volume_placement_capacity`,
|
|
2588
|
+
the provider preserves the volume and propagates the error for the caller's
|
|
2589
|
+
bounded retry, which adopts it by its durable Fly ID. Create itself never
|
|
2590
|
+
deletes its app or volume; only explicit retirement or the reference-checked
|
|
2591
|
+
definitive-failure sweep above can do so. Volume creates retain transport and
|
|
2592
|
+
429 backoff. After an ambiguous failure, bounded lookups by name adopt a
|
|
2593
|
+
volume whose response was lost before any POST replay, preventing duplicates.
|
|
2594
|
+
Fly documents that "a volume can be attached to only one Machine", which
|
|
2595
|
+
matches one sandbox per project. Fly does
|
|
2596
|
+
not document the legal character set for volume names, so names are
|
|
2597
|
+
conservatively reduced to `[A-Za-z0-9_]` — an assumption, flagged as such.
|
|
2402
2598
|
|
|
2403
2599
|
**Warm start: templates are tar archives in object storage.** Fly cannot
|
|
2404
2600
|
`commit` a running Machine — it PULLS images from a registry — and the
|