@molecule/api-code-sandbox-flyio 1.2.6 → 1.2.8
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/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +171 -34
- package/package.json +1 -1
package/dist/provider.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,KAAK,EAWV,eAAe,EAGhB,MAAM,4BAA4B,CAAA;AAGnC,OAAO,EAAE,YAAY,EAAgC,MAAM,UAAU,CAAA;AAwBrE,OAAO,EAAmD,KAAK,WAAW,EAAE,MAAM,cAAc,CAAA;AAEhG,OAAO,KAAK,EAIV,WAAW,EAUZ,MAAM,YAAY,CAAA;
|
|
1
|
+
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,KAAK,EAWV,eAAe,EAGhB,MAAM,4BAA4B,CAAA;AAGnC,OAAO,EAAE,YAAY,EAAgC,MAAM,UAAU,CAAA;AAwBrE,OAAO,EAAmD,KAAK,WAAW,EAAE,MAAM,cAAc,CAAA;AAEhG,OAAO,KAAK,EAIV,WAAW,EAUZ,MAAM,YAAY,CAAA;AAsgFnB;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,MAAM,CAAC,EAAE,WAAW,EACpB,MAAM,CAAC,EAAE,YAAY,EACrB,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,GACzB,eAAe,CAEjB"}
|
package/dist/provider.js
CHANGED
|
@@ -141,17 +141,23 @@ const IMPORT_EXTRACT_TIMEOUT_MS = 55_000;
|
|
|
141
141
|
* sub-APIs asynchronously, so `POST /apps` can return before
|
|
142
142
|
* `POST /apps/{app}/machines` sees the app — which 404s "app not found" for a
|
|
143
143
|
* few seconds. Observed live during boot on 2026-08-08 (surfaced to the user as
|
|
144
|
-
* "Failed to start sandbox"). Six attempts span the client's 1+2+4+8+
|
|
145
|
-
* backoff ≈
|
|
144
|
+
* "Failed to start sandbox"). Six attempts span the client's 1+2+4+8+10 s
|
|
145
|
+
* backoff ≈ 25 s (the fifth step is clamped at the client's 10 s cap),
|
|
146
|
+
* comfortably past the observed window.
|
|
146
147
|
*/
|
|
147
148
|
const MACHINE_CREATE_ATTEMPTS = 6;
|
|
148
149
|
/**
|
|
149
150
|
* Retrying a `POST /machines` on 404 is SAFE precisely because "app not found"
|
|
150
151
|
* means no Machine was created — unlike a 5xx/timeout, where one might have
|
|
151
|
-
* been.
|
|
152
|
-
*
|
|
152
|
+
* been. 412 is safe for the same reason: "volume already claimed by machine
|
|
153
|
+
* <id>" refuses the create. It is what the create answers in the seconds after
|
|
154
|
+
* {@link FlyioSandboxProvider.destroyStaleMachines} removed the Machine that
|
|
155
|
+
* held the volume — Fly releases the claim asynchronously, and an immediate
|
|
156
|
+
* create lost a production deploy to it (2026-10-14; the identical retry a
|
|
157
|
+
* minute later succeeded). Only these two are added on top of the client's
|
|
158
|
+
* default retryable set; neither retry can double-create.
|
|
153
159
|
*/
|
|
154
|
-
const MACHINE_CREATE_RETRY_STATUSES = [404];
|
|
160
|
+
const MACHINE_CREATE_RETRY_STATUSES = [404, 412];
|
|
155
161
|
/**
|
|
156
162
|
* Base64 chunk size for {@link Sandbox.importFiles}'s temp-file write. Bounded by
|
|
157
163
|
* the same Fly exec payload limit as `WRITE_CHUNK_BASE64` (~15 KB, silently
|
|
@@ -365,8 +371,12 @@ class FlyioSandboxProvider {
|
|
|
365
371
|
* is what the throwaway `verifyEgress()` app wants: it never connects to
|
|
366
372
|
* anything but the probe targets, and allocating for an app that is deleted
|
|
367
373
|
* seconds later would leak one address per probe on every target app.
|
|
368
|
-
* @returns
|
|
369
|
-
* Machine's metadata
|
|
374
|
+
* @returns `routes`: target app name → allocated private address, for
|
|
375
|
+
* recording in the Machine's metadata (empty when no private services are
|
|
376
|
+
* declared); `allocated`: the subset of `routes` this call created, which
|
|
377
|
+
* the caller releases when the boot fails and the app is NOT discarded
|
|
378
|
+
* (an app that pre-existed is kept, and with it the addresses it already
|
|
379
|
+
* held — only this call's own allocations are safe to take back).
|
|
370
380
|
*/
|
|
371
381
|
async ensureApp(app, options = {}) {
|
|
372
382
|
const existing = await this.client.request(`/apps/${app}`, { nullOn: [404] });
|
|
@@ -399,9 +409,9 @@ class FlyioSandboxProvider {
|
|
|
399
409
|
await this.assignSharedIpv4(app);
|
|
400
410
|
await this.applyEgressPolicy(app);
|
|
401
411
|
if (options.privateRoutes === false)
|
|
402
|
-
return { routes: {}, created: createdHere };
|
|
412
|
+
return { routes: {}, allocated: {}, created: createdHere };
|
|
403
413
|
return {
|
|
404
|
-
|
|
414
|
+
...(await this.ensurePrivateRoutes(app, Boolean(existing))),
|
|
405
415
|
created: createdHere,
|
|
406
416
|
};
|
|
407
417
|
}
|
|
@@ -450,25 +460,52 @@ class FlyioSandboxProvider {
|
|
|
450
460
|
* allocated. Fly's own `IPAssignment` carries no network, so a listing on the
|
|
451
461
|
* target app cannot answer "does this project already have a route" — the
|
|
452
462
|
* Machine metadata this provider wrote is the only record.
|
|
463
|
+
*
|
|
464
|
+
* A failure part-way down the list RELEASES the addresses this call had
|
|
465
|
+
* already allocated before rethrowing: the caller's own failure path discards
|
|
466
|
+
* the app, which is a different resource on a different app than the addresses
|
|
467
|
+
* (see the allocation loop).
|
|
453
468
|
* @param app - The Fly app name (which is also its 6PN network name).
|
|
454
469
|
* @param appExisted - Whether the app was already there, in which case its
|
|
455
470
|
* Machines may carry addresses allocated by an earlier boot.
|
|
456
|
-
* @returns
|
|
471
|
+
* @returns `routes`: target app name → private address (what the Machine's
|
|
472
|
+
* metadata records); `allocated`: the subset of `routes` THIS call created —
|
|
473
|
+
* the only addresses safe to release when the boot fails after allocation,
|
|
474
|
+
* since the recorded ones belong to Machines an earlier boot may still run.
|
|
457
475
|
*/
|
|
458
476
|
async ensurePrivateRoutes(app, appExisted) {
|
|
459
477
|
const services = this.privateServices();
|
|
460
478
|
const network = this.networkFor(app);
|
|
461
479
|
if (!services?.length || !network)
|
|
462
|
-
return {};
|
|
480
|
+
return { routes: {}, allocated: {} };
|
|
463
481
|
const routes = appExisted ? await this.recordedPrivateRoutes(app) : {};
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
482
|
+
// The addresses THIS call allocated, tracked apart from `routes` so a
|
|
483
|
+
// failure part-way down the next list can release exactly them. The caller's
|
|
484
|
+
// failure path can only discard the APP (`discardCreatedApp(app, {})`), and
|
|
485
|
+
// these addresses live on OTHER apps — so without this, an address already
|
|
486
|
+
// allocated for an earlier service stays behind on its target app, inside a
|
|
487
|
+
// 6PN whose app is gone, and Fly's listing does not report which network an
|
|
488
|
+
// address belongs to: nothing could ever find it there to release it.
|
|
489
|
+
const allocated = {};
|
|
490
|
+
try {
|
|
491
|
+
for (const service of services) {
|
|
492
|
+
if (routes[service.app])
|
|
493
|
+
continue;
|
|
494
|
+
const ip = await this.allocatePrivateAddress(service, network);
|
|
495
|
+
if (ip) {
|
|
496
|
+
routes[service.app] = ip;
|
|
497
|
+
allocated[service.app] = ip;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
// Only what this call allocated. An address recorded by an EARLIER boot
|
|
503
|
+
// (`appExisted`) is left alone: the app survived this failure and may
|
|
504
|
+
// still hold a Machine reaching through that route.
|
|
505
|
+
await this.releasePrivateRoutes(allocated);
|
|
506
|
+
throw error;
|
|
470
507
|
}
|
|
471
|
-
return routes;
|
|
508
|
+
return { routes, allocated };
|
|
472
509
|
}
|
|
473
510
|
/**
|
|
474
511
|
* Reads the private addresses an earlier boot recorded on this app's Machines.
|
|
@@ -927,13 +964,26 @@ class FlyioSandboxProvider {
|
|
|
927
964
|
// leave in a Machine config.
|
|
928
965
|
assertPrivateRoutesForEnv({ ...config.env, ...config.selfDeliveredEnv }, this.privateServices());
|
|
929
966
|
const app = this.resolveApp(config.projectId);
|
|
930
|
-
const { routes: privateRoutes, created } = await this.ensureApp(app);
|
|
967
|
+
const { routes: privateRoutes, allocated, created } = await this.ensureApp(app);
|
|
931
968
|
try {
|
|
932
969
|
return await this.createInApp(app, privateRoutes, config, manifest);
|
|
933
970
|
}
|
|
934
971
|
catch (error) {
|
|
935
|
-
if (created && this.appPerProject())
|
|
972
|
+
if (created && this.appPerProject()) {
|
|
973
|
+
// The whole app goes, addresses included — discardCreatedApp releases
|
|
974
|
+
// every route it was handed (a created app holds only this call's).
|
|
936
975
|
await this.discardCreatedApp(app, privateRoutes);
|
|
976
|
+
}
|
|
977
|
+
else {
|
|
978
|
+
// The app SURVIVES this failure (it pre-existed, or it is the shared
|
|
979
|
+
// app), so the addresses this call allocated would otherwise stay
|
|
980
|
+
// behind on their target apps with nothing recording them: the
|
|
981
|
+
// Machine that would have carried the metadata was never created, and
|
|
982
|
+
// Fly's listing cannot say which network an address belongs to. Take
|
|
983
|
+
// back exactly this call's own allocations — never the routes an
|
|
984
|
+
// earlier boot recorded, whose Machines may still be running.
|
|
985
|
+
await this.releasePrivateRoutes(allocated);
|
|
986
|
+
}
|
|
937
987
|
throw error;
|
|
938
988
|
}
|
|
939
989
|
}
|
|
@@ -1038,7 +1088,35 @@ class FlyioSandboxProvider {
|
|
|
1038
1088
|
// `412 failed_precondition: machine not running` until the Machine is up.
|
|
1039
1089
|
// Wait for `started` here so `create` always hands back a live sandbox, as
|
|
1040
1090
|
// the JSDoc promises — the template path already does this via restore.
|
|
1041
|
-
|
|
1091
|
+
try {
|
|
1092
|
+
await this.waitForStarted(app, machine.id);
|
|
1093
|
+
}
|
|
1094
|
+
catch (error) {
|
|
1095
|
+
// The boot failed after the Machine existed. This call created it and
|
|
1096
|
+
// handed it to NOBODY — the caller got a rejection, not a handle — so
|
|
1097
|
+
// leaving it behind strands state nothing else ever cleans up: a Machine
|
|
1098
|
+
// that reached `started` bills compute until it is reaped, and one that
|
|
1099
|
+
// did not keeps the app's single-attach volume claimed (the next
|
|
1100
|
+
// create's stale-Machine sweep only runs if this project is retried).
|
|
1101
|
+
// The created-app failure path below cannot do this either: it deletes
|
|
1102
|
+
// the APP, and `discardCreatedApp` deliberately has no Machine-delete
|
|
1103
|
+
// step — an app delete that runs under a live Machine either fails or
|
|
1104
|
+
// hides the Machine. Destroy the Machine first (the same order
|
|
1105
|
+
// `restoreTemplate`'s failure path and `destroy()` use), then rethrow
|
|
1106
|
+
// the boot failure. Best-effort: the boot failure is what the caller
|
|
1107
|
+
// must see; a failed cleanup is logged like every other cleanup here.
|
|
1108
|
+
try {
|
|
1109
|
+
await this.client.request(`/apps/${app}/machines/${machine.id}?force=true`, {
|
|
1110
|
+
method: 'DELETE',
|
|
1111
|
+
nullOn: [404],
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
catch (cleanupError) {
|
|
1115
|
+
logger.warn('Failed to destroy the Fly Machine whose boot did not reach started — it will keep ' +
|
|
1116
|
+
'billing (and holding its volume) until it is reaped', { app, machineId: machine.id, error: cleanupError });
|
|
1117
|
+
}
|
|
1118
|
+
throw error;
|
|
1119
|
+
}
|
|
1042
1120
|
return this.buildSandbox(app, machine.id, 'running');
|
|
1043
1121
|
}
|
|
1044
1122
|
/**
|
|
@@ -1097,6 +1175,14 @@ class FlyioSandboxProvider {
|
|
|
1097
1175
|
* In per-project mode the Machines live in many apps, so this pages through
|
|
1098
1176
|
* `GET /v1/orgs/{org}/machines` and keeps the ones carrying this provider's
|
|
1099
1177
|
* `managed` metadata. In shared-app mode it lists that app directly.
|
|
1178
|
+
*
|
|
1179
|
+
* Only the APP-scoped 404 is an absence (`nullOn: [404]` on
|
|
1180
|
+
* `/apps/{app}/machines` — the app is gone, so its Machines are too). A 404
|
|
1181
|
+
* from the ORG-scoped listing is not: an org with no Machines answers 200
|
|
1182
|
+
* with an empty list, so a 404 there means the org is unknown to the token —
|
|
1183
|
+
* a failed query. Swallowing it as `[]` would tell a reaper the fleet is
|
|
1184
|
+
* clean when nothing had been looked at, the same conflation
|
|
1185
|
+
* `appsToEnumerate()` refuses for the org's app listing.
|
|
1100
1186
|
* @param _userId - Reserved for future per-user filtering; currently ignored,
|
|
1101
1187
|
* matching the Docker provider.
|
|
1102
1188
|
* @returns Every managed sandbox.
|
|
@@ -1118,7 +1204,7 @@ class FlyioSandboxProvider {
|
|
|
1118
1204
|
const query = new URLSearchParams({ limit: String(LIST_PAGE_SIZE) });
|
|
1119
1205
|
if (cursor)
|
|
1120
1206
|
query.set('cursor', cursor);
|
|
1121
|
-
const response = await this.client.request(`/orgs/${this.orgSlug()}/machines?${query.toString()}
|
|
1207
|
+
const response = await this.client.request(`/orgs/${this.orgSlug()}/machines?${query.toString()}`);
|
|
1122
1208
|
for (const machine of response?.machines ?? []) {
|
|
1123
1209
|
if (machine.config?.metadata?.[managedKey] !== 'true')
|
|
1124
1210
|
continue;
|
|
@@ -1964,8 +2050,17 @@ class FlyioSandboxProvider {
|
|
|
1964
2050
|
const store = provider.templateContext().store;
|
|
1965
2051
|
if (store) {
|
|
1966
2052
|
const key = `${store.prefix}/.imports/${machineId}-${Date.now().toString(36)}${Math.floor(Math.random() * 1e9).toString(36)}.tar.gz`;
|
|
2053
|
+
// Whether the uploaded object may still exist. Every exit from this
|
|
2054
|
+
// block removes it — not just the success one: the upload is the
|
|
2055
|
+
// SECOND write of this import, and a throw after it (a failing
|
|
2056
|
+
// `presignGet`, an exec that times out or loses the connection) used to
|
|
2057
|
+
// fall straight into the chunked fallback below with a multi-MB object
|
|
2058
|
+
// left in the bucket. The presigned URL expires on its own; the OBJECT
|
|
2059
|
+
// does not, so "it expires anyway" was only ever true of the URL.
|
|
2060
|
+
let uploaded = false;
|
|
1967
2061
|
try {
|
|
1968
2062
|
const putUrl = await store.presignPut(key, IMPORT_PRESIGN_EXPIRY_SECONDS);
|
|
2063
|
+
uploaded = true;
|
|
1969
2064
|
const putRes = await fetch(putUrl, { method: 'PUT', body: gz });
|
|
1970
2065
|
// The PUT's body is never read (a presigned store answers an empty
|
|
1971
2066
|
// 2xx or a short error document): release it on EVERY path, or the
|
|
@@ -1983,25 +2078,47 @@ class FlyioSandboxProvider {
|
|
|
1983
2078
|
'set -e',
|
|
1984
2079
|
`mkdir -p ${quotedPath}`,
|
|
1985
2080
|
'archive=$(mktemp /tmp/mol-import-XXXXXX.tar.gz)',
|
|
1986
|
-
|
|
1987
|
-
//
|
|
1988
|
-
//
|
|
1989
|
-
//
|
|
1990
|
-
//
|
|
1991
|
-
//
|
|
2081
|
+
// The archive is removed whether the DOWNLOAD or the extract
|
|
2082
|
+
// SUCCEEDS or fails: under `set -e`, a bare `curl`/`tar` line
|
|
2083
|
+
// aborted the script before any cleanup — a failed curl (an
|
|
2084
|
+
// expired presign, a reset mid-body) left its partial download
|
|
2085
|
+
// in the sandbox's /tmp until the Machine died, exactly like
|
|
2086
|
+
// the failed extracts `&& rm` used to leave. The `|| { }` guard
|
|
2087
|
+
// keeps `set -e` from aborting before the cleanup and re-raises
|
|
2088
|
+
// the real exit code.
|
|
2089
|
+
`curl --fail-with-body -sS --retry 3 --retry-connrefused -o "$archive" ${shellQuote(getUrl)} || { rc=$?; rm -f "$archive"; exit $rc; }`,
|
|
1992
2090
|
`tar -C ${quotedPath} --no-same-owner --no-same-permissions -xzf "$archive" || { rc=$?; rm -f "$archive"; exit $rc; }`,
|
|
1993
2091
|
'rm -f "$archive"',
|
|
1994
2092
|
].join('\n'), { timeout: IMPORT_EXTRACT_TIMEOUT_MS });
|
|
1995
|
-
// Best-effort
|
|
2093
|
+
// Best-effort, but a failure is not silent: only the presigned URL
|
|
2094
|
+
// expires on its own — the OBJECT does not (see the comment above
|
|
2095
|
+
// the `uploaded` flag), so a failed removal here strands the
|
|
2096
|
+
// archive in the bucket exactly like the failure path below does,
|
|
2097
|
+
// and is logged at the same level so it is findable. Cleared
|
|
2098
|
+
// first: past this point the removal is under way, so the catch
|
|
2099
|
+
// below must not queue a second one for the same key (a non-zero
|
|
2100
|
+
// extract exit still throws from `failIfError`).
|
|
2101
|
+
uploaded = false;
|
|
1996
2102
|
store.remove([key]).catch((error) => {
|
|
1997
|
-
logger.
|
|
2103
|
+
logger.warn('importFiles temp object cleanup failed — it stays in the object store', {
|
|
2104
|
+
key,
|
|
2105
|
+
error,
|
|
2106
|
+
});
|
|
1998
2107
|
});
|
|
1999
2108
|
failIfError(result, 'download');
|
|
2000
2109
|
return;
|
|
2001
2110
|
}
|
|
2002
2111
|
catch (error) {
|
|
2003
2112
|
// The store path is unavailable or failed — fall through to the
|
|
2004
|
-
// chunked-exec transport rather than failing the whole import.
|
|
2113
|
+
// chunked-exec transport rather than failing the whole import. Any
|
|
2114
|
+
// object this attempt uploaded goes first: the fallback re-imports
|
|
2115
|
+
// the same archive under a DIFFERENT key on the workspace volume, so
|
|
2116
|
+
// nothing else would ever remove this one.
|
|
2117
|
+
if (uploaded) {
|
|
2118
|
+
await store.remove([key]).catch((cleanupError) => {
|
|
2119
|
+
logger.warn('Could not remove the uploaded import archive after a failed import — it stays in the object store', { key, error: cleanupError });
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2005
2122
|
logger.warn('importFiles via object store failed; falling back to chunked exec', {
|
|
2006
2123
|
error,
|
|
2007
2124
|
});
|
|
@@ -2018,9 +2135,29 @@ class FlyioSandboxProvider {
|
|
|
2018
2135
|
const base64 = gz.toString('base64');
|
|
2019
2136
|
const tmp = `${WORKSPACE_PATH}/.mol-import-${machineId}-${Date.now().toString(36)}${Math.floor(Math.random() * 1e9).toString(36)}.tgz`;
|
|
2020
2137
|
const quotedTmp = shellQuote(tmp);
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2138
|
+
// A failed WRITE step leaves a partial archive behind on the durable
|
|
2139
|
+
// workspace volume — the chunked transport is the fallback for a
|
|
2140
|
+
// multi-MB archive, so a mid-write failure (a 429, a full disk) used to
|
|
2141
|
+
// strand every byte written so far until the Machine died. Removed
|
|
2142
|
+
// best-effort on any write failure; the extract step below cleans up
|
|
2143
|
+
// after ITSELF (inside the sandbox), so it needs no outer cleanup.
|
|
2144
|
+
const discardTempArchive = async () => {
|
|
2145
|
+
try {
|
|
2146
|
+
await this.exec(`rm -f ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS });
|
|
2147
|
+
}
|
|
2148
|
+
catch (cleanupError) {
|
|
2149
|
+
logger.warn('Could not remove the temp import archive after a failed import — it stays on the workspace volume', { tmp, error: cleanupError });
|
|
2150
|
+
}
|
|
2151
|
+
};
|
|
2152
|
+
try {
|
|
2153
|
+
failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(0, IMPORT_WRITE_CHUNK_BASE64))} | base64 -d > ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS }), 'write');
|
|
2154
|
+
for (let i = IMPORT_WRITE_CHUNK_BASE64; i < base64.length; i += IMPORT_WRITE_CHUNK_BASE64) {
|
|
2155
|
+
failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(i, i + IMPORT_WRITE_CHUNK_BASE64))} | base64 -d >> ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS }), 'write');
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
catch (error) {
|
|
2159
|
+
await discardTempArchive();
|
|
2160
|
+
throw error;
|
|
2024
2161
|
}
|
|
2025
2162
|
failIfError(await this.exec(
|
|
2026
2163
|
// The temp archive is removed whether the extract SUCCEEDS or fails:
|