@molecule/api-code-sandbox-flyio 1.0.10 → 1.0.12
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/exec.d.ts +4 -2
- package/dist/exec.d.ts.map +1 -1
- package/dist/exec.js +50 -24
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +56 -12
- package/package.json +1 -1
package/dist/exec.d.ts
CHANGED
|
@@ -83,13 +83,15 @@ export declare function buildScript(command: string, opts: ExecOptions | undefin
|
|
|
83
83
|
* Executes a command on a Machine, choosing the direct or detached strategy
|
|
84
84
|
* based on the caller's time budget.
|
|
85
85
|
*
|
|
86
|
+
* A command too large to pass inline (over {@link FLY_EXEC_MAX_ARG_BYTES}) is
|
|
87
|
+
* spilled to a file via chunked writes and run from there, so there is no size
|
|
88
|
+
* ceiling the caller must respect.
|
|
86
89
|
* @param rawExec - Transport callback issuing one Fly exec call.
|
|
87
90
|
* @param command - The shell command to run.
|
|
88
91
|
* @param opts - Core exec options (cwd, env, timeout in ms).
|
|
89
92
|
* @param defaultCwd - Working directory used when `opts.cwd` is unset.
|
|
90
93
|
* @returns The command's stdout, stderr and exit code.
|
|
91
|
-
* @throws {Error} When
|
|
92
|
-
* {@link MAX_SCRIPT_BASE64_BYTES}), or when the transport itself fails.
|
|
94
|
+
* @throws {Error} When staging a large command or the transport itself fails.
|
|
93
95
|
*/
|
|
94
96
|
export declare function execCommand(rawExec: RawExec, command: string, opts: ExecOptions | undefined, defaultCwd: string): Promise<ExecResult>;
|
|
95
97
|
//# sourceMappingURL=exec.d.ts.map
|
package/dist/exec.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"exec.d.ts","sourceRoot":"","sources":["../src/exec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAA;AAGzE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAKjD;;;;GAIG;AACH,eAAO,MAAM,4BAA4B,KAAK,CAAA;AAE9C;;;;GAIG;AACH,eAAO,MAAM,0BAA0B,KAAK,CAAA;AAW5C;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,QAAkB,CAAA;AAQxD;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,QAAS,CAAA;AAY5C;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,KAAK,CAAA;AAEzC,0FAA0F;AAC1F,MAAM,MAAM,OAAO,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,cAAc,EAAE,MAAM,KAAK,OAAO,CAAC,eAAe,CAAC,CAAA;AAE7F;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,eAAe,GAAG,UAAU,CAiBlE;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,WAAW,GAAG,SAAS,EAC7B,UAAU,EAAE,MAAM,GACjB,MAAM,CAGR;
|
|
1
|
+
{"version":3,"file":"exec.d.ts","sourceRoot":"","sources":["../src/exec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAA;AAGzE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAKjD;;;;GAIG;AACH,eAAO,MAAM,4BAA4B,KAAK,CAAA;AAE9C;;;;GAIG;AACH,eAAO,MAAM,0BAA0B,KAAK,CAAA;AAW5C;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,QAAkB,CAAA;AAQxD;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,QAAS,CAAA;AAY5C;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,KAAK,CAAA;AAEzC,0FAA0F;AAC1F,MAAM,MAAM,OAAO,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,cAAc,EAAE,MAAM,KAAK,OAAO,CAAC,eAAe,CAAC,CAAA;AAE7F;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,eAAe,GAAG,UAAU,CAiBlE;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,WAAW,GAAG,SAAS,EAC7B,UAAU,EAAE,MAAM,GACjB,MAAM,CAGR;AAmDD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,WAAW,CAC/B,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,WAAW,GAAG,SAAS,EAC7B,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,UAAU,CAAC,CAqBrB"}
|
package/dist/exec.js
CHANGED
|
@@ -50,10 +50,10 @@ const DEFAULT_TIMEOUT_MS = 600_000;
|
|
|
50
50
|
*/
|
|
51
51
|
export const MAX_CAPTURED_OUTPUT_BYTES = 5 * 1024 * 1024;
|
|
52
52
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
53
|
+
* Base64 chunk size for spilling a large script to a file, comfortably under
|
|
54
|
+
* {@link FLY_EXEC_MAX_ARG_BYTES} once the `printf … | base64 -d` wrapper is added.
|
|
55
55
|
*/
|
|
56
|
-
const
|
|
56
|
+
const SCRIPT_SPILL_CHUNK_BASE64 = 12_000;
|
|
57
57
|
/**
|
|
58
58
|
* Hard cap on the `sh -c <script>` argument SENT to Fly's exec endpoint. This is
|
|
59
59
|
* NOT the OS `MAX_ARG_STRLEN` (128 KB) — the Fly Machines exec API rejects a
|
|
@@ -128,31 +128,62 @@ function newRunId() {
|
|
|
128
128
|
id += Math.floor(Math.random() * 0xffffffff).toString(16);
|
|
129
129
|
return id.slice(0, 16);
|
|
130
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Writes `script` to `path` on the Machine via chunked base64 execs, each under
|
|
133
|
+
* {@link FLY_EXEC_MAX_ARG_BYTES}.
|
|
134
|
+
*
|
|
135
|
+
* Fly's exec API silently drops a single over-limit `printf %s <base64>`, so a
|
|
136
|
+
* script too large to pass inline (a big `node -e`, a bulk file write) must be
|
|
137
|
+
* delivered in pieces. This is the transport-level equivalent of `docker cp` for
|
|
138
|
+
* a command body — callers never see the limit.
|
|
139
|
+
* @param rawExec - Transport callback issuing one Fly exec call.
|
|
140
|
+
* @param path - Absolute destination path on the Machine.
|
|
141
|
+
* @param script - The script body to stage.
|
|
142
|
+
* @throws {Error} When the work dir or any chunk write fails.
|
|
143
|
+
*/
|
|
144
|
+
async function writeScriptChunked(rawExec, path, script) {
|
|
145
|
+
const base64 = Buffer.from(script, 'utf8').toString('base64');
|
|
146
|
+
const ensure = toExecResult(await rawExec(['sh', '-c', `mkdir -p ${WORK_DIR}`], CONTROL_EXEC_TIMEOUT_SECONDS));
|
|
147
|
+
if (ensure.exitCode !== 0) {
|
|
148
|
+
throw new Error(`Failed to create the Fly exec work dir: ${ensure.stderr || ensure.stdout}`);
|
|
149
|
+
}
|
|
150
|
+
for (let i = 0; i < base64.length; i += SCRIPT_SPILL_CHUNK_BASE64) {
|
|
151
|
+
const op = i === 0 ? '>' : '>>';
|
|
152
|
+
const chunk = base64.slice(i, i + SCRIPT_SPILL_CHUNK_BASE64);
|
|
153
|
+
const written = toExecResult(await rawExec(['sh', '-c', `printf %s ${shellQuote(chunk)} | base64 -d ${op} ${path}`], CONTROL_EXEC_TIMEOUT_SECONDS));
|
|
154
|
+
if (written.exitCode !== 0) {
|
|
155
|
+
throw new Error(`Failed to stage a Fly exec script chunk: ${written.stderr || written.stdout}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
131
159
|
/**
|
|
132
160
|
* Executes a command on a Machine, choosing the direct or detached strategy
|
|
133
161
|
* based on the caller's time budget.
|
|
134
162
|
*
|
|
163
|
+
* A command too large to pass inline (over {@link FLY_EXEC_MAX_ARG_BYTES}) is
|
|
164
|
+
* spilled to a file via chunked writes and run from there, so there is no size
|
|
165
|
+
* ceiling the caller must respect.
|
|
135
166
|
* @param rawExec - Transport callback issuing one Fly exec call.
|
|
136
167
|
* @param command - The shell command to run.
|
|
137
168
|
* @param opts - Core exec options (cwd, env, timeout in ms).
|
|
138
169
|
* @param defaultCwd - Working directory used when `opts.cwd` is unset.
|
|
139
170
|
* @returns The command's stdout, stderr and exit code.
|
|
140
|
-
* @throws {Error} When
|
|
141
|
-
* {@link MAX_SCRIPT_BASE64_BYTES}), or when the transport itself fails.
|
|
171
|
+
* @throws {Error} When staging a large command or the transport itself fails.
|
|
142
172
|
*/
|
|
143
173
|
export async function execCommand(rawExec, command, opts, defaultCwd) {
|
|
144
174
|
const budgetMs = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
145
175
|
const script = buildScript(command, opts, defaultCwd);
|
|
146
|
-
// A direct command travels as the `sh -c <script>` argument. Fly rejects one
|
|
147
|
-
// over FLY_EXEC_MAX_ARG_BYTES with a silent `exit_code: 0` (see the constant),
|
|
148
|
-
// so refuse it here with a message that names the real limit rather than
|
|
149
|
-
// letting the caller's data vanish. Bulk callers chunk beneath this.
|
|
150
|
-
if (script.length > FLY_EXEC_MAX_ARG_BYTES) {
|
|
151
|
-
throw new Error(`Fly exec command is ${script.length} bytes, over the ${FLY_EXEC_MAX_ARG_BYTES}-byte ` +
|
|
152
|
-
`limit the API silently rejects — chunk the payload smaller.`);
|
|
153
|
-
}
|
|
154
176
|
if (budgetMs <= DIRECT_EXEC_BUDGET_SECONDS * 1000) {
|
|
155
177
|
const seconds = Math.max(1, Math.min(Math.ceil(budgetMs / 1000), FLY_EXEC_MAX_TIMEOUT_SECONDS));
|
|
178
|
+
// Fly's exec API silently drops a command over FLY_EXEC_MAX_ARG_BYTES. A
|
|
179
|
+
// script that big cannot go inline, so spill it to a file (chunked writes,
|
|
180
|
+
// each under the limit) and run that — matching docker exec, which has no
|
|
181
|
+
// such ceiling. The caller stays unaware of the transport's limit.
|
|
182
|
+
if (script.length > FLY_EXEC_MAX_ARG_BYTES) {
|
|
183
|
+
const path = `${WORK_DIR}/direct-${newRunId()}.sh`;
|
|
184
|
+
await writeScriptChunked(rawExec, path, script);
|
|
185
|
+
return toExecResult(await rawExec(['sh', '-c', `sh ${path}; ec=$?; rm -f ${path}; exit $ec`], seconds));
|
|
186
|
+
}
|
|
156
187
|
return toExecResult(await rawExec(['sh', '-c', script], seconds));
|
|
157
188
|
}
|
|
158
189
|
return await execDetached(rawExec, script, budgetMs);
|
|
@@ -172,23 +203,18 @@ export async function execCommand(rawExec, command, opts, defaultCwd) {
|
|
|
172
203
|
*/
|
|
173
204
|
async function execDetached(rawExec, script, budgetMs) {
|
|
174
205
|
const runId = newRunId();
|
|
175
|
-
const base64 = Buffer.from(script, 'utf8').toString('base64');
|
|
176
|
-
if (base64.length > MAX_SCRIPT_BASE64_BYTES) {
|
|
177
|
-
throw new Error(t('codeSandbox.flyio.error.commandTooLarge', { limit: String(MAX_SCRIPT_BASE64_BYTES) }, {
|
|
178
|
-
defaultValue: `Command is too large to launch on a Fly Machine (encoded ${base64.length} bytes, limit ${MAX_SCRIPT_BASE64_BYTES}). ` +
|
|
179
|
-
'Write it to a file with writeFile() and exec that file instead.',
|
|
180
|
-
}));
|
|
181
|
-
}
|
|
182
206
|
const scriptPath = `${WORK_DIR}/${runId}.sh`;
|
|
183
207
|
const outPath = `${WORK_DIR}/${runId}.out`;
|
|
184
208
|
const errPath = `${WORK_DIR}/${runId}.err`;
|
|
185
209
|
const rcPath = `${WORK_DIR}/${runId}.rc`;
|
|
210
|
+
// Stage the script via chunked writes (each under the Fly exec arg limit),
|
|
211
|
+
// then launch it. A single `printf %s <base64>` would exceed the limit for a
|
|
212
|
+
// large script and Fly would drop it silently. writeScriptChunked also creates
|
|
213
|
+
// WORK_DIR, so the launcher below only has to background the run.
|
|
214
|
+
await writeScriptChunked(rawExec, scriptPath, script);
|
|
186
215
|
// The `&` must apply to the nohup line ALONE. In POSIX sh, `a && b &`
|
|
187
|
-
// backgrounds the whole AND-list,
|
|
188
|
-
// the launch, so these are separate statements rather than one chain.
|
|
216
|
+
// backgrounds the whole AND-list, so these are separate statements.
|
|
189
217
|
const launcher = [
|
|
190
|
-
`mkdir -p ${WORK_DIR} || exit 1`,
|
|
191
|
-
`printf %s ${shellQuote(base64)} | base64 -d > ${scriptPath} || exit 1`,
|
|
192
218
|
`nohup sh -c 'sh ${scriptPath} > ${outPath} 2> ${errPath}; printf %s "$?" > ${rcPath}' > /dev/null 2>&1 < /dev/null &`,
|
|
193
219
|
`printf %s ${runId}`,
|
|
194
220
|
].join('\n');
|
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,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;
|
|
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;AAs2DnB;;;;;;;;;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
|
@@ -90,6 +90,12 @@ const IMPORT_EXTRACT_TIMEOUT_MS = 55_000;
|
|
|
90
90
|
* the gzip pass before transfer is what keeps that count reasonable.
|
|
91
91
|
*/
|
|
92
92
|
const IMPORT_WRITE_CHUNK_BASE64 = 12_000;
|
|
93
|
+
/**
|
|
94
|
+
* Expiry for the presigned URLs {@link Sandbox.importFiles} uses to hand an
|
|
95
|
+
* archive to the object store and back to the Machine. Short — the upload and
|
|
96
|
+
* the single download happen back-to-back.
|
|
97
|
+
*/
|
|
98
|
+
const IMPORT_PRESIGN_EXPIRY_SECONDS = 3600;
|
|
93
99
|
/** Extra seconds allowed for the probe exec beyond its connection budget. */
|
|
94
100
|
const PROBE_EXEC_HEADROOM_SECONDS = 10;
|
|
95
101
|
/**
|
|
@@ -1495,25 +1501,63 @@ class FlyioSandboxProvider {
|
|
|
1495
1501
|
const parts = [];
|
|
1496
1502
|
for await (const chunk of archive)
|
|
1497
1503
|
parts.push(Buffer.from(chunk));
|
|
1498
|
-
const
|
|
1499
|
-
// The temp tar lives on the WORKSPACE VOLUME, not /tmp. The chunked
|
|
1500
|
-
// write and the extract are separate exec calls, and a Machine with
|
|
1501
|
-
// autostop=suspend can cold-start between them — which recreates the
|
|
1502
|
-
// rootfs from the image (wiping /tmp) while the ext4 volume at
|
|
1503
|
-
// /workspace persists. A /tmp temp file vanished mid-import; the volume
|
|
1504
|
-
// one survives.
|
|
1505
|
-
const tmp = `${WORKSPACE_PATH}/.mol-import-${machineId}.tgz`;
|
|
1506
|
-
const quotedTmp = shellQuote(tmp);
|
|
1504
|
+
const gz = gzipSync(Buffer.concat(parts));
|
|
1507
1505
|
const quotedPath = shellQuote(path);
|
|
1508
1506
|
const failIfError = (result, step) => {
|
|
1509
1507
|
if (result.exitCode !== 0) {
|
|
1510
1508
|
throw new Error(t('codeSandbox.flyio.error.importFailed', { path, step, error: result.stderr }, {
|
|
1511
|
-
defaultValue: `Failed to import files into ${path} (${step}): ${result.stderr}`,
|
|
1509
|
+
defaultValue: `Failed to import files into ${path} (${step}): ${result.stderr || result.stdout}`,
|
|
1512
1510
|
}));
|
|
1513
1511
|
}
|
|
1514
1512
|
};
|
|
1515
|
-
//
|
|
1516
|
-
//
|
|
1513
|
+
// PREFERRED: upload the archive to the object store and have the Machine
|
|
1514
|
+
// PULL it with a single curl. Fly's exec endpoint is rate-limited (~5/s)
|
|
1515
|
+
// and caps each command near 15 KB, so streaming a multi-MB tar through
|
|
1516
|
+
// it as base64 chunks triggers a 429 storm that never finishes. A
|
|
1517
|
+
// presigned download is ONE request — the same pull pattern templates use
|
|
1518
|
+
// (see buildRestoreCommand). Falls back to chunked exec when no store is
|
|
1519
|
+
// configured (e.g. unit tests).
|
|
1520
|
+
const store = provider.templateContext().store;
|
|
1521
|
+
if (store) {
|
|
1522
|
+
const key = `${store.prefix}/.imports/${machineId}-${Date.now().toString(36)}${Math.floor(Math.random() * 1e9).toString(36)}.tar.gz`;
|
|
1523
|
+
try {
|
|
1524
|
+
const putUrl = await store.presignPut(key, IMPORT_PRESIGN_EXPIRY_SECONDS);
|
|
1525
|
+
const putRes = await fetch(putUrl, { method: 'PUT', body: gz });
|
|
1526
|
+
if (!putRes.ok) {
|
|
1527
|
+
throw new Error(`presigned PUT to the object store failed: ${putRes.status}`);
|
|
1528
|
+
}
|
|
1529
|
+
const getUrl = await store.presignGet(key, IMPORT_PRESIGN_EXPIRY_SECONDS);
|
|
1530
|
+
const result = await this.exec([
|
|
1531
|
+
'set -e',
|
|
1532
|
+
`mkdir -p ${quotedPath}`,
|
|
1533
|
+
'archive=$(mktemp /tmp/mol-import-XXXXXX.tar.gz)',
|
|
1534
|
+
`curl --fail-with-body -sS --retry 3 --retry-connrefused -o "$archive" ${shellQuote(getUrl)}`,
|
|
1535
|
+
`tar -C ${quotedPath} --no-same-owner --no-same-permissions -xzf "$archive"`,
|
|
1536
|
+
'rm -f "$archive"',
|
|
1537
|
+
].join('\n'), { timeout: IMPORT_EXTRACT_TIMEOUT_MS });
|
|
1538
|
+
// Best-effort: the temp object expires anyway, but delete it promptly.
|
|
1539
|
+
store.remove([key]).catch((error) => {
|
|
1540
|
+
logger.debug('importFiles temp object cleanup failed', { key, error });
|
|
1541
|
+
});
|
|
1542
|
+
failIfError(result, 'download');
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
catch (error) {
|
|
1546
|
+
// The store path is unavailable or failed — fall through to the
|
|
1547
|
+
// chunked-exec transport rather than failing the whole import.
|
|
1548
|
+
logger.warn('importFiles via object store failed; falling back to chunked exec', {
|
|
1549
|
+
error,
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
// FALLBACK: chunked base64 over exec. Only used when no object store is
|
|
1554
|
+
// configured. The temp tar lives on the WORKSPACE VOLUME, not /tmp — the
|
|
1555
|
+
// chunked write and the extract are separate exec calls and a Machine
|
|
1556
|
+
// with autostop can cold-start between them, wiping /tmp (rootfs) while
|
|
1557
|
+
// the ext4 volume at /workspace persists.
|
|
1558
|
+
const base64 = gz.toString('base64');
|
|
1559
|
+
const tmp = `${WORKSPACE_PATH}/.mol-import-${machineId}.tgz`;
|
|
1560
|
+
const quotedTmp = shellQuote(tmp);
|
|
1517
1561
|
failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(0, IMPORT_WRITE_CHUNK_BASE64))} | base64 -d > ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS }), 'write');
|
|
1518
1562
|
for (let i = IMPORT_WRITE_CHUNK_BASE64; i < base64.length; i += IMPORT_WRITE_CHUNK_BASE64) {
|
|
1519
1563
|
failIfError(await this.exec(`printf %s ${shellQuote(base64.slice(i, i + IMPORT_WRITE_CHUNK_BASE64))} | base64 -d >> ${quotedTmp}`, { timeout: FILE_OP_TIMEOUT_MS }), 'write');
|