@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 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;CAClB;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;CA4EnF"}
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
- if (!isRetryableStatus(status) || attempt === attempts)
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`, {
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,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;AAwpDnB;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,MAAM,CAAC,EAAE,WAAW,EACpB,MAAM,CAAC,EAAE,YAAY,EACrB,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,GACzB,eAAe,CAEjB"}
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
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-code-sandbox-flyio",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Fly.io Machines (Firecracker microVM) code sandbox provider",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",