@molecule/api-code-sandbox-flyio 1.2.6 → 1.2.7
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 +163 -31
- 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;AAigFnB;;;;;;;;;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,8 +141,9 @@ 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
|
/**
|
|
@@ -365,8 +366,12 @@ class FlyioSandboxProvider {
|
|
|
365
366
|
* is what the throwaway `verifyEgress()` app wants: it never connects to
|
|
366
367
|
* anything but the probe targets, and allocating for an app that is deleted
|
|
367
368
|
* seconds later would leak one address per probe on every target app.
|
|
368
|
-
* @returns
|
|
369
|
-
* Machine's metadata
|
|
369
|
+
* @returns `routes`: target app name → allocated private address, for
|
|
370
|
+
* recording in the Machine's metadata (empty when no private services are
|
|
371
|
+
* declared); `allocated`: the subset of `routes` this call created, which
|
|
372
|
+
* the caller releases when the boot fails and the app is NOT discarded
|
|
373
|
+
* (an app that pre-existed is kept, and with it the addresses it already
|
|
374
|
+
* held — only this call's own allocations are safe to take back).
|
|
370
375
|
*/
|
|
371
376
|
async ensureApp(app, options = {}) {
|
|
372
377
|
const existing = await this.client.request(`/apps/${app}`, { nullOn: [404] });
|
|
@@ -399,9 +404,9 @@ class FlyioSandboxProvider {
|
|
|
399
404
|
await this.assignSharedIpv4(app);
|
|
400
405
|
await this.applyEgressPolicy(app);
|
|
401
406
|
if (options.privateRoutes === false)
|
|
402
|
-
return { routes: {}, created: createdHere };
|
|
407
|
+
return { routes: {}, allocated: {}, created: createdHere };
|
|
403
408
|
return {
|
|
404
|
-
|
|
409
|
+
...(await this.ensurePrivateRoutes(app, Boolean(existing))),
|
|
405
410
|
created: createdHere,
|
|
406
411
|
};
|
|
407
412
|
}
|
|
@@ -450,25 +455,52 @@ class FlyioSandboxProvider {
|
|
|
450
455
|
* allocated. Fly's own `IPAssignment` carries no network, so a listing on the
|
|
451
456
|
* target app cannot answer "does this project already have a route" — the
|
|
452
457
|
* Machine metadata this provider wrote is the only record.
|
|
458
|
+
*
|
|
459
|
+
* A failure part-way down the list RELEASES the addresses this call had
|
|
460
|
+
* already allocated before rethrowing: the caller's own failure path discards
|
|
461
|
+
* the app, which is a different resource on a different app than the addresses
|
|
462
|
+
* (see the allocation loop).
|
|
453
463
|
* @param app - The Fly app name (which is also its 6PN network name).
|
|
454
464
|
* @param appExisted - Whether the app was already there, in which case its
|
|
455
465
|
* Machines may carry addresses allocated by an earlier boot.
|
|
456
|
-
* @returns
|
|
466
|
+
* @returns `routes`: target app name → private address (what the Machine's
|
|
467
|
+
* metadata records); `allocated`: the subset of `routes` THIS call created —
|
|
468
|
+
* the only addresses safe to release when the boot fails after allocation,
|
|
469
|
+
* since the recorded ones belong to Machines an earlier boot may still run.
|
|
457
470
|
*/
|
|
458
471
|
async ensurePrivateRoutes(app, appExisted) {
|
|
459
472
|
const services = this.privateServices();
|
|
460
473
|
const network = this.networkFor(app);
|
|
461
474
|
if (!services?.length || !network)
|
|
462
|
-
return {};
|
|
475
|
+
return { routes: {}, allocated: {} };
|
|
463
476
|
const routes = appExisted ? await this.recordedPrivateRoutes(app) : {};
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
477
|
+
// The addresses THIS call allocated, tracked apart from `routes` so a
|
|
478
|
+
// failure part-way down the next list can release exactly them. The caller's
|
|
479
|
+
// failure path can only discard the APP (`discardCreatedApp(app, {})`), and
|
|
480
|
+
// these addresses live on OTHER apps — so without this, an address already
|
|
481
|
+
// allocated for an earlier service stays behind on its target app, inside a
|
|
482
|
+
// 6PN whose app is gone, and Fly's listing does not report which network an
|
|
483
|
+
// address belongs to: nothing could ever find it there to release it.
|
|
484
|
+
const allocated = {};
|
|
485
|
+
try {
|
|
486
|
+
for (const service of services) {
|
|
487
|
+
if (routes[service.app])
|
|
488
|
+
continue;
|
|
489
|
+
const ip = await this.allocatePrivateAddress(service, network);
|
|
490
|
+
if (ip) {
|
|
491
|
+
routes[service.app] = ip;
|
|
492
|
+
allocated[service.app] = ip;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
// Only what this call allocated. An address recorded by an EARLIER boot
|
|
498
|
+
// (`appExisted`) is left alone: the app survived this failure and may
|
|
499
|
+
// still hold a Machine reaching through that route.
|
|
500
|
+
await this.releasePrivateRoutes(allocated);
|
|
501
|
+
throw error;
|
|
470
502
|
}
|
|
471
|
-
return routes;
|
|
503
|
+
return { routes, allocated };
|
|
472
504
|
}
|
|
473
505
|
/**
|
|
474
506
|
* Reads the private addresses an earlier boot recorded on this app's Machines.
|
|
@@ -927,13 +959,26 @@ class FlyioSandboxProvider {
|
|
|
927
959
|
// leave in a Machine config.
|
|
928
960
|
assertPrivateRoutesForEnv({ ...config.env, ...config.selfDeliveredEnv }, this.privateServices());
|
|
929
961
|
const app = this.resolveApp(config.projectId);
|
|
930
|
-
const { routes: privateRoutes, created } = await this.ensureApp(app);
|
|
962
|
+
const { routes: privateRoutes, allocated, created } = await this.ensureApp(app);
|
|
931
963
|
try {
|
|
932
964
|
return await this.createInApp(app, privateRoutes, config, manifest);
|
|
933
965
|
}
|
|
934
966
|
catch (error) {
|
|
935
|
-
if (created && this.appPerProject())
|
|
967
|
+
if (created && this.appPerProject()) {
|
|
968
|
+
// The whole app goes, addresses included — discardCreatedApp releases
|
|
969
|
+
// every route it was handed (a created app holds only this call's).
|
|
936
970
|
await this.discardCreatedApp(app, privateRoutes);
|
|
971
|
+
}
|
|
972
|
+
else {
|
|
973
|
+
// The app SURVIVES this failure (it pre-existed, or it is the shared
|
|
974
|
+
// app), so the addresses this call allocated would otherwise stay
|
|
975
|
+
// behind on their target apps with nothing recording them: the
|
|
976
|
+
// Machine that would have carried the metadata was never created, and
|
|
977
|
+
// Fly's listing cannot say which network an address belongs to. Take
|
|
978
|
+
// back exactly this call's own allocations — never the routes an
|
|
979
|
+
// earlier boot recorded, whose Machines may still be running.
|
|
980
|
+
await this.releasePrivateRoutes(allocated);
|
|
981
|
+
}
|
|
937
982
|
throw error;
|
|
938
983
|
}
|
|
939
984
|
}
|
|
@@ -1038,7 +1083,35 @@ class FlyioSandboxProvider {
|
|
|
1038
1083
|
// `412 failed_precondition: machine not running` until the Machine is up.
|
|
1039
1084
|
// Wait for `started` here so `create` always hands back a live sandbox, as
|
|
1040
1085
|
// the JSDoc promises — the template path already does this via restore.
|
|
1041
|
-
|
|
1086
|
+
try {
|
|
1087
|
+
await this.waitForStarted(app, machine.id);
|
|
1088
|
+
}
|
|
1089
|
+
catch (error) {
|
|
1090
|
+
// The boot failed after the Machine existed. This call created it and
|
|
1091
|
+
// handed it to NOBODY — the caller got a rejection, not a handle — so
|
|
1092
|
+
// leaving it behind strands state nothing else ever cleans up: a Machine
|
|
1093
|
+
// that reached `started` bills compute until it is reaped, and one that
|
|
1094
|
+
// did not keeps the app's single-attach volume claimed (the next
|
|
1095
|
+
// create's stale-Machine sweep only runs if this project is retried).
|
|
1096
|
+
// The created-app failure path below cannot do this either: it deletes
|
|
1097
|
+
// the APP, and `discardCreatedApp` deliberately has no Machine-delete
|
|
1098
|
+
// step — an app delete that runs under a live Machine either fails or
|
|
1099
|
+
// hides the Machine. Destroy the Machine first (the same order
|
|
1100
|
+
// `restoreTemplate`'s failure path and `destroy()` use), then rethrow
|
|
1101
|
+
// the boot failure. Best-effort: the boot failure is what the caller
|
|
1102
|
+
// must see; a failed cleanup is logged like every other cleanup here.
|
|
1103
|
+
try {
|
|
1104
|
+
await this.client.request(`/apps/${app}/machines/${machine.id}?force=true`, {
|
|
1105
|
+
method: 'DELETE',
|
|
1106
|
+
nullOn: [404],
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
catch (cleanupError) {
|
|
1110
|
+
logger.warn('Failed to destroy the Fly Machine whose boot did not reach started — it will keep ' +
|
|
1111
|
+
'billing (and holding its volume) until it is reaped', { app, machineId: machine.id, error: cleanupError });
|
|
1112
|
+
}
|
|
1113
|
+
throw error;
|
|
1114
|
+
}
|
|
1042
1115
|
return this.buildSandbox(app, machine.id, 'running');
|
|
1043
1116
|
}
|
|
1044
1117
|
/**
|
|
@@ -1097,6 +1170,14 @@ class FlyioSandboxProvider {
|
|
|
1097
1170
|
* In per-project mode the Machines live in many apps, so this pages through
|
|
1098
1171
|
* `GET /v1/orgs/{org}/machines` and keeps the ones carrying this provider's
|
|
1099
1172
|
* `managed` metadata. In shared-app mode it lists that app directly.
|
|
1173
|
+
*
|
|
1174
|
+
* Only the APP-scoped 404 is an absence (`nullOn: [404]` on
|
|
1175
|
+
* `/apps/{app}/machines` — the app is gone, so its Machines are too). A 404
|
|
1176
|
+
* from the ORG-scoped listing is not: an org with no Machines answers 200
|
|
1177
|
+
* with an empty list, so a 404 there means the org is unknown to the token —
|
|
1178
|
+
* a failed query. Swallowing it as `[]` would tell a reaper the fleet is
|
|
1179
|
+
* clean when nothing had been looked at, the same conflation
|
|
1180
|
+
* `appsToEnumerate()` refuses for the org's app listing.
|
|
1100
1181
|
* @param _userId - Reserved for future per-user filtering; currently ignored,
|
|
1101
1182
|
* matching the Docker provider.
|
|
1102
1183
|
* @returns Every managed sandbox.
|
|
@@ -1118,7 +1199,7 @@ class FlyioSandboxProvider {
|
|
|
1118
1199
|
const query = new URLSearchParams({ limit: String(LIST_PAGE_SIZE) });
|
|
1119
1200
|
if (cursor)
|
|
1120
1201
|
query.set('cursor', cursor);
|
|
1121
|
-
const response = await this.client.request(`/orgs/${this.orgSlug()}/machines?${query.toString()}
|
|
1202
|
+
const response = await this.client.request(`/orgs/${this.orgSlug()}/machines?${query.toString()}`);
|
|
1122
1203
|
for (const machine of response?.machines ?? []) {
|
|
1123
1204
|
if (machine.config?.metadata?.[managedKey] !== 'true')
|
|
1124
1205
|
continue;
|
|
@@ -1964,8 +2045,17 @@ class FlyioSandboxProvider {
|
|
|
1964
2045
|
const store = provider.templateContext().store;
|
|
1965
2046
|
if (store) {
|
|
1966
2047
|
const key = `${store.prefix}/.imports/${machineId}-${Date.now().toString(36)}${Math.floor(Math.random() * 1e9).toString(36)}.tar.gz`;
|
|
2048
|
+
// Whether the uploaded object may still exist. Every exit from this
|
|
2049
|
+
// block removes it — not just the success one: the upload is the
|
|
2050
|
+
// SECOND write of this import, and a throw after it (a failing
|
|
2051
|
+
// `presignGet`, an exec that times out or loses the connection) used to
|
|
2052
|
+
// fall straight into the chunked fallback below with a multi-MB object
|
|
2053
|
+
// left in the bucket. The presigned URL expires on its own; the OBJECT
|
|
2054
|
+
// does not, so "it expires anyway" was only ever true of the URL.
|
|
2055
|
+
let uploaded = false;
|
|
1967
2056
|
try {
|
|
1968
2057
|
const putUrl = await store.presignPut(key, IMPORT_PRESIGN_EXPIRY_SECONDS);
|
|
2058
|
+
uploaded = true;
|
|
1969
2059
|
const putRes = await fetch(putUrl, { method: 'PUT', body: gz });
|
|
1970
2060
|
// The PUT's body is never read (a presigned store answers an empty
|
|
1971
2061
|
// 2xx or a short error document): release it on EVERY path, or the
|
|
@@ -1983,25 +2073,47 @@ class FlyioSandboxProvider {
|
|
|
1983
2073
|
'set -e',
|
|
1984
2074
|
`mkdir -p ${quotedPath}`,
|
|
1985
2075
|
'archive=$(mktemp /tmp/mol-import-XXXXXX.tar.gz)',
|
|
1986
|
-
|
|
1987
|
-
//
|
|
1988
|
-
//
|
|
1989
|
-
//
|
|
1990
|
-
//
|
|
1991
|
-
//
|
|
2076
|
+
// The archive is removed whether the DOWNLOAD or the extract
|
|
2077
|
+
// SUCCEEDS or fails: under `set -e`, a bare `curl`/`tar` line
|
|
2078
|
+
// aborted the script before any cleanup — a failed curl (an
|
|
2079
|
+
// expired presign, a reset mid-body) left its partial download
|
|
2080
|
+
// in the sandbox's /tmp until the Machine died, exactly like
|
|
2081
|
+
// the failed extracts `&& rm` used to leave. The `|| { }` guard
|
|
2082
|
+
// keeps `set -e` from aborting before the cleanup and re-raises
|
|
2083
|
+
// the real exit code.
|
|
2084
|
+
`curl --fail-with-body -sS --retry 3 --retry-connrefused -o "$archive" ${shellQuote(getUrl)} || { rc=$?; rm -f "$archive"; exit $rc; }`,
|
|
1992
2085
|
`tar -C ${quotedPath} --no-same-owner --no-same-permissions -xzf "$archive" || { rc=$?; rm -f "$archive"; exit $rc; }`,
|
|
1993
2086
|
'rm -f "$archive"',
|
|
1994
2087
|
].join('\n'), { timeout: IMPORT_EXTRACT_TIMEOUT_MS });
|
|
1995
|
-
// Best-effort
|
|
2088
|
+
// Best-effort, but a failure is not silent: only the presigned URL
|
|
2089
|
+
// expires on its own — the OBJECT does not (see the comment above
|
|
2090
|
+
// the `uploaded` flag), so a failed removal here strands the
|
|
2091
|
+
// archive in the bucket exactly like the failure path below does,
|
|
2092
|
+
// and is logged at the same level so it is findable. Cleared
|
|
2093
|
+
// first: past this point the removal is under way, so the catch
|
|
2094
|
+
// below must not queue a second one for the same key (a non-zero
|
|
2095
|
+
// extract exit still throws from `failIfError`).
|
|
2096
|
+
uploaded = false;
|
|
1996
2097
|
store.remove([key]).catch((error) => {
|
|
1997
|
-
logger.
|
|
2098
|
+
logger.warn('importFiles temp object cleanup failed — it stays in the object store', {
|
|
2099
|
+
key,
|
|
2100
|
+
error,
|
|
2101
|
+
});
|
|
1998
2102
|
});
|
|
1999
2103
|
failIfError(result, 'download');
|
|
2000
2104
|
return;
|
|
2001
2105
|
}
|
|
2002
2106
|
catch (error) {
|
|
2003
2107
|
// The store path is unavailable or failed — fall through to the
|
|
2004
|
-
// chunked-exec transport rather than failing the whole import.
|
|
2108
|
+
// chunked-exec transport rather than failing the whole import. Any
|
|
2109
|
+
// object this attempt uploaded goes first: the fallback re-imports
|
|
2110
|
+
// the same archive under a DIFFERENT key on the workspace volume, so
|
|
2111
|
+
// nothing else would ever remove this one.
|
|
2112
|
+
if (uploaded) {
|
|
2113
|
+
await store.remove([key]).catch((cleanupError) => {
|
|
2114
|
+
logger.warn('Could not remove the uploaded import archive after a failed import — it stays in the object store', { key, error: cleanupError });
|
|
2115
|
+
});
|
|
2116
|
+
}
|
|
2005
2117
|
logger.warn('importFiles via object store failed; falling back to chunked exec', {
|
|
2006
2118
|
error,
|
|
2007
2119
|
});
|
|
@@ -2018,9 +2130,29 @@ class FlyioSandboxProvider {
|
|
|
2018
2130
|
const base64 = gz.toString('base64');
|
|
2019
2131
|
const tmp = `${WORKSPACE_PATH}/.mol-import-${machineId}-${Date.now().toString(36)}${Math.floor(Math.random() * 1e9).toString(36)}.tgz`;
|
|
2020
2132
|
const quotedTmp = shellQuote(tmp);
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2133
|
+
// A failed WRITE step leaves a partial archive behind on the durable
|
|
2134
|
+
// workspace volume — the chunked transport is the fallback for a
|
|
2135
|
+
// multi-MB archive, so a mid-write failure (a 429, a full disk) used to
|
|
2136
|
+
// strand every byte written so far until the Machine died. Removed
|
|
2137
|
+
// best-effort on any write failure; the extract step below cleans up
|
|
2138
|
+
// after ITSELF (inside the sandbox), so it needs no outer cleanup.
|
|
2139
|
+
const discardTempArchive = async () => {
|
|
2140
|
+
try {
|
|
2141
|
+
await this.exec(`rm -f ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS });
|
|
2142
|
+
}
|
|
2143
|
+
catch (cleanupError) {
|
|
2144
|
+
logger.warn('Could not remove the temp import archive after a failed import — it stays on the workspace volume', { tmp, error: cleanupError });
|
|
2145
|
+
}
|
|
2146
|
+
};
|
|
2147
|
+
try {
|
|
2148
|
+
failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(0, IMPORT_WRITE_CHUNK_BASE64))} | base64 -d > ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS }), 'write');
|
|
2149
|
+
for (let i = IMPORT_WRITE_CHUNK_BASE64; i < base64.length; i += IMPORT_WRITE_CHUNK_BASE64) {
|
|
2150
|
+
failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(i, i + IMPORT_WRITE_CHUNK_BASE64))} | base64 -d >> ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS }), 'write');
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
catch (error) {
|
|
2154
|
+
await discardTempArchive();
|
|
2155
|
+
throw error;
|
|
2024
2156
|
}
|
|
2025
2157
|
failIfError(await this.exec(
|
|
2026
2158
|
// The temp archive is removed whether the extract SUCCEEDS or fails:
|