@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.
- package/LICENSE +115 -0
- package/README.md +2189 -0
- package/dist/api.d.ts +140 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +226 -0
- package/dist/browser-guard.d.ts +2 -0
- package/dist/browser-guard.d.ts.map +1 -0
- package/dist/browser-guard.js +18 -0
- package/dist/egress.d.ts +180 -0
- package/dist/egress.d.ts.map +1 -0
- package/dist/egress.js +283 -0
- package/dist/exec.d.ts +85 -0
- package/dist/exec.d.ts.map +1 -0
- package/dist/exec.js +215 -0
- package/dist/flycast.d.ts +173 -0
- package/dist/flycast.d.ts.map +1 -0
- package/dist/flycast.js +287 -0
- package/dist/ids.d.ts +79 -0
- package/dist/ids.d.ts.map +1 -0
- package/dist/ids.js +121 -0
- package/dist/index.d.ts +346 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +359 -0
- package/dist/provider.d.ts +24 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +1425 -0
- package/dist/storage.d.ts +119 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +241 -0
- package/dist/templates.d.ts +310 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +655 -0
- package/dist/types.d.ts +519 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +11 -0
- package/dist/utilities.d.ts +29 -0
- package/dist/utilities.d.ts.map +1 -0
- package/dist/utilities.js +44 -0
- package/package.json +63 -0
package/dist/provider.js
ADDED
|
@@ -0,0 +1,1425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fly.io Machines implementation of `SandboxProvider`.
|
|
3
|
+
*
|
|
4
|
+
* Each sandbox is one Fly Machine — a Firecracker microVM — inside a Fly app,
|
|
5
|
+
* managed over the Machines API at `https://api.machines.dev/v1`.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import { getLogger } from '@molecule/api-bond';
|
|
10
|
+
import { t } from '@molecule/api-i18n';
|
|
11
|
+
import { FlyApiClient, FlyApiError, normalizeApiUrl } from './api.js';
|
|
12
|
+
import { buildEgressProbeCommand, DEFAULT_EGRESS_POLICY_NAME, DEFAULT_EGRESS_PROBE_TARGETS, extractPolicyId, parseEgressAllowedPorts, parseEgressProbeTargets, verdictForProbeExit, } from './egress.js';
|
|
13
|
+
import { execCommand, toExecResult } from './exec.js';
|
|
14
|
+
import { assertPrivateRoutesForEnv, decodePrivateRoutes, DEFAULT_PRIVATE_IP_TYPE, encodePrivateRoutes, flycastHost, mergeEgressPorts, parsePrivateServices, PRIVATE_ROUTES_METADATA_SUFFIX, } from './flycast.js';
|
|
15
|
+
import { appNameForProject, mapMachineState, parseSandboxId, toSandboxId } from './ids.js';
|
|
16
|
+
import { createTemplateStore, MAX_PRESIGN_EXPIRY_SECONDS } from './storage.js';
|
|
17
|
+
import * as templates from './templates.js';
|
|
18
|
+
import { shellQuote } from './utilities.js';
|
|
19
|
+
const logger = getLogger();
|
|
20
|
+
/**
|
|
21
|
+
* Default sandbox image. Fly pulls images itself, so this MUST be a reference
|
|
22
|
+
* Fly can resolve — a tag in the org's `registry.fly.io` repository or a public
|
|
23
|
+
* registry. A local `molecule-sandbox:latest` is invisible to Fly.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_IMAGE = 'registry.fly.io/molecule-sandbox:latest';
|
|
26
|
+
/** Default Fly region for Machines and volumes. */
|
|
27
|
+
const DEFAULT_REGION = 'iad';
|
|
28
|
+
/** Default per-project Fly app name prefix. */
|
|
29
|
+
const DEFAULT_APP_PREFIX = 'mol-sandbox';
|
|
30
|
+
/** Default metadata key prefix identifying Machines this provider manages. */
|
|
31
|
+
const DEFAULT_METADATA_PREFIX = 'molecule-sandbox';
|
|
32
|
+
/** Default internal port the preview service forwards to (the Vite dev server). */
|
|
33
|
+
const DEFAULT_PREVIEW_PORT = 5173;
|
|
34
|
+
/** Working directory every exec and file operation is relative to. */
|
|
35
|
+
const WORKSPACE_PATH = '/workspace';
|
|
36
|
+
/** Default volume size in GB when `SandboxConfig.resources.diskMB` is unset. */
|
|
37
|
+
const DEFAULT_VOLUME_GB = 10;
|
|
38
|
+
/** Page size for the org-wide Machine listing. */
|
|
39
|
+
const LIST_PAGE_SIZE = 200;
|
|
40
|
+
/** Hard cap on pages fetched while listing, so a bad cursor cannot loop forever. */
|
|
41
|
+
const LIST_MAX_PAGES = 50;
|
|
42
|
+
/**
|
|
43
|
+
* Chunk size for base64-encoded `writeFile` payloads. `sh -c` receives the whole
|
|
44
|
+
* command as ONE argument, which Linux caps at MAX_ARG_STRLEN (128 KB), so a
|
|
45
|
+
* file over ~45 KB has to be written across several appends.
|
|
46
|
+
*/
|
|
47
|
+
const WRITE_CHUNK_BASE64 = 60_000;
|
|
48
|
+
/**
|
|
49
|
+
* Budget for the file-operation execs (`cat`, `ls`, `rm`, chunked writes), in ms.
|
|
50
|
+
*
|
|
51
|
+
* This MUST stay inside `DIRECT_EXEC_BUDGET_SECONDS` so file operations take the
|
|
52
|
+
* single-call exec path. Leaving it at the default budget sent every file read
|
|
53
|
+
* through the detach-and-poll path instead — three extra round-trips and a
|
|
54
|
+
* minimum two-second poll wait for a `cat`, and a chunked write large enough to
|
|
55
|
+
* be re-encoded past the launcher's argument limit.
|
|
56
|
+
*/
|
|
57
|
+
const FILE_OP_TIMEOUT_MS = 30_000;
|
|
58
|
+
/**
|
|
59
|
+
* Fly's own ceiling on `GET /v1/apps/{app}/machines/{id}/wait`: "This request
|
|
60
|
+
* will block for up to 60 seconds."
|
|
61
|
+
*/
|
|
62
|
+
const MAX_WAIT_SECONDS = 60;
|
|
63
|
+
/** Guest size for the throwaway `verifyEgress()` probe Machine. */
|
|
64
|
+
const PROBE_MEMORY_MB = 256;
|
|
65
|
+
/**
|
|
66
|
+
* How long the probe Machine's init command sleeps. It is destroyed explicitly
|
|
67
|
+
* once the probe has run; this plus `auto_destroy` is the backstop that reaps it
|
|
68
|
+
* if this process dies mid-check.
|
|
69
|
+
*/
|
|
70
|
+
const PROBE_LIFETIME_SECONDS = 300;
|
|
71
|
+
/** Default per-connection timeout for the egress probe, in ms. */
|
|
72
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 3000;
|
|
73
|
+
/** Extra seconds allowed for the probe exec beyond its connection budget. */
|
|
74
|
+
const PROBE_EXEC_HEADROOM_SECONDS = 10;
|
|
75
|
+
/**
|
|
76
|
+
* Fly.io Machines implementation of `SandboxProvider`.
|
|
77
|
+
*
|
|
78
|
+
* Lifecycle mapping (every endpoint verified against
|
|
79
|
+
* https://docs.machines.dev/openapi.json):
|
|
80
|
+
*
|
|
81
|
+
* | Interface | Fly Machines API |
|
|
82
|
+
* |---|---|
|
|
83
|
+
* | `create` | `POST /v1/apps/{app}/machines` (after ensuring the app exists) |
|
|
84
|
+
* | `get` | `GET /v1/apps/{app}/machines/{id}` |
|
|
85
|
+
* | `list` | `GET /v1/orgs/{org}/machines`, or `GET /v1/apps/{app}/machines` |
|
|
86
|
+
* | `destroy` | `DELETE /v1/apps/{app}/machines/{id}?force=true` |
|
|
87
|
+
* | `start` / `wake` | `POST /v1/apps/{app}/machines/{id}/start` |
|
|
88
|
+
* | `stop` | `POST /v1/apps/{app}/machines/{id}/stop` |
|
|
89
|
+
* | `sleep` | `POST /v1/apps/{app}/machines/{id}/suspend` |
|
|
90
|
+
* | `exec` and all file ops | `POST /v1/apps/{app}/machines/{id}/exec` |
|
|
91
|
+
*/
|
|
92
|
+
class FlyioSandboxProvider {
|
|
93
|
+
name = 'flyio';
|
|
94
|
+
config;
|
|
95
|
+
client;
|
|
96
|
+
metadataPrefix;
|
|
97
|
+
/** Emit the `onFileChange` unsupported warning once per provider, not once per sandbox. */
|
|
98
|
+
warnedNoFileWatch = false;
|
|
99
|
+
/**
|
|
100
|
+
* Memoized template store. `undefined` means "not resolved yet", `null` means
|
|
101
|
+
* "resolved, and not configured" — resolution is deferred to first use so a
|
|
102
|
+
* secrets bond can populate the environment after this singleton is built,
|
|
103
|
+
* exactly like the API token.
|
|
104
|
+
*/
|
|
105
|
+
templateStoreCache;
|
|
106
|
+
/**
|
|
107
|
+
* Creates the provider.
|
|
108
|
+
* @param config - Fly-specific configuration; every field also has an env fallback.
|
|
109
|
+
* @param client - Injectable API client, for tests. Built from `config` when omitted.
|
|
110
|
+
* @param store - Injectable template object store, for tests. Resolved from
|
|
111
|
+
* `config` (and the environment) on first use when omitted; pass `null` to
|
|
112
|
+
* model an operator who configured no store at all.
|
|
113
|
+
*/
|
|
114
|
+
constructor(config = {}, client, store) {
|
|
115
|
+
this.config = config;
|
|
116
|
+
this.metadataPrefix = config.metadataPrefix ?? DEFAULT_METADATA_PREFIX;
|
|
117
|
+
if (store !== undefined)
|
|
118
|
+
this.templateStoreCache = store;
|
|
119
|
+
this.client =
|
|
120
|
+
client ??
|
|
121
|
+
new FlyApiClient({
|
|
122
|
+
// Resolved lazily on each request so a secrets bond can populate the
|
|
123
|
+
// environment after the provider singleton is constructed.
|
|
124
|
+
token: () => this.config.apiToken ?? process.env.FLY_API_TOKEN ?? process.env.FLY_ACCESS_TOKEN,
|
|
125
|
+
baseUrl: normalizeApiUrl(config.apiUrl ?? process.env.FLY_API_HOSTNAME),
|
|
126
|
+
timeoutMs: config.requestTimeoutMs,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Configuration resolution
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
/**
|
|
133
|
+
* Resolves the Fly organization slug that owns sandbox apps.
|
|
134
|
+
* @returns The org slug.
|
|
135
|
+
*/
|
|
136
|
+
orgSlug() {
|
|
137
|
+
return this.config.orgSlug ?? process.env.FLY_ORG_SLUG ?? 'personal';
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Resolves the Fly region for Machines and volumes.
|
|
141
|
+
* @returns The region code.
|
|
142
|
+
*/
|
|
143
|
+
region() {
|
|
144
|
+
return this.config.region ?? process.env.FLY_REGION ?? DEFAULT_REGION;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Whether each project gets its own Fly app on its own custom 6PN network.
|
|
148
|
+
* @returns `true` unless explicitly disabled.
|
|
149
|
+
*/
|
|
150
|
+
appPerProject() {
|
|
151
|
+
if (this.config.appPerProject !== undefined)
|
|
152
|
+
return this.config.appPerProject;
|
|
153
|
+
return process.env.FLY_SANDBOX_APP_PER_PROJECT !== 'false';
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Resolves the Fly app a project's Machine belongs to.
|
|
157
|
+
*
|
|
158
|
+
* In the default per-project mode this is `<prefix>-<projectId>`, which is
|
|
159
|
+
* also the app's custom 6PN network name — so one tenant's sandbox cannot
|
|
160
|
+
* reach another's over private networking. The shared-app mode puts every
|
|
161
|
+
* tenant on ONE 6PN, which is the same cross-tenant exposure the Docker
|
|
162
|
+
* bond's shared `bridge` network has, and is refused in production.
|
|
163
|
+
* @param projectId - The project id from `SandboxConfig`.
|
|
164
|
+
* @returns The Fly app name.
|
|
165
|
+
* @throws {Error} When shared-app mode is selected in production, or with no app name.
|
|
166
|
+
*/
|
|
167
|
+
resolveApp(projectId) {
|
|
168
|
+
if (this.appPerProject()) {
|
|
169
|
+
return appNameForProject(this.config.appPrefix ?? process.env.FLY_SANDBOX_APP_PREFIX ?? DEFAULT_APP_PREFIX, projectId);
|
|
170
|
+
}
|
|
171
|
+
const shared = this.config.appName ?? process.env.FLY_SANDBOX_APP;
|
|
172
|
+
if (process.env.NODE_ENV === 'production') {
|
|
173
|
+
throw new Error('Shared-app mode is forbidden in production (via FLY_SANDBOX_APP_PER_PROJECT=false or ' +
|
|
174
|
+
'config.appPerProject=false): every Machine in one Fly app shares a 6PN private ' +
|
|
175
|
+
"network, so one tenant's sandbox can reach another tenant's dev-server ports by " +
|
|
176
|
+
'private IPv6. Use the default per-project apps, each on its own custom 6PN.');
|
|
177
|
+
}
|
|
178
|
+
if (!shared) {
|
|
179
|
+
throw new Error(t('codeSandbox.flyio.error.noAppName', undefined, {
|
|
180
|
+
defaultValue: 'Shared-app mode needs an app name — set FLY_SANDBOX_APP or config.appName, ' +
|
|
181
|
+
'or leave config.appPerProject at its default (true).',
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
logger.warn('Fly sandboxes are running in shared-app mode — every tenant shares one 6PN private ' +
|
|
185
|
+
'network and can reach every other tenant sandbox by private IPv6. Use per-project apps.', { app: shared });
|
|
186
|
+
return shared;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Builds the Fly Proxy service exposing a sandbox's preview port, or `undefined`
|
|
190
|
+
* when public exposure is disabled.
|
|
191
|
+
*
|
|
192
|
+
* `autostop: 'suspend'` is the scale-to-zero behaviour this bond exists for:
|
|
193
|
+
* Fly Proxy suspends the Machine when it goes idle and resumes it from its
|
|
194
|
+
* memory snapshot on the next request, so an idle sandbox bills only for its
|
|
195
|
+
* rootfs and volume.
|
|
196
|
+
* @returns The service definition, or `undefined`.
|
|
197
|
+
*/
|
|
198
|
+
previewService() {
|
|
199
|
+
if (this.config.publicService === false)
|
|
200
|
+
return undefined;
|
|
201
|
+
return {
|
|
202
|
+
protocol: 'tcp',
|
|
203
|
+
internal_port: this.config.previewPort ?? DEFAULT_PREVIEW_PORT,
|
|
204
|
+
ports: [
|
|
205
|
+
{ port: 80, handlers: ['http'], force_https: true },
|
|
206
|
+
{ port: 443, handlers: ['tls', 'http'] },
|
|
207
|
+
],
|
|
208
|
+
autostart: true,
|
|
209
|
+
autostop: this.config.autostop ?? 'suspend',
|
|
210
|
+
min_machines_running: 0,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// App + volume provisioning
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
/**
|
|
217
|
+
* Resolves the custom 6PN network a project's app lives on, or `undefined` in
|
|
218
|
+
* shared-app mode (where the app is on the org's default network).
|
|
219
|
+
* @param app - The Fly app name.
|
|
220
|
+
* @returns The network name, or `undefined`.
|
|
221
|
+
*/
|
|
222
|
+
networkFor(app) {
|
|
223
|
+
if (!this.appPerProject())
|
|
224
|
+
return undefined;
|
|
225
|
+
return this.config.network ?? app;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Resolves the apps every sandbox must reach across its per-project 6PN.
|
|
229
|
+
*
|
|
230
|
+
* Empty in shared-app mode: that app sits on the org's default network, where
|
|
231
|
+
* `<app>.internal` already resolves and Flycast would add nothing.
|
|
232
|
+
* @returns The declared services, or `undefined` when none are configured.
|
|
233
|
+
*/
|
|
234
|
+
privateServices() {
|
|
235
|
+
if (!this.appPerProject())
|
|
236
|
+
return undefined;
|
|
237
|
+
return (this.config.privateServices ?? parsePrivateServices(process.env.FLY_SANDBOX_PRIVATE_SERVICES));
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Idempotently ensures the Fly app exists, creating it on its own custom 6PN
|
|
241
|
+
* network in per-project mode, applies the egress network policy, and
|
|
242
|
+
* allocates the Flycast routes that let the sandbox reach the tenant database
|
|
243
|
+
* and the egress proxy.
|
|
244
|
+
*
|
|
245
|
+
* A Fly app's network CANNOT be changed after creation, so an app that already
|
|
246
|
+
* exists is used as-is — this never silently "fixes" an app that was created
|
|
247
|
+
* on the shared org network. The egress policy IS re-applied to an existing
|
|
248
|
+
* app, because Fly applies a policy at Machine boot ("restart or redeploy the
|
|
249
|
+
* Machines for changes to take effect") and this runs before the Machine is
|
|
250
|
+
* created.
|
|
251
|
+
* @param app - The Fly app name.
|
|
252
|
+
* @param options - `privateRoutes: false` skips the Flycast allocation, which
|
|
253
|
+
* is what the throwaway `verifyEgress()` app wants: it never connects to
|
|
254
|
+
* anything but the probe targets, and allocating for an app that is deleted
|
|
255
|
+
* seconds later would leak one address per probe on every target app.
|
|
256
|
+
* @returns Target app name → allocated private address, for recording in the
|
|
257
|
+
* Machine's metadata. Empty when no private services are declared.
|
|
258
|
+
*/
|
|
259
|
+
async ensureApp(app, options = {}) {
|
|
260
|
+
const existing = await this.client.request(`/apps/${app}`, { nullOn: [404] });
|
|
261
|
+
if (!existing) {
|
|
262
|
+
const body = { name: app, org_slug: this.orgSlug() };
|
|
263
|
+
const network = this.networkFor(app);
|
|
264
|
+
if (network)
|
|
265
|
+
body.network = network;
|
|
266
|
+
let created = true;
|
|
267
|
+
try {
|
|
268
|
+
await this.client.request(`/apps`, { method: 'POST', body });
|
|
269
|
+
logger.info('Created Fly sandbox app', { app, network: body.network });
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
// 409/422 means another concurrent boot created the same app first, which
|
|
273
|
+
// is exactly the outcome we wanted. Anything else is a real failure.
|
|
274
|
+
if (error instanceof FlyApiError && (error.status === 409 || error.status === 422)) {
|
|
275
|
+
logger.debug('Fly sandbox app already exists', { app });
|
|
276
|
+
created = false;
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (created && this.config.assignSharedIpv4)
|
|
283
|
+
await this.assignSharedIpv4(app);
|
|
284
|
+
}
|
|
285
|
+
await this.applyEgressPolicy(app);
|
|
286
|
+
if (options.privateRoutes === false)
|
|
287
|
+
return {};
|
|
288
|
+
return this.ensurePrivateRoutes(app, Boolean(existing));
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Allocates a Flycast private address on every declared service app, INTO this
|
|
292
|
+
* project's 6PN, so `<service>.flycast` resolves from inside the sandbox.
|
|
293
|
+
*
|
|
294
|
+
* A failure here is FATAL, for the same reason the egress policy's is: a
|
|
295
|
+
* sandbox whose route to its own database was never created boots perfectly,
|
|
296
|
+
* reports healthy, and then fails to connect somewhere deep inside the user's
|
|
297
|
+
* own application — which is indistinguishable from the user's code being
|
|
298
|
+
* wrong. Failing the boot with the app name and the remedy is the only honest
|
|
299
|
+
* outcome.
|
|
300
|
+
*
|
|
301
|
+
* Self-healing rather than create-once: an app that already exists is checked
|
|
302
|
+
* against the routes recorded on its Machines and any MISSING service is
|
|
303
|
+
* allocated. Fly's own `IPAssignment` carries no network, so a listing on the
|
|
304
|
+
* target app cannot answer "does this project already have a route" — the
|
|
305
|
+
* Machine metadata this provider wrote is the only record.
|
|
306
|
+
* @param app - The Fly app name (which is also its 6PN network name).
|
|
307
|
+
* @param appExisted - Whether the app was already there, in which case its
|
|
308
|
+
* Machines may carry addresses allocated by an earlier boot.
|
|
309
|
+
* @returns Target app name → private address.
|
|
310
|
+
*/
|
|
311
|
+
async ensurePrivateRoutes(app, appExisted) {
|
|
312
|
+
const services = this.privateServices();
|
|
313
|
+
const network = this.networkFor(app);
|
|
314
|
+
if (!services?.length || !network)
|
|
315
|
+
return {};
|
|
316
|
+
const routes = appExisted ? await this.recordedPrivateRoutes(app) : {};
|
|
317
|
+
for (const service of services) {
|
|
318
|
+
if (routes[service.app])
|
|
319
|
+
continue;
|
|
320
|
+
const ip = await this.allocatePrivateAddress(service, network);
|
|
321
|
+
if (ip)
|
|
322
|
+
routes[service.app] = ip;
|
|
323
|
+
}
|
|
324
|
+
return routes;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Reads the private addresses an earlier boot recorded on this app's Machines.
|
|
328
|
+
* @param app - The Fly app name.
|
|
329
|
+
* @returns Target app name → private address; empty when nothing is recorded.
|
|
330
|
+
*/
|
|
331
|
+
async recordedPrivateRoutes(app) {
|
|
332
|
+
const key = `${this.metadataPrefix}.${PRIVATE_ROUTES_METADATA_SUFFIX}`;
|
|
333
|
+
try {
|
|
334
|
+
const machines = await this.client.request(`/apps/${app}/machines`, {
|
|
335
|
+
nullOn: [404],
|
|
336
|
+
});
|
|
337
|
+
for (const machine of machines ?? []) {
|
|
338
|
+
const recorded = machine.config?.metadata?.[key];
|
|
339
|
+
if (recorded)
|
|
340
|
+
return decodePrivateRoutes(recorded);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
// Only the reuse optimization is lost. Falling through allocates the
|
|
345
|
+
// routes again, which costs an address rather than a broken sandbox —
|
|
346
|
+
// and the alternative (treating an unreadable listing as "routes exist")
|
|
347
|
+
// is exactly the "I could not look" ⇒ "it is fine" conflation this
|
|
348
|
+
// provider refuses everywhere else.
|
|
349
|
+
logger.warn('Could not read the Fly sandbox app’s Machines for recorded private routes — ' +
|
|
350
|
+
'reallocating, which may leave an unreferenced address on the target apps', { app, error });
|
|
351
|
+
}
|
|
352
|
+
return {};
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Allocates ONE Flycast private address on a target app, for this project's
|
|
356
|
+
* network.
|
|
357
|
+
*
|
|
358
|
+
* `POST /v1/apps/{app}/ip_assignments` with `{type, network, org_slug}` — the
|
|
359
|
+
* REST equivalent of `fly ips allocate-v6 --private --network <network>`. The
|
|
360
|
+
* request schema (`assignIPRequest`) is in Fly's OpenAPI specification; the
|
|
361
|
+
* `type` value is not enumerated there, so it is configurable.
|
|
362
|
+
* @param service - The declared target app and the port sandboxes dial it at.
|
|
363
|
+
* @param network - The project's 6PN, which requests will originate from.
|
|
364
|
+
* @returns The allocated address, or `undefined` when Fly reports the address
|
|
365
|
+
* already exists and cannot be re-read.
|
|
366
|
+
* @throws {Error} When the allocation fails, or succeeds without returning an
|
|
367
|
+
* address — an allocation this provider can neither verify nor release later
|
|
368
|
+
* is untracked state, and the boot is the last moment it can be reported.
|
|
369
|
+
*/
|
|
370
|
+
async allocatePrivateAddress(service, network) {
|
|
371
|
+
let assignment;
|
|
372
|
+
try {
|
|
373
|
+
assignment = await this.client.request(`/apps/${service.app}/ip_assignments`, {
|
|
374
|
+
method: 'POST',
|
|
375
|
+
body: {
|
|
376
|
+
type: this.config.privateIpAssignmentType ?? DEFAULT_PRIVATE_IP_TYPE,
|
|
377
|
+
network,
|
|
378
|
+
org_slug: this.orgSlug(),
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
// A conflict means an address for this (app, network) pair is already
|
|
384
|
+
// there — from a boot that crashed between allocating and recording. The
|
|
385
|
+
// ROUTE is what the sandbox needs and it exists, so this is not a boot
|
|
386
|
+
// failure; it is an address this provider can no longer release, because
|
|
387
|
+
// Fly's listing does not report which network an address belongs to.
|
|
388
|
+
if (error instanceof FlyApiError && (error.status === 409 || error.status === 422)) {
|
|
389
|
+
logger.warn('A Fly private address for this project’s network already exists on the target app — ' +
|
|
390
|
+
'the route works, but this one cannot be released on destroy. Reconcile with ' +
|
|
391
|
+
'`fly ips list --app <target>`.', { target: service.app, network, status: error.status });
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
throw new Error(t('codeSandbox.flyio.error.privateRouteFailed', { app: service.app, network }, {
|
|
395
|
+
defaultValue: `Could not allocate a Flycast private address on Fly app "${service.app}" for ` +
|
|
396
|
+
`network "${network}", so a sandbox in that network cannot reach ` +
|
|
397
|
+
`"${flycastHost(service.app)}:${service.port}". Check that the API token may ` +
|
|
398
|
+
`allocate IPs on "${service.app}", that the app exists in organization ` +
|
|
399
|
+
`"${this.orgSlug()}", and that it declares an [http_service] or [services] section ` +
|
|
400
|
+
'— Flycast routes through Fly Proxy and does nothing without one.',
|
|
401
|
+
}), { cause: error });
|
|
402
|
+
}
|
|
403
|
+
if (!assignment?.ip) {
|
|
404
|
+
throw new Error(t('codeSandbox.flyio.error.privateRouteNoAddress', { app: service.app, network }, {
|
|
405
|
+
defaultValue: `Fly allocated a private address on app "${service.app}" for network "${network}" ` +
|
|
406
|
+
'but returned no address, so it can never be released. Check ' +
|
|
407
|
+
`\`fly ips list --app ${service.app}\` and remove the orphan.`,
|
|
408
|
+
}));
|
|
409
|
+
}
|
|
410
|
+
logger.info('Allocated a Fly private (Flycast) address for a sandbox network', {
|
|
411
|
+
target: service.app,
|
|
412
|
+
network,
|
|
413
|
+
host: flycastHost(service.app),
|
|
414
|
+
port: service.port,
|
|
415
|
+
});
|
|
416
|
+
return assignment.ip;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Releases the Flycast addresses a project's sandbox recorded.
|
|
420
|
+
*
|
|
421
|
+
* Best-effort: the sandbox is being destroyed either way, and an address left
|
|
422
|
+
* behind is a reconciliation item (`fly ips list --app <target>`) rather than
|
|
423
|
+
* a failed teardown. It routes into a 6PN that no longer has any app in it.
|
|
424
|
+
* @param routes - Target app name → private address, from Machine metadata.
|
|
425
|
+
*/
|
|
426
|
+
async releasePrivateRoutes(routes) {
|
|
427
|
+
for (const [target, ip] of Object.entries(routes)) {
|
|
428
|
+
try {
|
|
429
|
+
await this.client.request(`/apps/${target}/ip_assignments/${encodeURIComponent(ip)}`, {
|
|
430
|
+
method: 'DELETE',
|
|
431
|
+
nullOn: [404],
|
|
432
|
+
});
|
|
433
|
+
logger.debug('Released a Fly private (Flycast) address', { target, ip });
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
logger.warn('Failed to release a Fly private (Flycast) address — it will keep pointing at a 6PN ' +
|
|
437
|
+
'with no apps in it until it is reconciled with `fly ips list`', { target, ip, error });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Resolves the ports the egress network policy allows, or `undefined` when no
|
|
443
|
+
* policy should be applied.
|
|
444
|
+
*
|
|
445
|
+
* The operator's list is UNIONED with the port of every declared private
|
|
446
|
+
* service, because those two are one decision wearing two hats: a policy is
|
|
447
|
+
* deny-by-default once any rule exists, so declaring
|
|
448
|
+
* `molecule-pg-tenant:5432` and leaving the policy at `tcp:3128` would drop
|
|
449
|
+
* every database connection in the fleet with no diagnostic but a timeout.
|
|
450
|
+
* Deriving it is what lets the operator documentation keep saying "never widen
|
|
451
|
+
* this list" — see {@link mergeEgressPorts}.
|
|
452
|
+
* @returns The allowed ports, or `undefined`.
|
|
453
|
+
* @throws {Error} When configured with an EMPTY list. Fly documents the deny
|
|
454
|
+
* default as a consequence of an `allow` rule existing and says nothing
|
|
455
|
+
* about a rule with no ports, so accepting `[]` would ship a security
|
|
456
|
+
* control whose behaviour is a guess.
|
|
457
|
+
*/
|
|
458
|
+
egressAllowedPorts() {
|
|
459
|
+
return mergeEgressPorts(this.configuredEgressPorts(), this.privateServices());
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Resolves ONLY the ports the operator configured, before the declared private
|
|
463
|
+
* services are merged in.
|
|
464
|
+
* @returns The configured ports, or `undefined` when no policy should be applied.
|
|
465
|
+
* @throws {Error} When configured with an EMPTY list.
|
|
466
|
+
*/
|
|
467
|
+
configuredEgressPorts() {
|
|
468
|
+
const configured = this.config.egressAllowedPorts;
|
|
469
|
+
if (configured) {
|
|
470
|
+
if (configured.length === 0) {
|
|
471
|
+
throw new Error(t('codeSandbox.flyio.error.emptyEgressPorts', undefined, {
|
|
472
|
+
defaultValue: 'config.egressAllowedPorts is empty. Fly derives its deny-all default from an ' +
|
|
473
|
+
'existing allow rule and does not document a rule with no ports, so this provider ' +
|
|
474
|
+
'refuses to send one. Omit the option for no policy, or allow at least one port.',
|
|
475
|
+
}));
|
|
476
|
+
}
|
|
477
|
+
return configured;
|
|
478
|
+
}
|
|
479
|
+
return parseEgressAllowedPorts(process.env.FLY_SANDBOX_EGRESS_ALLOWED_PORTS);
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Applies this provider's egress network policy to an app, updating the
|
|
483
|
+
* existing policy in place when one is already there.
|
|
484
|
+
*
|
|
485
|
+
* A failure here is FATAL to the caller, deliberately. The operator asked for
|
|
486
|
+
* an egress restriction; booting a sandbox for untrusted code without it — and
|
|
487
|
+
* logging a warning nobody reads — is the trade this whole capability exists to
|
|
488
|
+
* refuse.
|
|
489
|
+
* @param app - The Fly app name.
|
|
490
|
+
* @returns Nothing; resolves once the policy is in place (or none was configured).
|
|
491
|
+
*/
|
|
492
|
+
async applyEgressPolicy(app) {
|
|
493
|
+
const ports = this.egressAllowedPorts();
|
|
494
|
+
if (!ports)
|
|
495
|
+
return;
|
|
496
|
+
const name = this.config.egressPolicyName ?? DEFAULT_EGRESS_POLICY_NAME;
|
|
497
|
+
let id;
|
|
498
|
+
try {
|
|
499
|
+
// NO TRAILING SLASH. Verified against a real Fly org (2026-08-05):
|
|
500
|
+
// GET /apps/{app}/network_policies -> 200 []
|
|
501
|
+
// GET /apps/{app}/network_policies/ -> 404 "404 page not found"
|
|
502
|
+
// With the slash this always 404'd, and `nullOn: [404]` turned that into a
|
|
503
|
+
// silent downgrade — every apply became a create, stacking a duplicate
|
|
504
|
+
// policy per Machine instead of updating the one this provider owns.
|
|
505
|
+
const listed = await this.client.request(`/apps/${app}/network_policies`, {
|
|
506
|
+
nullOn: [404],
|
|
507
|
+
});
|
|
508
|
+
id = extractPolicyId(listed, name);
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
// Only the UPDATE-in-place optimization is lost: the POST below still
|
|
512
|
+
// applies the policy, it just risks stacking a duplicate with identical
|
|
513
|
+
// allow rules. Enforcement is what matters, so this does not fail the boot.
|
|
514
|
+
logger.warn('Could not list Fly network policies — applying the egress policy as a create', {
|
|
515
|
+
app,
|
|
516
|
+
error,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
const policy = {
|
|
520
|
+
...(id ? { id } : {}),
|
|
521
|
+
name,
|
|
522
|
+
selector: { all: true },
|
|
523
|
+
rules: [{ action: 'allow', direction: 'egress', ports }],
|
|
524
|
+
};
|
|
525
|
+
await this.client.request(`/apps/${app}/network_policies`, { method: 'POST', body: policy });
|
|
526
|
+
logger.info('Applied Fly egress network policy', { app, name, ports, updated: Boolean(id) });
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Requests a shared Anycast IPv4 for the app so `<app>.fly.dev` serves traffic.
|
|
530
|
+
*
|
|
531
|
+
* Non-fatal: a sandbox is still fully usable over exec and private networking
|
|
532
|
+
* without a public address, so an allocation failure degrades the preview URL
|
|
533
|
+
* rather than failing the boot.
|
|
534
|
+
* @param app - The Fly app name.
|
|
535
|
+
*/
|
|
536
|
+
async assignSharedIpv4(app) {
|
|
537
|
+
try {
|
|
538
|
+
await this.client.request(`/apps/${app}/ip_assignments`, {
|
|
539
|
+
method: 'POST',
|
|
540
|
+
body: {
|
|
541
|
+
type: this.config.ipAssignmentType ?? 'shared_v4',
|
|
542
|
+
org_slug: this.orgSlug(),
|
|
543
|
+
region: this.region(),
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
logger.warn('Failed to assign a shared IPv4 to the Fly sandbox app — the public preview URL will ' +
|
|
549
|
+
'not resolve until an address is allocated', { app, error });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Sanitizes a caller-supplied volume name into a conservative form.
|
|
554
|
+
*
|
|
555
|
+
* Fly does NOT document the character rules for volume names, so this reduces
|
|
556
|
+
* to `[A-Za-z0-9_]`, which is the form every Fly example uses. Callers pass
|
|
557
|
+
* names like `mol-<uuid>`; the hyphens become underscores.
|
|
558
|
+
* @param name - The caller's volume name.
|
|
559
|
+
* @returns A sanitized Fly volume name.
|
|
560
|
+
*/
|
|
561
|
+
volumeName(name) {
|
|
562
|
+
return name.replace(/[^A-Za-z0-9_]/g, '_');
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Idempotently ensures a volume exists in the app and returns its id.
|
|
566
|
+
*
|
|
567
|
+
* A Fly volume is app-scoped and attaches to exactly ONE Machine, which is why
|
|
568
|
+
* volumes are provisioned here — inside `create()`, where the app, region and
|
|
569
|
+
* size are all known — rather than through the core's optional
|
|
570
|
+
* `createVolume(name)`; see the module `@remarks`.
|
|
571
|
+
* @param app - The Fly app name.
|
|
572
|
+
* @param name - The caller's volume name.
|
|
573
|
+
* @param sizeGB - Size to create the volume with, in whole GB.
|
|
574
|
+
* @returns The Fly volume id.
|
|
575
|
+
*/
|
|
576
|
+
async ensureVolume(app, name, sizeGB) {
|
|
577
|
+
const flyName = this.volumeName(name);
|
|
578
|
+
const existing = await this.findVolume(app, flyName);
|
|
579
|
+
if (existing)
|
|
580
|
+
return existing.id;
|
|
581
|
+
const created = await this.client.request(`/apps/${app}/volumes`, {
|
|
582
|
+
method: 'POST',
|
|
583
|
+
body: {
|
|
584
|
+
name: flyName,
|
|
585
|
+
region: this.region(),
|
|
586
|
+
size_gb: sizeGB,
|
|
587
|
+
encrypted: true,
|
|
588
|
+
},
|
|
589
|
+
});
|
|
590
|
+
if (!created?.id) {
|
|
591
|
+
throw new Error(t('codeSandbox.flyio.error.volumeCreateFailed', { name: flyName }, { defaultValue: `Fly volume "${flyName}" was created but the API returned no id.` }));
|
|
592
|
+
}
|
|
593
|
+
return created.id;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Finds a volume in an app by name, ignoring ones already being destroyed.
|
|
597
|
+
* @param app - The Fly app name.
|
|
598
|
+
* @param flyName - The sanitized Fly volume name.
|
|
599
|
+
* @returns The matching volume, or `null`.
|
|
600
|
+
*/
|
|
601
|
+
async findVolume(app, flyName) {
|
|
602
|
+
const volumes = await this.client.request(`/apps/${app}/volumes`, {
|
|
603
|
+
nullOn: [404],
|
|
604
|
+
});
|
|
605
|
+
if (!Array.isArray(volumes))
|
|
606
|
+
return null;
|
|
607
|
+
return (volumes.find((volume) => volume.name === flyName && volume.state !== 'destroyed' && volume.state !== 'destroying') ?? null);
|
|
608
|
+
}
|
|
609
|
+
// ---------------------------------------------------------------------------
|
|
610
|
+
// SandboxProvider
|
|
611
|
+
// ---------------------------------------------------------------------------
|
|
612
|
+
/**
|
|
613
|
+
* Creates a sandbox as a Fly Machine.
|
|
614
|
+
*
|
|
615
|
+
* Unlike the Docker provider — where `create` produces a stopped container —
|
|
616
|
+
* the Machines API LAUNCHES the Machine as part of `POST .../machines`. The
|
|
617
|
+
* returned sandbox therefore already reports a live state, and `start()` is
|
|
618
|
+
* idempotent.
|
|
619
|
+
*
|
|
620
|
+
* `config.templateId` is honored by RESTORING the template's archive into the
|
|
621
|
+
* new Machine before the sandbox is handed back, and a missing template is a
|
|
622
|
+
* hard failure — booting the base image instead would return a healthy-looking
|
|
623
|
+
* sandbox whose filesystem is not the one the caller named. Note the one place
|
|
624
|
+
* this provider cannot match the core's wording: on Fly a template is a
|
|
625
|
+
* filesystem, not an image, so `templateId` does not override `image`. The
|
|
626
|
+
* image still selects the OS and toolchain; the template supplies the captured
|
|
627
|
+
* paths on top of it.
|
|
628
|
+
* @param config - Project id, image, env, volume name, template, labels and resource limits.
|
|
629
|
+
* @returns A `Sandbox` bound to the new Machine.
|
|
630
|
+
*/
|
|
631
|
+
async create(config) {
|
|
632
|
+
// Resolved BEFORE anything is provisioned, so a bad template id costs no app,
|
|
633
|
+
// no volume and no Machine.
|
|
634
|
+
let manifest = null;
|
|
635
|
+
if (config.templateId) {
|
|
636
|
+
const found = await templates.readTemplate(this.templateContext(), config.templateId);
|
|
637
|
+
if (!found) {
|
|
638
|
+
throw new Error(t('codeSandbox.flyio.error.templateMissing', { templateId: config.templateId }, {
|
|
639
|
+
defaultValue: `Cannot create a sandbox from template "${config.templateId}": it does not exist. ` +
|
|
640
|
+
'Commit it first with commitTemplate().',
|
|
641
|
+
}));
|
|
642
|
+
}
|
|
643
|
+
manifest = found.manifest;
|
|
644
|
+
}
|
|
645
|
+
// Also resolved BEFORE anything is provisioned: a sandbox told to dial a
|
|
646
|
+
// `.flycast` host nobody declared would boot healthy and then fail to
|
|
647
|
+
// connect inside the user's own application.
|
|
648
|
+
assertPrivateRoutesForEnv(config.env, this.privateServices());
|
|
649
|
+
const app = this.resolveApp(config.projectId);
|
|
650
|
+
const privateRoutes = await this.ensureApp(app);
|
|
651
|
+
const metadata = {
|
|
652
|
+
[`${this.metadataPrefix}.projectId`]: config.projectId,
|
|
653
|
+
[`${this.metadataPrefix}.managed`]: 'true',
|
|
654
|
+
// Recorded so `destroy()` can release exactly the addresses this project
|
|
655
|
+
// allocated. Fly's IP listing does not report which network an address
|
|
656
|
+
// belongs to, so nothing else can reconstruct this.
|
|
657
|
+
...(Object.keys(privateRoutes).length
|
|
658
|
+
? {
|
|
659
|
+
[`${this.metadataPrefix}.${PRIVATE_ROUTES_METADATA_SUFFIX}`]: encodePrivateRoutes(privateRoutes),
|
|
660
|
+
}
|
|
661
|
+
: {}),
|
|
662
|
+
// Recorded for operators reconciling which sandboxes came from which
|
|
663
|
+
// template. It is NOT what `inUse` is derived from — see `templates.ts`.
|
|
664
|
+
...(config.templateId ? { [`${this.metadataPrefix}.templateId`]: config.templateId } : {}),
|
|
665
|
+
};
|
|
666
|
+
// Caller labels are merged FIRST so the provider's own managed keys always
|
|
667
|
+
// win — a caller can never clobber `managed`/`projectId`.
|
|
668
|
+
if (config.labels && typeof config.labels === 'object' && !Array.isArray(config.labels)) {
|
|
669
|
+
for (const [key, value] of Object.entries(config.labels)) {
|
|
670
|
+
if (key in metadata)
|
|
671
|
+
continue;
|
|
672
|
+
metadata[key] = String(value);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
const machineConfig = {
|
|
676
|
+
image: config.image ?? this.config.baseImage ?? process.env.FLY_SANDBOX_IMAGE ?? DEFAULT_IMAGE,
|
|
677
|
+
env: config.env,
|
|
678
|
+
metadata,
|
|
679
|
+
guest: {
|
|
680
|
+
cpus: config.resources?.cpu ?? this.config.defaultCpu ?? 1,
|
|
681
|
+
cpu_kind: this.config.defaultCpuKind ?? 'shared',
|
|
682
|
+
memory_mb: config.resources?.memoryMB ?? this.config.defaultMemoryMB ?? 1024,
|
|
683
|
+
},
|
|
684
|
+
// `no` matches the Docker provider: a sandbox whose main process exits
|
|
685
|
+
// stays stopped for the caller to inspect, rather than silently looping.
|
|
686
|
+
// Fly's default when unset is `on-failure`.
|
|
687
|
+
restart: { policy: 'no' },
|
|
688
|
+
auto_destroy: false,
|
|
689
|
+
};
|
|
690
|
+
if (config.volumeName) {
|
|
691
|
+
metadata[`${this.metadataPrefix}.volumeName`] = config.volumeName;
|
|
692
|
+
const sizeGB = config.resources?.diskMB
|
|
693
|
+
? Math.max(1, Math.ceil(config.resources.diskMB / 1024))
|
|
694
|
+
: (this.config.defaultVolumeGB ?? DEFAULT_VOLUME_GB);
|
|
695
|
+
const volumeId = await this.ensureVolume(app, config.volumeName, sizeGB);
|
|
696
|
+
machineConfig.mounts = [{ volume: volumeId, path: WORKSPACE_PATH }];
|
|
697
|
+
}
|
|
698
|
+
const service = this.previewService();
|
|
699
|
+
if (service)
|
|
700
|
+
machineConfig.services = [service];
|
|
701
|
+
const machine = await this.client.request(`/apps/${app}/machines`, {
|
|
702
|
+
method: 'POST',
|
|
703
|
+
body: { region: this.region(), config: machineConfig },
|
|
704
|
+
});
|
|
705
|
+
if (!machine?.id) {
|
|
706
|
+
throw new Error(t('codeSandbox.flyio.error.createFailed', { app }, { defaultValue: `Fly Machine creation in app "${app}" returned no Machine id.` }));
|
|
707
|
+
}
|
|
708
|
+
if (manifest && config.templateId) {
|
|
709
|
+
await this.restoreTemplate(app, machine.id, config.templateId, manifest);
|
|
710
|
+
// The restore waited for `started` and ran a command in it, so the state
|
|
711
|
+
// read back from the creation response is stale by definition.
|
|
712
|
+
return this.buildSandbox(app, machine.id, 'running');
|
|
713
|
+
}
|
|
714
|
+
return this.buildSandbox(app, machine.id, mapMachineState(machine.state ?? 'created'));
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Retrieves an existing sandbox by its composite `<app>:<machineId>` id.
|
|
718
|
+
* @param id - The sandbox id returned by `create`.
|
|
719
|
+
* @returns The sandbox, or `null` when the id is malformed or the Machine is gone.
|
|
720
|
+
*/
|
|
721
|
+
async get(id) {
|
|
722
|
+
let app;
|
|
723
|
+
let machineId;
|
|
724
|
+
try {
|
|
725
|
+
;
|
|
726
|
+
({ app, machineId } = parseSandboxId(id));
|
|
727
|
+
}
|
|
728
|
+
catch (error) {
|
|
729
|
+
logger.warn('Cannot get Fly sandbox — malformed sandbox id', { id, error });
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
try {
|
|
733
|
+
const machine = await this.client.request(`/apps/${app}/machines/${machineId}`, {
|
|
734
|
+
nullOn: [404],
|
|
735
|
+
});
|
|
736
|
+
if (!machine)
|
|
737
|
+
return null;
|
|
738
|
+
return this.buildSandbox(app, machine.id, mapMachineState(machine.state ?? 'created'));
|
|
739
|
+
}
|
|
740
|
+
catch (error) {
|
|
741
|
+
logger.debug('Failed to get Fly Machine', { id, error });
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Lists every sandbox this provider manages.
|
|
747
|
+
*
|
|
748
|
+
* In per-project mode the Machines live in many apps, so this pages through
|
|
749
|
+
* `GET /v1/orgs/{org}/machines` and keeps the ones carrying this provider's
|
|
750
|
+
* `managed` metadata. In shared-app mode it lists that app directly.
|
|
751
|
+
* @param _userId - Reserved for future per-user filtering; currently ignored,
|
|
752
|
+
* matching the Docker provider.
|
|
753
|
+
* @returns Every managed sandbox.
|
|
754
|
+
*/
|
|
755
|
+
async list(_userId) {
|
|
756
|
+
const managedKey = `${this.metadataPrefix}.managed`;
|
|
757
|
+
if (!this.appPerProject()) {
|
|
758
|
+
const app = this.resolveApp('unused');
|
|
759
|
+
const machines = await this.client.request(`/apps/${app}/machines`, {
|
|
760
|
+
nullOn: [404],
|
|
761
|
+
});
|
|
762
|
+
return (machines ?? [])
|
|
763
|
+
.filter((machine) => machine.config?.metadata?.[managedKey] === 'true')
|
|
764
|
+
.map((machine) => this.buildSandbox(app, machine.id, mapMachineState(machine.state ?? 'created')));
|
|
765
|
+
}
|
|
766
|
+
const sandboxes = [];
|
|
767
|
+
let cursor;
|
|
768
|
+
for (let page = 0; page < LIST_MAX_PAGES; page++) {
|
|
769
|
+
const query = new URLSearchParams({ limit: String(LIST_PAGE_SIZE) });
|
|
770
|
+
if (cursor)
|
|
771
|
+
query.set('cursor', cursor);
|
|
772
|
+
const response = await this.client.request(`/orgs/${this.orgSlug()}/machines?${query.toString()}`, { nullOn: [404] });
|
|
773
|
+
for (const machine of response?.machines ?? []) {
|
|
774
|
+
if (machine.config?.metadata?.[managedKey] !== 'true')
|
|
775
|
+
continue;
|
|
776
|
+
sandboxes.push(this.buildSandbox(machine.app_name, machine.id, mapMachineState(machine.state ?? 'created')));
|
|
777
|
+
}
|
|
778
|
+
cursor = response?.next_cursor || undefined;
|
|
779
|
+
if (!cursor)
|
|
780
|
+
break;
|
|
781
|
+
if (page === LIST_MAX_PAGES - 1) {
|
|
782
|
+
// Fly still had a cursor: this listing is TRUNCATED, and a caller that
|
|
783
|
+
// reaps or reconciles from it would leave the unseen Machines running
|
|
784
|
+
// and billing forever. Say so rather than returning a short list that
|
|
785
|
+
// looks complete. Fly's `limit` is advisory ("Responses may be shorter,
|
|
786
|
+
// or even empty, even when more machines remain"), so a large org can
|
|
787
|
+
// reach this cap well before LIST_MAX_PAGES * LIST_PAGE_SIZE Machines.
|
|
788
|
+
logger.warn('Fly org-wide Machine listing hit its page cap and is TRUNCATED — some managed ' +
|
|
789
|
+
'sandboxes are missing from this result', { org: this.orgSlug(), pages: LIST_MAX_PAGES, returned: sandboxes.length });
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return sandboxes;
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Destroys a sandbox.
|
|
796
|
+
*
|
|
797
|
+
* In per-project mode the whole Fly app is deleted after the Machine, which
|
|
798
|
+
* also removes its volumes and its custom 6PN network — leaving an empty app
|
|
799
|
+
* behind would leak both. In shared-app mode only the Machine and its own
|
|
800
|
+
* volumes are removed.
|
|
801
|
+
*
|
|
802
|
+
* The Flycast addresses this project holds live on OTHER apps (the tenant
|
|
803
|
+
* database, the egress proxy), so deleting the app does not touch them. They
|
|
804
|
+
* are read from the Machine's metadata FIRST — the Machine is the only record
|
|
805
|
+
* of which address belongs to which network — and released last.
|
|
806
|
+
* @param id - The composite sandbox id.
|
|
807
|
+
*/
|
|
808
|
+
async destroy(id) {
|
|
809
|
+
const { app, machineId } = parseSandboxId(id);
|
|
810
|
+
let volumeIds = [];
|
|
811
|
+
let privateRoutes = {};
|
|
812
|
+
try {
|
|
813
|
+
const machine = await this.client.request(`/apps/${app}/machines/${machineId}`, {
|
|
814
|
+
nullOn: [404],
|
|
815
|
+
});
|
|
816
|
+
privateRoutes = decodePrivateRoutes(machine?.config?.metadata?.[`${this.metadataPrefix}.${PRIVATE_ROUTES_METADATA_SUFFIX}`]);
|
|
817
|
+
if (!this.appPerProject()) {
|
|
818
|
+
volumeIds = (machine?.config?.mounts ?? [])
|
|
819
|
+
.map((mount) => mount.volume)
|
|
820
|
+
.filter((volume) => Boolean(volume));
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
catch (error) {
|
|
824
|
+
// Best-effort metadata gathering; the Machine may already be gone.
|
|
825
|
+
// Destruction proceeds either way — but say so, because an unread
|
|
826
|
+
// metadata block means the private addresses below are NOT released.
|
|
827
|
+
logger.warn('Could not read the Fly Machine before destroy — its volumes and any private (Flycast) ' +
|
|
828
|
+
'addresses it recorded will not be cleaned up', { id, error });
|
|
829
|
+
}
|
|
830
|
+
try {
|
|
831
|
+
await this.client.request(`/apps/${app}/machines/${machineId}?force=true`, {
|
|
832
|
+
method: 'DELETE',
|
|
833
|
+
nullOn: [404],
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
logger.warn('Failed to destroy Fly Machine', { id, error });
|
|
838
|
+
}
|
|
839
|
+
if (this.appPerProject()) {
|
|
840
|
+
try {
|
|
841
|
+
await this.client.request(`/apps/${app}`, { method: 'DELETE', nullOn: [404] });
|
|
842
|
+
}
|
|
843
|
+
catch (error) {
|
|
844
|
+
logger.warn('Failed to delete Fly sandbox app — its volumes and 6PN network may leak', {
|
|
845
|
+
app,
|
|
846
|
+
error,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
await this.releasePrivateRoutes(privateRoutes);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
for (const volumeId of volumeIds) {
|
|
853
|
+
try {
|
|
854
|
+
await this.client.request(`/apps/${app}/volumes/${volumeId}`, {
|
|
855
|
+
method: 'DELETE',
|
|
856
|
+
nullOn: [404],
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
catch (error) {
|
|
860
|
+
logger.warn('Failed to remove Fly volume', { app, volumeId, error });
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
await this.releasePrivateRoutes(privateRoutes);
|
|
864
|
+
}
|
|
865
|
+
// ---------------------------------------------------------------------------
|
|
866
|
+
// Templates
|
|
867
|
+
// ---------------------------------------------------------------------------
|
|
868
|
+
/**
|
|
869
|
+
* Builds the context the template capability runs against.
|
|
870
|
+
*
|
|
871
|
+
* The store is resolved once and memoized, so a boot that checks for a
|
|
872
|
+
* template does not rebuild an S3 client per call.
|
|
873
|
+
* @returns The template context.
|
|
874
|
+
*/
|
|
875
|
+
templateContext() {
|
|
876
|
+
if (this.templateStoreCache === undefined) {
|
|
877
|
+
this.templateStoreCache = createTemplateStore(this.config);
|
|
878
|
+
if (this.templateStoreCache) {
|
|
879
|
+
logger.info('Fly sandbox templates are enabled', {
|
|
880
|
+
store: this.templateStoreCache.describe,
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return {
|
|
885
|
+
store: this.templateStoreCache,
|
|
886
|
+
exec: async (app, machineId, command, timeoutMs) =>
|
|
887
|
+
// `cwd: '/'` rather than the workspace: a freshly-booted Machine that is
|
|
888
|
+
// about to be restored INTO has no `/workspace` yet, and `execCommand`
|
|
889
|
+
// starts every script with a `cd` that would fail there.
|
|
890
|
+
execCommand(this.rawExec(app, machineId), command, { cwd: '/', timeout: timeoutMs }, '/'),
|
|
891
|
+
ensureStarted: async (app, machineId) => {
|
|
892
|
+
await this.startMachine(app, machineId);
|
|
893
|
+
},
|
|
894
|
+
parseSandboxId,
|
|
895
|
+
presignExpirySeconds: Math.max(60, Math.min(this.config.templateUrlExpirySeconds ?? templates.DEFAULT_PRESIGN_EXPIRY_SECONDS, MAX_PRESIGN_EXPIRY_SECONDS)),
|
|
896
|
+
// Floored only against a zero/negative budget: how long a capture may take
|
|
897
|
+
// is the operator's call, and a low value simply fails the transfer rather
|
|
898
|
+
// than doing anything unsafe.
|
|
899
|
+
transferTimeoutMs: Math.max(1000, this.config.templateTransferTimeoutMs ?? templates.DEFAULT_TRANSFER_TIMEOUT_MS),
|
|
900
|
+
maxArchiveBytes: Math.max(1, Math.min(this.config.templateMaxArchiveBytes ?? templates.MAX_ARCHIVE_BYTES, templates.MAX_ARCHIVE_BYTES)),
|
|
901
|
+
warn: (message, meta) => logger.warn(message, meta),
|
|
902
|
+
debug: (message, meta) => logger.debug(message, meta),
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Capture a sandbox's filesystem as a reusable template.
|
|
907
|
+
*
|
|
908
|
+
* See `templates.ts` for why this is a tar in object storage rather than
|
|
909
|
+
* anything Fly-native: Fly cannot produce an image from a running Machine, and
|
|
910
|
+
* a volume snapshot cannot cross the per-project app boundary this bond
|
|
911
|
+
* creates for tenant isolation.
|
|
912
|
+
* @param options - What to capture and what to call it.
|
|
913
|
+
* @returns The template as it now exists.
|
|
914
|
+
*/
|
|
915
|
+
async commitTemplate(options) {
|
|
916
|
+
return templates.commitTemplate(this.templateContext(), options);
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Read one template by the caller's identifier.
|
|
920
|
+
* @param templateId - The caller's identifier.
|
|
921
|
+
* @returns The template, or `null` when no template has that id.
|
|
922
|
+
*/
|
|
923
|
+
async getTemplate(templateId) {
|
|
924
|
+
return templates.getTemplate(this.templateContext(), templateId);
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Enumerate templates in the configured store.
|
|
928
|
+
* @param options - Narrowing by id prefix.
|
|
929
|
+
* @returns Every matching template.
|
|
930
|
+
*/
|
|
931
|
+
async listTemplates(options) {
|
|
932
|
+
return templates.listTemplates(this.templateContext(), options);
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Delete a template, refusing while a restore is still reading it.
|
|
936
|
+
* @param templateId - The caller's identifier.
|
|
937
|
+
*/
|
|
938
|
+
async removeTemplate(templateId) {
|
|
939
|
+
return templates.removeTemplate(this.templateContext(), templateId);
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Restores a template into a freshly-created Machine.
|
|
943
|
+
*
|
|
944
|
+
* A failure here DESTROYS the Machine. The alternative is to hand back a
|
|
945
|
+
* sandbox that started cleanly and whose filesystem is not the one that was
|
|
946
|
+
* asked for — the same silent-wrong-contents failure that makes a missing
|
|
947
|
+
* template a hard error rather than a fallback to the base image.
|
|
948
|
+
* @param app - The Fly app name.
|
|
949
|
+
* @param machineId - The Fly Machine id.
|
|
950
|
+
* @param templateId - The caller's template identifier.
|
|
951
|
+
* @param manifest - The CONTROL-PLANE manifest, whose `capturePaths` bound what is extracted.
|
|
952
|
+
* @throws {Error} When the restore fails; the Machine is destroyed first.
|
|
953
|
+
*/
|
|
954
|
+
async restoreTemplate(app, machineId, templateId, manifest) {
|
|
955
|
+
const ctx = this.templateContext();
|
|
956
|
+
const store = templates.requireStore(ctx);
|
|
957
|
+
const leaseId = `${machineId}-${Date.now().toString(36)}`.replace(/[^A-Za-z0-9_.-]/g, '_');
|
|
958
|
+
let lease = null;
|
|
959
|
+
try {
|
|
960
|
+
// The restore runs through exec, so the Machine has to be up. `create`
|
|
961
|
+
// launches it but returns as soon as Fly accepts the request.
|
|
962
|
+
await this.waitForStarted(app, machineId);
|
|
963
|
+
lease = await templates.acquireRestoreLease(ctx, templateId, leaseId);
|
|
964
|
+
const url = await store.presignGet(templates.archiveKey(store, templateId), ctx.presignExpirySeconds);
|
|
965
|
+
const result = await ctx.exec(app, machineId, templates.buildRestoreCommand(manifest.capturePaths, url), ctx.transferTimeoutMs);
|
|
966
|
+
if (result.exitCode !== 0) {
|
|
967
|
+
throw new Error(t('codeSandbox.flyio.error.templateRestoreFailed', { templateId, exitCode: String(result.exitCode) }, {
|
|
968
|
+
defaultValue: `Restoring template "${templateId}" into Fly Machine ${machineId} failed ` +
|
|
969
|
+
`(exit ${result.exitCode}): ${result.stderr.slice(0, 500) || result.stdout.slice(0, 500)}`,
|
|
970
|
+
}));
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
catch (error) {
|
|
974
|
+
try {
|
|
975
|
+
await this.client.request(`/apps/${app}/machines/${machineId}?force=true`, {
|
|
976
|
+
method: 'DELETE',
|
|
977
|
+
nullOn: [404],
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
catch (cleanupError) {
|
|
981
|
+
logger.warn('Failed to destroy the Fly Machine whose template restore failed — it will keep ' +
|
|
982
|
+
'billing until it is reaped', { app, machineId, error: cleanupError });
|
|
983
|
+
}
|
|
984
|
+
throw error;
|
|
985
|
+
}
|
|
986
|
+
finally {
|
|
987
|
+
if (lease)
|
|
988
|
+
await templates.releaseRestoreLease(ctx, lease);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
// ---------------------------------------------------------------------------
|
|
992
|
+
// Egress verification
|
|
993
|
+
// ---------------------------------------------------------------------------
|
|
994
|
+
/**
|
|
995
|
+
* PROVES whether a sandbox Machine can open raw connections to the public
|
|
996
|
+
* internet, by running one and watching what happens.
|
|
997
|
+
*
|
|
998
|
+
* **Why it is built this way.** Fly's tenant isolation (custom 6PN networks)
|
|
999
|
+
* answers a different question — whether one app can reach ANOTHER app — and
|
|
1000
|
+
* says nothing about the public internet. The only mechanism Fly documents for
|
|
1001
|
+
* egress is a **network policy**
|
|
1002
|
+
* (https://fly.io/docs/machines/guides-examples/network-policies/), an
|
|
1003
|
+
* app-scoped object whose `allow` rules make everything else in that direction
|
|
1004
|
+
* drop. Reading that policy back would be attestation, not observation, and the
|
|
1005
|
+
* failure this capability replaced was exactly an attestation that stayed true
|
|
1006
|
+
* on paper after it stopped being true in fact. So this creates a throwaway
|
|
1007
|
+
* Machine through the SAME provisioning path every sandbox goes through
|
|
1008
|
+
* (`ensureApp`, which applies the same policy), attempts raw TCP connects to
|
|
1009
|
+
* literal public IPs from inside it, and reports what the sockets did.
|
|
1010
|
+
*
|
|
1011
|
+
* `open` and `filtered` are therefore observations. Everything else — no
|
|
1012
|
+
* targets, no API token, the Machine never booting, a probe image without
|
|
1013
|
+
* `node`, an exec that returned no status — is `inconclusive`, never
|
|
1014
|
+
* `filtered`.
|
|
1015
|
+
*
|
|
1016
|
+
* **What a `filtered` verdict here does and does not mean.** It means raw
|
|
1017
|
+
* egress to the probed ports is dropped. It does NOT mean host-level control:
|
|
1018
|
+
* Fly network policies match protocol and port only, with no host or CIDR
|
|
1019
|
+
* matching, so a host allowlist can only be built by allowing the port of an
|
|
1020
|
+
* egress proxy and routing sandbox traffic through it. It also cannot speak
|
|
1021
|
+
* for Machines that were already running when their app's policy changed —
|
|
1022
|
+
* Fly applies a policy at Machine boot ("restart or redeploy the Machines for
|
|
1023
|
+
* changes to take effect"), which is why `ensureApp` applies it before every
|
|
1024
|
+
* Machine is created.
|
|
1025
|
+
*
|
|
1026
|
+
* Costs one Machine boot and (in per-project mode) one app create/delete per
|
|
1027
|
+
* call, so callers should cache the verdict rather than probing per request.
|
|
1028
|
+
* @returns What was observed about egress from a Fly sandbox Machine.
|
|
1029
|
+
*/
|
|
1030
|
+
async verifyEgress() {
|
|
1031
|
+
const targets = parseEgressProbeTargets(this.config.egressProbeTargets ??
|
|
1032
|
+
process.env.SANDBOX_EGRESS_PROBE_TARGETS ??
|
|
1033
|
+
DEFAULT_EGRESS_PROBE_TARGETS);
|
|
1034
|
+
if (targets.length === 0) {
|
|
1035
|
+
return {
|
|
1036
|
+
state: 'inconclusive',
|
|
1037
|
+
detail: 'No valid literal ip:port egress probe targets are configured.',
|
|
1038
|
+
remediation: 'Set SANDBOX_EGRESS_PROBE_TARGETS (or config.egressProbeTargets) to comma-separated ' +
|
|
1039
|
+
'literal ip:port values — hostnames are rejected, and IPv6 literals must be bracketed.',
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
// parseInt yields NaN (not undefined) for an absent or junk value, so the
|
|
1043
|
+
// fallback is a finiteness check rather than `??`.
|
|
1044
|
+
const configured = this.config.egressProbeTimeoutMs ??
|
|
1045
|
+
Number.parseInt(process.env.SANDBOX_EGRESS_PROBE_TIMEOUT_MS ?? '', 10);
|
|
1046
|
+
const timeoutMs = Number.isFinite(configured)
|
|
1047
|
+
? Math.min(15_000, Math.max(500, configured))
|
|
1048
|
+
: DEFAULT_PROBE_TIMEOUT_MS;
|
|
1049
|
+
let app = null;
|
|
1050
|
+
let machineId = null;
|
|
1051
|
+
let policyPorts;
|
|
1052
|
+
try {
|
|
1053
|
+
policyPorts = this.egressAllowedPorts();
|
|
1054
|
+
app = this.resolveApp(this.probeProjectId());
|
|
1055
|
+
// The SAME policy a real sandbox gets (private-service ports included, so
|
|
1056
|
+
// the probe observes the real posture), but NO Flycast allocation: this
|
|
1057
|
+
// app is deleted seconds from now, and allocating would leave one
|
|
1058
|
+
// unreleasable address per probe on every target app — every 15 minutes.
|
|
1059
|
+
await this.ensureApp(app, { privateRoutes: false });
|
|
1060
|
+
machineId = await this.createProbeMachine(app);
|
|
1061
|
+
await this.waitForStarted(app, machineId);
|
|
1062
|
+
const seconds = Math.min(MAX_WAIT_SECONDS, Math.max(1, Math.ceil(timeoutMs / 1000) + PROBE_EXEC_HEADROOM_SECONDS));
|
|
1063
|
+
const result = toExecResult(await this.rawExec(app, machineId)(['sh', '-c', buildEgressProbeCommand(targets, timeoutMs)], seconds));
|
|
1064
|
+
return verdictForProbeExit(result.exitCode, { app, targets, policyPorts });
|
|
1065
|
+
}
|
|
1066
|
+
catch (error) {
|
|
1067
|
+
// A probe that could not RUN proves nothing. Reporting `filtered` here is
|
|
1068
|
+
// precisely the lie this method exists to make impossible.
|
|
1069
|
+
return {
|
|
1070
|
+
state: 'inconclusive',
|
|
1071
|
+
detail: `The Fly egress probe could not run: ${error instanceof Error ? error.message : String(error)}`,
|
|
1072
|
+
remediation: 'Check that FLY_API_TOKEN can create apps and Machines in the organization, and that ' +
|
|
1073
|
+
'the probe image is pullable by Fly and contains `node` and `sleep`.',
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
finally {
|
|
1077
|
+
await this.destroyProbe(app, machineId);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Generates the synthetic project id the throwaway probe app is named from.
|
|
1082
|
+
* Hex only, so {@link appNameForProject} never has to rewrite it.
|
|
1083
|
+
* @returns A project id such as `egress-probe-1a2b3c4d5e6f`.
|
|
1084
|
+
*/
|
|
1085
|
+
probeProjectId() {
|
|
1086
|
+
let hex = '';
|
|
1087
|
+
while (hex.length < 12)
|
|
1088
|
+
hex += Math.floor(Math.random() * 0xffffffff).toString(16);
|
|
1089
|
+
return `egress-probe-${hex.slice(0, 12)}`;
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Creates the throwaway probe Machine.
|
|
1093
|
+
*
|
|
1094
|
+
* It runs the sandbox image (so it observes egress from the same image real
|
|
1095
|
+
* sandboxes run) with `init.exec` overriding the entrypoint — Fly documents
|
|
1096
|
+
* `exec` as overriding "any other startup command line, either in our API or in
|
|
1097
|
+
* your Docker container definition" — so the Machine is guaranteed to stay up
|
|
1098
|
+
* long enough to exec into, whatever the image's own entrypoint does. Proxy
|
|
1099
|
+
* environment variables are blanked: a probe that politely used a proxy would
|
|
1100
|
+
* report the proxy's policy rather than the network's.
|
|
1101
|
+
* @param app - The Fly app to create it in.
|
|
1102
|
+
* @returns The new Machine's id.
|
|
1103
|
+
* @throws {Error} When the API returns no Machine id.
|
|
1104
|
+
*/
|
|
1105
|
+
async createProbeMachine(app) {
|
|
1106
|
+
const config = {
|
|
1107
|
+
image: this.config.egressProbeImage ??
|
|
1108
|
+
this.config.baseImage ??
|
|
1109
|
+
process.env.FLY_SANDBOX_IMAGE ??
|
|
1110
|
+
DEFAULT_IMAGE,
|
|
1111
|
+
env: {
|
|
1112
|
+
HTTP_PROXY: '',
|
|
1113
|
+
HTTPS_PROXY: '',
|
|
1114
|
+
http_proxy: '',
|
|
1115
|
+
https_proxy: '',
|
|
1116
|
+
NO_PROXY: '',
|
|
1117
|
+
no_proxy: '',
|
|
1118
|
+
},
|
|
1119
|
+
// NOT the `managed` metadata: a probe Machine must never appear in list().
|
|
1120
|
+
metadata: { [`${this.metadataPrefix}.egressProbe`]: 'true' },
|
|
1121
|
+
guest: { cpus: 1, cpu_kind: 'shared', memory_mb: PROBE_MEMORY_MB },
|
|
1122
|
+
init: { exec: ['sleep', String(PROBE_LIFETIME_SECONDS)] },
|
|
1123
|
+
restart: { policy: 'no' },
|
|
1124
|
+
// Backstop: reaps the Machine when the sleep ends, even if this process
|
|
1125
|
+
// dies before its explicit cleanup runs.
|
|
1126
|
+
auto_destroy: true,
|
|
1127
|
+
};
|
|
1128
|
+
const machine = await this.client.request(`/apps/${app}/machines`, {
|
|
1129
|
+
method: 'POST',
|
|
1130
|
+
body: { region: this.region(), config },
|
|
1131
|
+
});
|
|
1132
|
+
if (!machine?.id) {
|
|
1133
|
+
throw new Error(t('codeSandbox.flyio.error.probeCreateFailed', { app }, { defaultValue: `Fly egress probe Machine creation in app "${app}" returned no id.` }));
|
|
1134
|
+
}
|
|
1135
|
+
return machine.id;
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Removes the probe Machine and, in per-project mode, its throwaway app.
|
|
1139
|
+
*
|
|
1140
|
+
* Best-effort by design: a cleanup failure must not turn a completed
|
|
1141
|
+
* observation into an error, and `auto_destroy` plus the bounded init sleep
|
|
1142
|
+
* already reap the Machine on their own.
|
|
1143
|
+
* @param app - The probe app, or `null` when it was never resolved.
|
|
1144
|
+
* @param machineId - The probe Machine id, or `null` when it was never created.
|
|
1145
|
+
*/
|
|
1146
|
+
async destroyProbe(app, machineId) {
|
|
1147
|
+
if (!app)
|
|
1148
|
+
return;
|
|
1149
|
+
if (machineId) {
|
|
1150
|
+
try {
|
|
1151
|
+
await this.client.request(`/apps/${app}/machines/${machineId}?force=true`, {
|
|
1152
|
+
method: 'DELETE',
|
|
1153
|
+
nullOn: [404],
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
catch (error) {
|
|
1157
|
+
logger.warn('Failed to remove the Fly egress probe Machine', { app, machineId, error });
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
// Never in shared-app mode: that app holds real sandboxes.
|
|
1161
|
+
if (!this.appPerProject())
|
|
1162
|
+
return;
|
|
1163
|
+
try {
|
|
1164
|
+
await this.client.request(`/apps/${app}`, { method: 'DELETE', nullOn: [404] });
|
|
1165
|
+
}
|
|
1166
|
+
catch (error) {
|
|
1167
|
+
logger.warn('Failed to delete the Fly egress probe app — it may leak an empty app', {
|
|
1168
|
+
app,
|
|
1169
|
+
error,
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
// ---------------------------------------------------------------------------
|
|
1174
|
+
// Sandbox
|
|
1175
|
+
// ---------------------------------------------------------------------------
|
|
1176
|
+
/**
|
|
1177
|
+
* Issues one Fly exec call against a Machine.
|
|
1178
|
+
* @param app - The Fly app name.
|
|
1179
|
+
* @param machineId - The Fly Machine id.
|
|
1180
|
+
* @returns A transport callback for {@link execCommand}.
|
|
1181
|
+
*/
|
|
1182
|
+
rawExec(app, machineId) {
|
|
1183
|
+
return async (command, timeoutSeconds) => {
|
|
1184
|
+
const response = await this.client.request(`/apps/${app}/machines/${machineId}/exec`, {
|
|
1185
|
+
method: 'POST',
|
|
1186
|
+
body: { command, timeout: timeoutSeconds },
|
|
1187
|
+
// The API's own timeout bounds the command; allow the HTTP request a
|
|
1188
|
+
// little longer so the transport never fires first.
|
|
1189
|
+
timeoutMs: (timeoutSeconds + 15) * 1000,
|
|
1190
|
+
});
|
|
1191
|
+
return response ?? {};
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Starts (or resumes) a Machine, tolerating the case where it is already up.
|
|
1196
|
+
*
|
|
1197
|
+
* A Machine that is already `started` may reject a redundant start. Rather
|
|
1198
|
+
* than pattern-matching an error string, the state is re-read and the call
|
|
1199
|
+
* treated as successful when the Machine is in fact running.
|
|
1200
|
+
* @param app - The Fly app name.
|
|
1201
|
+
* @param machineId - The Fly Machine id.
|
|
1202
|
+
*/
|
|
1203
|
+
async startMachine(app, machineId) {
|
|
1204
|
+
try {
|
|
1205
|
+
await this.client.request(`/apps/${app}/machines/${machineId}/start`, { method: 'POST' });
|
|
1206
|
+
}
|
|
1207
|
+
catch (error) {
|
|
1208
|
+
const machine = await this.client
|
|
1209
|
+
.request(`/apps/${app}/machines/${machineId}`, { nullOn: [404] })
|
|
1210
|
+
.catch(() => null);
|
|
1211
|
+
if (machine && (machine.state === 'started' || machine.state === 'starting')) {
|
|
1212
|
+
logger.debug('Fly Machine start rejected but the Machine is already running', {
|
|
1213
|
+
app,
|
|
1214
|
+
machineId,
|
|
1215
|
+
state: machine.state,
|
|
1216
|
+
});
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
throw error;
|
|
1220
|
+
}
|
|
1221
|
+
await this.waitForStarted(app, machineId);
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Blocks until a Machine actually reaches `started`.
|
|
1225
|
+
*
|
|
1226
|
+
* `POST .../start` returns as soon as Fly ACCEPTS the request, not when the
|
|
1227
|
+
* Machine is running — so without this, `start()` and `wake()` resolve against
|
|
1228
|
+
* a Machine that is still booting and the caller's very next `exec` fails on a
|
|
1229
|
+
* Machine that is not up. Fly's `wait` endpoint exists for exactly this and
|
|
1230
|
+
* blocks server-side for up to 60 s.
|
|
1231
|
+
* @param app - The Fly app name.
|
|
1232
|
+
* @param machineId - The Fly Machine id.
|
|
1233
|
+
* @throws {Error} When the Machine has not started within the budget.
|
|
1234
|
+
*/
|
|
1235
|
+
async waitForStarted(app, machineId) {
|
|
1236
|
+
const seconds = Math.max(1, Math.min(this.config.startTimeoutSeconds ?? MAX_WAIT_SECONDS, MAX_WAIT_SECONDS));
|
|
1237
|
+
try {
|
|
1238
|
+
await this.client.request(`/apps/${app}/machines/${machineId}/wait?state=started&timeout=${seconds}`,
|
|
1239
|
+
// One attempt: a retry would re-block for the whole budget, and the
|
|
1240
|
+
// fallback below re-reads the state anyway.
|
|
1241
|
+
{ attempts: 1, timeoutMs: (seconds + 15) * 1000 });
|
|
1242
|
+
}
|
|
1243
|
+
catch (error) {
|
|
1244
|
+
// The wait endpoint returning an error is not itself proof the Machine
|
|
1245
|
+
// failed — re-read the authoritative state before failing the caller.
|
|
1246
|
+
const machine = await this.client
|
|
1247
|
+
.request(`/apps/${app}/machines/${machineId}`, { nullOn: [404] })
|
|
1248
|
+
.catch(() => null);
|
|
1249
|
+
if (machine?.state === 'started')
|
|
1250
|
+
return;
|
|
1251
|
+
throw new Error(t('codeSandbox.flyio.error.startTimeout', { app, machineId, seconds: String(seconds), state: machine?.state ?? 'unknown' }, {
|
|
1252
|
+
defaultValue: `Fly Machine ${machineId} in app "${app}" did not reach "started" within ` +
|
|
1253
|
+
`${seconds}s (last observed state: ${machine?.state ?? 'unknown'}).`,
|
|
1254
|
+
}), { cause: error });
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Builds the `Sandbox` facade for one Machine.
|
|
1259
|
+
* @param app - The Fly app name.
|
|
1260
|
+
* @param machineId - The Fly Machine id.
|
|
1261
|
+
* @param initialStatus - Status derived from the Machine's current Fly state.
|
|
1262
|
+
* @returns A `Sandbox` bound to that Machine.
|
|
1263
|
+
*/
|
|
1264
|
+
buildSandbox(app, machineId, initialStatus) {
|
|
1265
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
1266
|
+
const provider = this;
|
|
1267
|
+
const exec = provider.rawExec(app, machineId);
|
|
1268
|
+
return {
|
|
1269
|
+
id: toSandboxId(app, machineId),
|
|
1270
|
+
status: initialStatus,
|
|
1271
|
+
previewUrl: provider.renderPreviewUrl(app, machineId),
|
|
1272
|
+
async start() {
|
|
1273
|
+
await provider.startMachine(app, machineId);
|
|
1274
|
+
this.status = 'running';
|
|
1275
|
+
},
|
|
1276
|
+
async stop() {
|
|
1277
|
+
await provider.client.request(`/apps/${app}/machines/${machineId}/stop`, { method: 'POST' });
|
|
1278
|
+
this.status = 'stopped';
|
|
1279
|
+
},
|
|
1280
|
+
/**
|
|
1281
|
+
* SUSPENDS the Machine. Fly snapshots the microVM's memory to disk, so the
|
|
1282
|
+
* next `wake()` resumes in a few hundred ms instead of cold-booting, and
|
|
1283
|
+
* a suspended Machine bills for storage only. This mapping — not a plain
|
|
1284
|
+
* stop — is why this bond exists.
|
|
1285
|
+
*/
|
|
1286
|
+
async sleep() {
|
|
1287
|
+
await provider.client.request(`/apps/${app}/machines/${machineId}/suspend`, {
|
|
1288
|
+
method: 'POST',
|
|
1289
|
+
});
|
|
1290
|
+
this.status = 'sleeping';
|
|
1291
|
+
},
|
|
1292
|
+
/**
|
|
1293
|
+
* RESUMES the Machine. Fly has no separate resume endpoint: `start` on a
|
|
1294
|
+
* suspended Machine restores the memory snapshot, falling back to a cold
|
|
1295
|
+
* boot (with the rootfs preserved) when the snapshot cannot be used.
|
|
1296
|
+
*/
|
|
1297
|
+
async wake() {
|
|
1298
|
+
await provider.startMachine(app, machineId);
|
|
1299
|
+
this.status = 'running';
|
|
1300
|
+
},
|
|
1301
|
+
async exec(command, opts) {
|
|
1302
|
+
return await execCommand(exec, command, opts, WORKSPACE_PATH);
|
|
1303
|
+
},
|
|
1304
|
+
async readFile(path) {
|
|
1305
|
+
const result = await this.exec(`cat ${shellQuote(path)}`, { timeout: FILE_OP_TIMEOUT_MS });
|
|
1306
|
+
if (result.exitCode !== 0) {
|
|
1307
|
+
throw new Error(t('codeSandbox.flyio.error.readFailed', { path, error: result.stderr }, { defaultValue: `Failed to read ${path}: ${result.stderr}` }));
|
|
1308
|
+
}
|
|
1309
|
+
return result.stdout;
|
|
1310
|
+
},
|
|
1311
|
+
async writeFile(path, content) {
|
|
1312
|
+
const base64 = Buffer.from(content).toString('base64');
|
|
1313
|
+
const quoted = shellQuote(path);
|
|
1314
|
+
const failIfError = (result) => {
|
|
1315
|
+
if (result.exitCode !== 0) {
|
|
1316
|
+
throw new Error(t('codeSandbox.flyio.error.writeFailed', { path, error: result.stderr }, { defaultValue: `Failed to write ${path}: ${result.stderr}` }));
|
|
1317
|
+
}
|
|
1318
|
+
};
|
|
1319
|
+
// Chunked for the same reason as the Docker provider: the whole command
|
|
1320
|
+
// is one `sh -c` argument, capped at MAX_ARG_STRLEN (128 KB). Each chunk
|
|
1321
|
+
// is a multiple of 4 base64 characters (= 3 decoded bytes) so it decodes
|
|
1322
|
+
// standalone and the bytes concatenate exactly.
|
|
1323
|
+
failIfError(await this.exec(`mkdir -p "$(dirname ${quoted})" && printf %s ${shellQuote(base64.slice(0, WRITE_CHUNK_BASE64))} | base64 -d > ${quoted}`, { timeout: FILE_OP_TIMEOUT_MS }));
|
|
1324
|
+
for (let i = WRITE_CHUNK_BASE64; i < base64.length; i += WRITE_CHUNK_BASE64) {
|
|
1325
|
+
failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(i, i + WRITE_CHUNK_BASE64))} | base64 -d >> ${quoted}`, { timeout: FILE_OP_TIMEOUT_MS }));
|
|
1326
|
+
}
|
|
1327
|
+
},
|
|
1328
|
+
async readDir(path) {
|
|
1329
|
+
// Trailing slash so `ls` follows a symlinked directory.
|
|
1330
|
+
const dirPath = path.endsWith('/') ? path : `${path}/`;
|
|
1331
|
+
// Deliberately NOT piped: a pipeline reports the LAST command's status,
|
|
1332
|
+
// which would mask a missing directory. The `total` header is filtered
|
|
1333
|
+
// in JS instead.
|
|
1334
|
+
const result = await this.exec(`ls -la --time-style=+%s ${shellQuote(dirPath)}`, {
|
|
1335
|
+
timeout: FILE_OP_TIMEOUT_MS,
|
|
1336
|
+
});
|
|
1337
|
+
// A missing directory must THROW. An empty array means "exists and is
|
|
1338
|
+
// empty", and conflating the two sends an AI executor chasing files
|
|
1339
|
+
// that were never there.
|
|
1340
|
+
if (result.exitCode !== 0) {
|
|
1341
|
+
throw new Error(t('codeSandbox.flyio.error.readDirFailed', { path, error: result.stderr }, { defaultValue: `Failed to list ${path}: ${result.stderr}` }));
|
|
1342
|
+
}
|
|
1343
|
+
return result.stdout
|
|
1344
|
+
.trim()
|
|
1345
|
+
.split('\n')
|
|
1346
|
+
.filter(Boolean)
|
|
1347
|
+
.filter((line) => !/^total\s/.test(line))
|
|
1348
|
+
.filter((line) => {
|
|
1349
|
+
const name = line.split(/\s+/).slice(6).join(' ');
|
|
1350
|
+
return name !== '.' && name !== '..';
|
|
1351
|
+
})
|
|
1352
|
+
.map((line) => {
|
|
1353
|
+
const parts = line.split(/\s+/);
|
|
1354
|
+
const isDir = line.startsWith('d');
|
|
1355
|
+
const isSymlink = line.startsWith('l');
|
|
1356
|
+
const size = Number.parseInt(parts[4] ?? '0', 10);
|
|
1357
|
+
const rawName = parts.slice(6).join(' ');
|
|
1358
|
+
const arrowIndex = rawName.indexOf(' -> ');
|
|
1359
|
+
const name = isSymlink && arrowIndex !== -1 ? rawName.slice(0, arrowIndex) : rawName;
|
|
1360
|
+
const symlinkTarget = isSymlink && arrowIndex !== -1 ? rawName.slice(arrowIndex + 4) : undefined;
|
|
1361
|
+
return {
|
|
1362
|
+
name,
|
|
1363
|
+
type: isDir ? 'directory' : 'file',
|
|
1364
|
+
size: isDir ? undefined : size,
|
|
1365
|
+
...(symlinkTarget ? { symlinkTarget } : {}),
|
|
1366
|
+
};
|
|
1367
|
+
});
|
|
1368
|
+
},
|
|
1369
|
+
async deleteFile(path) {
|
|
1370
|
+
const result = await this.exec(`rm -rf ${shellQuote(path)}`, {
|
|
1371
|
+
timeout: FILE_OP_TIMEOUT_MS,
|
|
1372
|
+
});
|
|
1373
|
+
if (result.exitCode !== 0) {
|
|
1374
|
+
throw new Error(t('codeSandbox.flyio.error.deleteFailed', { path, error: result.stderr }, { defaultValue: `Failed to delete ${path}: ${result.stderr}` }));
|
|
1375
|
+
}
|
|
1376
|
+
},
|
|
1377
|
+
getPreviewUrl(port) {
|
|
1378
|
+
return provider.renderPreviewUrl(app, machineId, port);
|
|
1379
|
+
},
|
|
1380
|
+
/**
|
|
1381
|
+
* NOT SUPPORTED on Fly. See the module `@remarks` — the Machines API has
|
|
1382
|
+
* no filesystem-event channel, and the only alternative (polling through
|
|
1383
|
+
* the rate-limited exec endpoint) would spend the caller's whole API
|
|
1384
|
+
* budget to report changes seconds late. The callback is never invoked;
|
|
1385
|
+
* the returned unsubscribe function is a real no-op.
|
|
1386
|
+
*/
|
|
1387
|
+
onFileChange(_cb) {
|
|
1388
|
+
if (!provider.warnedNoFileWatch) {
|
|
1389
|
+
provider.warnedNoFileWatch = true;
|
|
1390
|
+
logger.warn('onFileChange is not supported by the Fly.io sandbox provider — the Machines API ' +
|
|
1391
|
+
'exposes no filesystem-event channel. The callback will never fire; poll with ' +
|
|
1392
|
+
'readDir/readFile if you need change detection.');
|
|
1393
|
+
}
|
|
1394
|
+
return () => { };
|
|
1395
|
+
},
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* Renders the preview URL for a Machine from the configured template.
|
|
1400
|
+
* @param app - The Fly app name.
|
|
1401
|
+
* @param machineId - The Fly Machine id.
|
|
1402
|
+
* @param port - Port to substitute for `{port}`; defaults to the preview port.
|
|
1403
|
+
* @returns The rendered URL.
|
|
1404
|
+
*/
|
|
1405
|
+
renderPreviewUrl(app, machineId, port) {
|
|
1406
|
+
const template = this.config.previewUrlTemplate ?? 'https://{app}.fly.dev';
|
|
1407
|
+
return template
|
|
1408
|
+
.replace(/\{app\}/g, app)
|
|
1409
|
+
.replace(/\{machineId\}/g, machineId)
|
|
1410
|
+
.replace(/\{port\}/g, String(port ?? this.config.previewPort ?? DEFAULT_PREVIEW_PORT));
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Creates a Fly.io Machines sandbox provider.
|
|
1415
|
+
* @param config - Optional Fly configuration: API token/URL, org slug, app
|
|
1416
|
+
* naming and isolation mode, region, base image, guest sizing, preview
|
|
1417
|
+
* service, and the preview URL template. Every field has an env fallback.
|
|
1418
|
+
* @param client - Injectable Machines API client, for tests.
|
|
1419
|
+
* @param store - Injectable template object store, for tests. Resolved from the
|
|
1420
|
+
* template settings (or their env fallbacks) on first use when omitted.
|
|
1421
|
+
* @returns A `SandboxProvider` backed by Fly Machines.
|
|
1422
|
+
*/
|
|
1423
|
+
export function createProvider(config, client, store) {
|
|
1424
|
+
return new FlyioSandboxProvider(config, client, store);
|
|
1425
|
+
}
|