@molecule/api-code-sandbox-flyio 1.0.5 → 1.0.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/api.d.ts +7 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +2 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +113 -0
- package/package.json +1 -1
package/dist/api.d.ts
CHANGED
|
@@ -57,6 +57,13 @@ export interface FlyRequestOptions {
|
|
|
57
57
|
* Used for idempotent deletes and existence checks (`[404]`).
|
|
58
58
|
*/
|
|
59
59
|
nullOn?: number[];
|
|
60
|
+
/**
|
|
61
|
+
* Extra HTTP statuses to treat as transient (retry) beyond the default
|
|
62
|
+
* `429`/`5xx`. The exec endpoint passes `[404]`: a machine-not-found for a
|
|
63
|
+
* Machine we just created and are actively driving is Fly API inconsistency
|
|
64
|
+
* under exec-burst load, not a real absence, so it is worth repeating.
|
|
65
|
+
*/
|
|
66
|
+
retryStatuses?: number[];
|
|
60
67
|
}
|
|
61
68
|
/**
|
|
62
69
|
* Decides whether a failed attempt is worth repeating.
|
package/dist/api.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAgBH;;;;GAIG;AACH,qBAAa,WAAY,SAAQ,KAAK;IACpC,kFAAkF;IAClF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,sEAAsE;IACtE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,yCAAyC;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IAErB;;;;;;;OAOG;gBAED,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAgBhC;AAED,4CAA4C;AAC5C,MAAM,WAAW,iBAAiB;IAChC,sCAAsC;IACtC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,0DAA0D;IAC1D,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,8EAA8E;IAC9E,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAgBH;;;;GAIG;AACH,qBAAa,WAAY,SAAQ,KAAK;IACpC,kFAAkF;IAClF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,sEAAsE;IACtE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,yCAAyC;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IAErB;;;;;;;OAOG;gBAED,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAgBhC;AAED,4CAA4C;AAC5C,MAAM,WAAW,iBAAiB;IAChC,sCAAsC;IACtC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,0DAA0D;IAC1D,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,8EAA8E;IAC9E,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;IACjB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;CACzB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAQhF;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAoBjE;AAED,oDAAoD;AACpD,MAAM,WAAW,mBAAmB;IAClC,gFAAgF;IAChF,KAAK,EAAE,MAAM,MAAM,GAAG,SAAS,CAAA;IAC/B,gCAAgC;IAChC,OAAO,EAAE,MAAM,CAAA;IACf,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;IACxB,6DAA6D;IAC7D,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CACtC;AAED;;;;;;GAMG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA0B;IAChD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAQ;IAChC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAQ;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAc;IACxC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA+B;IAErD;;;OAGG;gBACS,OAAO,EAAE,mBAAmB;IASxC;;;;;;;;;;OAUG;IACG,OAAO,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;CA8EnF"}
|
package/dist/api.js
CHANGED
|
@@ -210,7 +210,8 @@ export class FlyApiClient {
|
|
|
210
210
|
finally {
|
|
211
211
|
clearTimeout(timer);
|
|
212
212
|
}
|
|
213
|
-
|
|
213
|
+
const retryable = isRetryableStatus(status) || (options.retryStatuses?.includes(status) ?? false);
|
|
214
|
+
if (!retryable || attempt === attempts)
|
|
214
215
|
break;
|
|
215
216
|
const delay = retryDelayMs(attempt, retryAfter);
|
|
216
217
|
logger.warn(`Fly API transient failure on ${method} ${path} — retrying`, {
|
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;
|
|
1
|
+
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,KAAK,EAUV,eAAe,EAEhB,MAAM,4BAA4B,CAAA;AAGnC,OAAO,EAAE,YAAY,EAAgC,MAAM,UAAU,CAAA;AAsBrE,OAAO,EAAmD,KAAK,WAAW,EAAE,MAAM,cAAc,CAAA;AAEhG,OAAO,KAAK,EAEV,WAAW,EAUZ,MAAM,YAAY,CAAA;AAuyDnB;;;;;;;;;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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
|
+
import { gzipSync } from 'node:zlib';
|
|
10
11
|
import { getLogger } from '@molecule/api-bond';
|
|
11
12
|
import { t } from '@molecule/api-i18n';
|
|
12
13
|
import { FlyApiClient, FlyApiError, normalizeApiUrl } from './api.js';
|
|
@@ -71,6 +72,24 @@ const PROBE_MEMORY_MB = 256;
|
|
|
71
72
|
const PROBE_LIFETIME_SECONDS = 300;
|
|
72
73
|
/** Default per-connection timeout for the egress probe, in ms. */
|
|
73
74
|
const DEFAULT_PROBE_TIMEOUT_MS = 3000;
|
|
75
|
+
/**
|
|
76
|
+
* Timeout for the `tar -xzf` step of {@link Sandbox.importFiles}. Extraction is
|
|
77
|
+
* I/O-bound and fast even for a few-hundred-MB tree, so this stays within the
|
|
78
|
+
* direct-exec budget (`DIRECT_EXEC_BUDGET_SECONDS`, 55 s) rather than tipping
|
|
79
|
+
* into the detached poll path, which exists for genuinely long-running commands
|
|
80
|
+
* like `npm install`, not for un-tarring.
|
|
81
|
+
*/
|
|
82
|
+
const IMPORT_EXTRACT_TIMEOUT_MS = 55_000;
|
|
83
|
+
/**
|
|
84
|
+
* Base64 chunk size for {@link Sandbox.importFiles}'s temp-file write. Larger
|
|
85
|
+
* than `WRITE_CHUNK_BASE64` (which is tuned for small single files) so a
|
|
86
|
+
* multi-megabyte scaffold moves in fewer exec calls — each exec spends Fly's
|
|
87
|
+
* per-action rate-limit budget, and a burst of small chunks trips 429s (and, on
|
|
88
|
+
* a busy API, transient 404s). Kept well under the 128 KB `MAX_ARG_STRLEN`
|
|
89
|
+
* ceiling once the `printf … | base64 -d` wrapper and `buildScript` prefix are
|
|
90
|
+
* added.
|
|
91
|
+
*/
|
|
92
|
+
const IMPORT_WRITE_CHUNK_BASE64 = 100_000;
|
|
74
93
|
/** Extra seconds allowed for the probe exec beyond its connection budget. */
|
|
75
94
|
const PROBE_EXEC_HEADROOM_SECONDS = 10;
|
|
76
95
|
/**
|
|
@@ -352,6 +371,52 @@ class FlyioSandboxProvider {
|
|
|
352
371
|
}
|
|
353
372
|
return {};
|
|
354
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* Destroys any Machine already in this project's app before a fresh create.
|
|
376
|
+
*
|
|
377
|
+
* A create that fails after the Machine exists — a mid-scaffold error, a
|
|
378
|
+
* retried queue job — leaves a Machine holding the app's single-attach volume.
|
|
379
|
+
* The next create's `POST /machines` then fails with `412 volume already
|
|
380
|
+
* claimed by machine <id>`, and the project can never recover on its own. The
|
|
381
|
+
* app is one-per-project and holds exactly one sandbox, so any managed Machine
|
|
382
|
+
* still here is a stale partial: destroy it (force covers running/suspended) to
|
|
383
|
+
* free the volume and make create idempotent per project.
|
|
384
|
+
* @param app - The Fly app name.
|
|
385
|
+
*/
|
|
386
|
+
async destroyStaleMachines(app) {
|
|
387
|
+
const managedKey = `${this.metadataPrefix}.managed`;
|
|
388
|
+
let machines = null;
|
|
389
|
+
try {
|
|
390
|
+
machines = await this.client.request(`/apps/${app}/machines`, {
|
|
391
|
+
nullOn: [404],
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
// A listing we cannot read is not proof the app is empty. A hidden Machine
|
|
396
|
+
// would fail the create with 412 regardless, so surface it and fall
|
|
397
|
+
// through rather than pretend the app is clean.
|
|
398
|
+
logger.warn('Could not list Machines before create; a stale Machine may still hold the volume', { app, error });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
for (const machine of Array.isArray(machines) ? machines : []) {
|
|
402
|
+
if (machine.config?.metadata?.[managedKey] !== 'true')
|
|
403
|
+
continue;
|
|
404
|
+
try {
|
|
405
|
+
await this.client.request(`/apps/${app}/machines/${machine.id}?force=true`, {
|
|
406
|
+
method: 'DELETE',
|
|
407
|
+
nullOn: [404],
|
|
408
|
+
});
|
|
409
|
+
logger.info('Destroyed a stale Machine before re-creating the sandbox', {
|
|
410
|
+
app,
|
|
411
|
+
machineId: machine.id,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
logger.warn('Failed to destroy a stale Machine before create; create may fail with ' +
|
|
416
|
+
'412 volume already claimed', { app, machineId: machine.id, error });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
355
420
|
/**
|
|
356
421
|
* Allocates ONE Flycast private address on a target app, for this project's
|
|
357
422
|
* network.
|
|
@@ -662,6 +727,11 @@ class FlyioSandboxProvider {
|
|
|
662
727
|
assertPrivateRoutesForEnv(config.env, this.privateServices());
|
|
663
728
|
const app = this.resolveApp(config.projectId);
|
|
664
729
|
const privateRoutes = await this.ensureApp(app);
|
|
730
|
+
// Clear any stale Machine from a prior failed/partial create before
|
|
731
|
+
// provisioning — otherwise it still holds the app's single-attach volume and
|
|
732
|
+
// `POST /machines` fails with `412 volume already claimed`. Makes create
|
|
733
|
+
// idempotent so a retried queue job (or a user re-clicking Start) recovers.
|
|
734
|
+
await this.destroyStaleMachines(app);
|
|
665
735
|
const metadata = {
|
|
666
736
|
[`${this.metadataPrefix}.projectId`]: config.projectId,
|
|
667
737
|
[`${this.metadataPrefix}.managed`]: 'true',
|
|
@@ -1208,6 +1278,10 @@ class FlyioSandboxProvider {
|
|
|
1208
1278
|
// The API's own timeout bounds the command; allow the HTTP request a
|
|
1209
1279
|
// little longer so the transport never fires first.
|
|
1210
1280
|
timeoutMs: (timeoutSeconds + 15) * 1000,
|
|
1281
|
+
// A 404 on a Machine we created and are actively driving (e.g. the
|
|
1282
|
+
// exec burst importFiles issues) is Fly API inconsistency under load,
|
|
1283
|
+
// not a real absence — retry it alongside the default 429/5xx.
|
|
1284
|
+
retryStatuses: [404],
|
|
1211
1285
|
});
|
|
1212
1286
|
return response ?? {};
|
|
1213
1287
|
};
|
|
@@ -1395,6 +1469,45 @@ class FlyioSandboxProvider {
|
|
|
1395
1469
|
throw new Error(t('codeSandbox.flyio.error.deleteFailed', { path, error: result.stderr }, { defaultValue: `Failed to delete ${path}: ${result.stderr}` }));
|
|
1396
1470
|
}
|
|
1397
1471
|
},
|
|
1472
|
+
/**
|
|
1473
|
+
* Streams a POSIX tar archive into the Machine and extracts it at `path`.
|
|
1474
|
+
*
|
|
1475
|
+
* The Fly Machines exec endpoint is a single request/response with no
|
|
1476
|
+
* stdin, so the archive cannot be piped to `tar` directly. It is buffered,
|
|
1477
|
+
* gzipped (fewer bytes = fewer exec round trips over the base64 channel),
|
|
1478
|
+
* written to a temp file in `WRITE_CHUNK_BASE64`-sized pieces the same way
|
|
1479
|
+
* `writeFile` does, then extracted.
|
|
1480
|
+
*
|
|
1481
|
+
* Ownership and setuid/setgid bits are NOT restored (`--no-same-owner
|
|
1482
|
+
* --no-same-permissions`): the archive is caller/tenant-authored data, and
|
|
1483
|
+
* honoring its mode bits is a privilege-escalation vector in whatever boots
|
|
1484
|
+
* it next — see the {@link Sandbox.importFiles} contract.
|
|
1485
|
+
* @param path - Absolute destination path inside the Machine.
|
|
1486
|
+
* @param archive - POSIX tar byte stream to extract there.
|
|
1487
|
+
*/
|
|
1488
|
+
async importFiles(path, archive) {
|
|
1489
|
+
const parts = [];
|
|
1490
|
+
for await (const chunk of archive)
|
|
1491
|
+
parts.push(Buffer.from(chunk));
|
|
1492
|
+
const base64 = gzipSync(Buffer.concat(parts)).toString('base64');
|
|
1493
|
+
const tmp = `/tmp/mol-import-${machineId}.tgz`;
|
|
1494
|
+
const quotedTmp = shellQuote(tmp);
|
|
1495
|
+
const quotedPath = shellQuote(path);
|
|
1496
|
+
const failIfError = (result, step) => {
|
|
1497
|
+
if (result.exitCode !== 0) {
|
|
1498
|
+
throw new Error(t('codeSandbox.flyio.error.importFailed', { path, step, error: result.stderr }, {
|
|
1499
|
+
defaultValue: `Failed to import files into ${path} (${step}): ${result.stderr}`,
|
|
1500
|
+
}));
|
|
1501
|
+
}
|
|
1502
|
+
};
|
|
1503
|
+
// First chunk truncates the temp file; the rest append. Each chunk is a
|
|
1504
|
+
// multiple of 4 base64 chars so it decodes standalone (see writeFile).
|
|
1505
|
+
failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(0, IMPORT_WRITE_CHUNK_BASE64))} | base64 -d > ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS }), 'write');
|
|
1506
|
+
for (let i = IMPORT_WRITE_CHUNK_BASE64; i < base64.length; i += IMPORT_WRITE_CHUNK_BASE64) {
|
|
1507
|
+
failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(i, i + IMPORT_WRITE_CHUNK_BASE64))} | base64 -d >> ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS }), 'write');
|
|
1508
|
+
}
|
|
1509
|
+
failIfError(await this.exec(`mkdir -p ${quotedPath} && tar -xzf ${quotedTmp} -C ${quotedPath} --no-same-owner --no-same-permissions && rm -f ${quotedTmp}`, { timeout: IMPORT_EXTRACT_TIMEOUT_MS }), 'extract');
|
|
1510
|
+
},
|
|
1398
1511
|
getPreviewUrl(port) {
|
|
1399
1512
|
return provider.renderPreviewUrl(app, machineId, port);
|
|
1400
1513
|
},
|