@molecule/api-code-sandbox-flyio 1.0.5 → 1.0.6
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 +99 -0
- 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;
|
|
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;AAwxDnB;;;;;;;;;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,14 @@ 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;
|
|
74
83
|
/** Extra seconds allowed for the probe exec beyond its connection budget. */
|
|
75
84
|
const PROBE_EXEC_HEADROOM_SECONDS = 10;
|
|
76
85
|
/**
|
|
@@ -352,6 +361,52 @@ class FlyioSandboxProvider {
|
|
|
352
361
|
}
|
|
353
362
|
return {};
|
|
354
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* Destroys any Machine already in this project's app before a fresh create.
|
|
366
|
+
*
|
|
367
|
+
* A create that fails after the Machine exists — a mid-scaffold error, a
|
|
368
|
+
* retried queue job — leaves a Machine holding the app's single-attach volume.
|
|
369
|
+
* The next create's `POST /machines` then fails with `412 volume already
|
|
370
|
+
* claimed by machine <id>`, and the project can never recover on its own. The
|
|
371
|
+
* app is one-per-project and holds exactly one sandbox, so any managed Machine
|
|
372
|
+
* still here is a stale partial: destroy it (force covers running/suspended) to
|
|
373
|
+
* free the volume and make create idempotent per project.
|
|
374
|
+
* @param app - The Fly app name.
|
|
375
|
+
*/
|
|
376
|
+
async destroyStaleMachines(app) {
|
|
377
|
+
const managedKey = `${this.metadataPrefix}.managed`;
|
|
378
|
+
let machines = null;
|
|
379
|
+
try {
|
|
380
|
+
machines = await this.client.request(`/apps/${app}/machines`, {
|
|
381
|
+
nullOn: [404],
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
// A listing we cannot read is not proof the app is empty. A hidden Machine
|
|
386
|
+
// would fail the create with 412 regardless, so surface it and fall
|
|
387
|
+
// through rather than pretend the app is clean.
|
|
388
|
+
logger.warn('Could not list Machines before create; a stale Machine may still hold the volume', { app, error });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
for (const machine of Array.isArray(machines) ? machines : []) {
|
|
392
|
+
if (machine.config?.metadata?.[managedKey] !== 'true')
|
|
393
|
+
continue;
|
|
394
|
+
try {
|
|
395
|
+
await this.client.request(`/apps/${app}/machines/${machine.id}?force=true`, {
|
|
396
|
+
method: 'DELETE',
|
|
397
|
+
nullOn: [404],
|
|
398
|
+
});
|
|
399
|
+
logger.info('Destroyed a stale Machine before re-creating the sandbox', {
|
|
400
|
+
app,
|
|
401
|
+
machineId: machine.id,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
logger.warn('Failed to destroy a stale Machine before create; create may fail with ' +
|
|
406
|
+
'412 volume already claimed', { app, machineId: machine.id, error });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
355
410
|
/**
|
|
356
411
|
* Allocates ONE Flycast private address on a target app, for this project's
|
|
357
412
|
* network.
|
|
@@ -662,6 +717,11 @@ class FlyioSandboxProvider {
|
|
|
662
717
|
assertPrivateRoutesForEnv(config.env, this.privateServices());
|
|
663
718
|
const app = this.resolveApp(config.projectId);
|
|
664
719
|
const privateRoutes = await this.ensureApp(app);
|
|
720
|
+
// Clear any stale Machine from a prior failed/partial create before
|
|
721
|
+
// provisioning — otherwise it still holds the app's single-attach volume and
|
|
722
|
+
// `POST /machines` fails with `412 volume already claimed`. Makes create
|
|
723
|
+
// idempotent so a retried queue job (or a user re-clicking Start) recovers.
|
|
724
|
+
await this.destroyStaleMachines(app);
|
|
665
725
|
const metadata = {
|
|
666
726
|
[`${this.metadataPrefix}.projectId`]: config.projectId,
|
|
667
727
|
[`${this.metadataPrefix}.managed`]: 'true',
|
|
@@ -1395,6 +1455,45 @@ class FlyioSandboxProvider {
|
|
|
1395
1455
|
throw new Error(t('codeSandbox.flyio.error.deleteFailed', { path, error: result.stderr }, { defaultValue: `Failed to delete ${path}: ${result.stderr}` }));
|
|
1396
1456
|
}
|
|
1397
1457
|
},
|
|
1458
|
+
/**
|
|
1459
|
+
* Streams a POSIX tar archive into the Machine and extracts it at `path`.
|
|
1460
|
+
*
|
|
1461
|
+
* The Fly Machines exec endpoint is a single request/response with no
|
|
1462
|
+
* stdin, so the archive cannot be piped to `tar` directly. It is buffered,
|
|
1463
|
+
* gzipped (fewer bytes = fewer exec round trips over the base64 channel),
|
|
1464
|
+
* written to a temp file in `WRITE_CHUNK_BASE64`-sized pieces the same way
|
|
1465
|
+
* `writeFile` does, then extracted.
|
|
1466
|
+
*
|
|
1467
|
+
* Ownership and setuid/setgid bits are NOT restored (`--no-same-owner
|
|
1468
|
+
* --no-same-permissions`): the archive is caller/tenant-authored data, and
|
|
1469
|
+
* honoring its mode bits is a privilege-escalation vector in whatever boots
|
|
1470
|
+
* it next — see the {@link Sandbox.importFiles} contract.
|
|
1471
|
+
* @param path - Absolute destination path inside the Machine.
|
|
1472
|
+
* @param archive - POSIX tar byte stream to extract there.
|
|
1473
|
+
*/
|
|
1474
|
+
async importFiles(path, archive) {
|
|
1475
|
+
const parts = [];
|
|
1476
|
+
for await (const chunk of archive)
|
|
1477
|
+
parts.push(Buffer.from(chunk));
|
|
1478
|
+
const base64 = gzipSync(Buffer.concat(parts)).toString('base64');
|
|
1479
|
+
const tmp = `/tmp/mol-import-${machineId}.tgz`;
|
|
1480
|
+
const quotedTmp = shellQuote(tmp);
|
|
1481
|
+
const quotedPath = shellQuote(path);
|
|
1482
|
+
const failIfError = (result, step) => {
|
|
1483
|
+
if (result.exitCode !== 0) {
|
|
1484
|
+
throw new Error(t('codeSandbox.flyio.error.importFailed', { path, step, error: result.stderr }, {
|
|
1485
|
+
defaultValue: `Failed to import files into ${path} (${step}): ${result.stderr}`,
|
|
1486
|
+
}));
|
|
1487
|
+
}
|
|
1488
|
+
};
|
|
1489
|
+
// First chunk truncates the temp file; the rest append. Each chunk is a
|
|
1490
|
+
// multiple of 4 base64 chars so it decodes standalone (see writeFile).
|
|
1491
|
+
failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(0, WRITE_CHUNK_BASE64))} | base64 -d > ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS }), 'write');
|
|
1492
|
+
for (let i = WRITE_CHUNK_BASE64; i < base64.length; i += WRITE_CHUNK_BASE64) {
|
|
1493
|
+
failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(i, i + WRITE_CHUNK_BASE64))} | base64 -d >> ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS }), 'write');
|
|
1494
|
+
}
|
|
1495
|
+
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');
|
|
1496
|
+
},
|
|
1398
1497
|
getPreviewUrl(port) {
|
|
1399
1498
|
return provider.renderPreviewUrl(app, machineId, port);
|
|
1400
1499
|
},
|