@molecule/api-code-sandbox-flyio 1.0.1
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/LICENSE +115 -0
- package/README.md +2189 -0
- package/dist/api.d.ts +140 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +226 -0
- package/dist/browser-guard.d.ts +2 -0
- package/dist/browser-guard.d.ts.map +1 -0
- package/dist/browser-guard.js +18 -0
- package/dist/egress.d.ts +180 -0
- package/dist/egress.d.ts.map +1 -0
- package/dist/egress.js +283 -0
- package/dist/exec.d.ts +85 -0
- package/dist/exec.d.ts.map +1 -0
- package/dist/exec.js +215 -0
- package/dist/flycast.d.ts +173 -0
- package/dist/flycast.d.ts.map +1 -0
- package/dist/flycast.js +287 -0
- package/dist/ids.d.ts +79 -0
- package/dist/ids.d.ts.map +1 -0
- package/dist/ids.js +121 -0
- package/dist/index.d.ts +346 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +359 -0
- package/dist/provider.d.ts +24 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +1425 -0
- package/dist/storage.d.ts +119 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +241 -0
- package/dist/templates.d.ts +310 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +655 -0
- package/dist/types.d.ts +519 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +11 -0
- package/dist/utilities.d.ts +29 -0
- package/dist/utilities.d.ts.map +1 -0
- package/dist/utilities.js +44 -0
- package/package.json +63 -0
package/dist/egress.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fly network policies — the only egress restriction Fly.io exposes — and the
|
|
3
|
+
* pure logic behind the observation that proves one is actually in force.
|
|
4
|
+
*
|
|
5
|
+
* **What Fly actually offers.** 6PN custom private networks isolate apps from
|
|
6
|
+
* EACH OTHER ("Apps on separate 6PNs can never communicate unless explicitly
|
|
7
|
+
* configured to do so",
|
|
8
|
+
* https://fly.io/docs/networking/custom-private-networks/); they say nothing
|
|
9
|
+
* about reaching the public internet, which is a different question and the one
|
|
10
|
+
* `verifyEgress()` answers. The mechanism Fly documents for that is **network
|
|
11
|
+
* policies** (https://fly.io/docs/machines/guides-examples/network-policies/,
|
|
12
|
+
* announced at https://community.fly.io/t/new-feature-network-policies/19173):
|
|
13
|
+
* an app-scoped object with a `selector` and `allow` rules, where "Once you
|
|
14
|
+
* create a rule for a given direction, the default for that direction becomes
|
|
15
|
+
* drop."
|
|
16
|
+
*
|
|
17
|
+
* Three properties of that mechanism drive everything below, and every one of
|
|
18
|
+
* them is a limitation you cannot design around:
|
|
19
|
+
*
|
|
20
|
+
* 1. **Rules match protocol + port ONLY.** There is no host, IP or CIDR
|
|
21
|
+
* matching, and (as of the announcement) no port ranges. A network policy
|
|
22
|
+
* therefore CANNOT express a host allowlist. `allow tcp/443` permits a
|
|
23
|
+
* sandbox to reach every host on the internet that listens on 443.
|
|
24
|
+
* 2. **They do not apply to Fly Proxy traffic** — stated in the docs and, in
|
|
25
|
+
* the announcement, in capitals. Inbound preview traffic through the proxy
|
|
26
|
+
* is unaffected by an ingress rule.
|
|
27
|
+
* 3. **They take effect at Machine boot.** Fly's own troubleshooting note:
|
|
28
|
+
* "After creating or updating a policy, restart or redeploy the Machines for
|
|
29
|
+
* changes to take effect." A policy applied to an app with an already-running
|
|
30
|
+
* Machine does not retroactively cover it.
|
|
31
|
+
*
|
|
32
|
+
* The endpoints are documented in the guide and the announcement but are NOT in
|
|
33
|
+
* Fly's OpenAPI specification (https://docs.machines.dev/openapi.json — 68
|
|
34
|
+
* paths, none of them `network_policies`), so the request shape below is taken
|
|
35
|
+
* from those two documents verbatim and the LIST response shape, which neither
|
|
36
|
+
* document specifies, is parsed defensively by {@link extractPolicyId}.
|
|
37
|
+
*
|
|
38
|
+
* VERIFIED AGAINST A REAL FLY ORG, 2026-08-05 — the endpoints DO exist despite
|
|
39
|
+
* their absence from the spec:
|
|
40
|
+
*
|
|
41
|
+
* GET /apps/{app}/network_policies -> 200 []
|
|
42
|
+
* GET /apps/{app}/network_policies/ -> 404 (trailing slash is NOT accepted)
|
|
43
|
+
* POST /apps/{app}/network_policies -> 400 {"error":"name is required"}
|
|
44
|
+
*
|
|
45
|
+
* The POST error is the useful one: it proves the route is real and validating,
|
|
46
|
+
* and that `name` is mandatory — which is why {@link DEFAULT_EGRESS_POLICY_NAME}
|
|
47
|
+
* is always sent.
|
|
48
|
+
*
|
|
49
|
+
* @module
|
|
50
|
+
*/
|
|
51
|
+
import { shellQuote } from './utilities.js';
|
|
52
|
+
/** Default name of the egress policy this provider owns on each sandbox app. */
|
|
53
|
+
export const DEFAULT_EGRESS_POLICY_NAME = 'molecule-sandbox-egress';
|
|
54
|
+
/**
|
|
55
|
+
* Probe exit status meaning at least one raw connection to a public IP
|
|
56
|
+
* SUCCEEDED. Deliberately not 1, so an interpreter error (which exits 1) can
|
|
57
|
+
* never be mistaken for a completed observation.
|
|
58
|
+
*/
|
|
59
|
+
export const EGRESS_PROBE_EXIT_REACHED = 9;
|
|
60
|
+
/** Probe exit status meaning every raw connection attempt was refused or timed out. */
|
|
61
|
+
export const EGRESS_PROBE_EXIT_BLOCKED = 0;
|
|
62
|
+
/**
|
|
63
|
+
* Default probe targets: one IPv4 and one IPv6 literal, both anycast resolvers
|
|
64
|
+
* that answer on 443 from anywhere. IPv6 is included because Fly Machines have
|
|
65
|
+
* native public IPv6 egress and "often egress over IPv6 when the destination
|
|
66
|
+
* has an AAAA record" (https://fly.io/docs/networking/egress-ips/) — probing
|
|
67
|
+
* only IPv4 could miss an open v6 path entirely.
|
|
68
|
+
*/
|
|
69
|
+
export const DEFAULT_EGRESS_PROBE_TARGETS = '1.1.1.1:443,[2606:4700:4700::1111]:443';
|
|
70
|
+
/** Matches a bare IPv4 literal. */
|
|
71
|
+
const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
|
|
72
|
+
/** Matches a bracketed IPv6 literal, e.g. `[2606:4700:4700::1111]`. */
|
|
73
|
+
const IPV6_BRACKETED = /^\[[0-9a-fA-F:]+\]$/;
|
|
74
|
+
/**
|
|
75
|
+
* Renders a target the way it is written in configuration, so an operator can
|
|
76
|
+
* paste it straight back.
|
|
77
|
+
* @param target - The parsed target.
|
|
78
|
+
* @returns `host:port`, with IPv6 hosts bracketed.
|
|
79
|
+
*/
|
|
80
|
+
export function formatEgressProbeTarget(target) {
|
|
81
|
+
const host = target.host.includes(':') ? `[${target.host}]` : target.host;
|
|
82
|
+
return `${host}:${target.port}`;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Parses probe targets from configuration.
|
|
86
|
+
*
|
|
87
|
+
* Only LITERAL addresses are accepted, never hostnames — Fly's own
|
|
88
|
+
* troubleshooting guidance for testing a policy is to "use direct IP addresses
|
|
89
|
+
* (not hostnames) to test blocked traffic to avoid DNS masking", and a probe
|
|
90
|
+
* that failed at name resolution would report a blocked connection it never
|
|
91
|
+
* actually attempted.
|
|
92
|
+
* @param raw - Comma-separated `ip:port` string, or an array of them. IPv6
|
|
93
|
+
* literals must be bracketed (`[2606:4700:4700::1111]:443`).
|
|
94
|
+
* @returns The valid targets, in order. Invalid entries are dropped rather than
|
|
95
|
+
* throwing: the caller turns an empty result into an `inconclusive` verdict,
|
|
96
|
+
* which is the honest outcome for "nothing to probe".
|
|
97
|
+
*/
|
|
98
|
+
export function parseEgressProbeTargets(raw) {
|
|
99
|
+
const entries = Array.isArray(raw) ? raw : (raw ?? '').split(',');
|
|
100
|
+
const targets = [];
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
const trimmed = entry.trim();
|
|
103
|
+
const index = trimmed.lastIndexOf(':');
|
|
104
|
+
if (index <= 0)
|
|
105
|
+
continue;
|
|
106
|
+
const host = trimmed.slice(0, index);
|
|
107
|
+
const port = Number(trimmed.slice(index + 1));
|
|
108
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
109
|
+
continue;
|
|
110
|
+
if (IPV4.test(host))
|
|
111
|
+
targets.push({ host, port });
|
|
112
|
+
else if (IPV6_BRACKETED.test(host))
|
|
113
|
+
targets.push({ host: host.slice(1, -1), port });
|
|
114
|
+
}
|
|
115
|
+
return targets;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Parses an allowed-port list for the egress network policy.
|
|
119
|
+
*
|
|
120
|
+
* An EMPTY list is deliberately not expressible. Fly documents the deny default
|
|
121
|
+
* as a consequence of at least one `allow` rule existing for a direction; what
|
|
122
|
+
* an allow rule with zero ports does is not documented anywhere, so a provider
|
|
123
|
+
* that sent one would be guessing at a security control. An operator who wants
|
|
124
|
+
* near-total denial allows a single port nothing in the sandbox uses.
|
|
125
|
+
* @param raw - Comma-separated `protocol:port` pairs, e.g. `tcp:3128,udp:53`.
|
|
126
|
+
* A bare port is treated as TCP.
|
|
127
|
+
* @returns The parsed ports, or `undefined` when the input is absent or empty
|
|
128
|
+
* (meaning "apply no policy"), which is NOT the same as "deny everything".
|
|
129
|
+
* @throws {Error} When an entry is present but unparseable — silently dropping a
|
|
130
|
+
* port from a security control would produce a policy the operator never wrote.
|
|
131
|
+
*/
|
|
132
|
+
export function parseEgressAllowedPorts(raw) {
|
|
133
|
+
const entries = (raw ?? '')
|
|
134
|
+
.split(',')
|
|
135
|
+
.map((entry) => entry.trim())
|
|
136
|
+
.filter(Boolean);
|
|
137
|
+
if (entries.length === 0)
|
|
138
|
+
return undefined;
|
|
139
|
+
const ports = [];
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
const [left, right] = entry.includes(':') ? entry.split(':', 2) : ['tcp', entry];
|
|
142
|
+
const protocol = left.toLowerCase();
|
|
143
|
+
const port = Number(right);
|
|
144
|
+
if ((protocol !== 'tcp' && protocol !== 'udp') ||
|
|
145
|
+
!Number.isInteger(port) ||
|
|
146
|
+
port < 1 ||
|
|
147
|
+
port > 65535) {
|
|
148
|
+
throw new Error(`Invalid Fly egress port "${entry}" — expected "tcp:443", "udp:53" or a bare port number.`);
|
|
149
|
+
}
|
|
150
|
+
ports.push({ protocol, port });
|
|
151
|
+
}
|
|
152
|
+
return ports;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Builds the shell command that observes egress from inside a Machine.
|
|
156
|
+
*
|
|
157
|
+
* Raw `net.connect` on purpose: an HTTP client would honour proxy environment
|
|
158
|
+
* variables and report the PROXY's policy instead of the network's. Every target
|
|
159
|
+
* is attempted in parallel and the process exits
|
|
160
|
+
* {@link EGRESS_PROBE_EXIT_REACHED} if any connected,
|
|
161
|
+
* {@link EGRESS_PROBE_EXIT_BLOCKED} if all were refused or timed out.
|
|
162
|
+
* @param targets - Literal targets to attempt.
|
|
163
|
+
* @param timeoutMs - Per-connection timeout.
|
|
164
|
+
* @returns A `sh`-safe command string.
|
|
165
|
+
*/
|
|
166
|
+
export function buildEgressProbeCommand(targets, timeoutMs) {
|
|
167
|
+
const script = `const net=require('net');const ts=${JSON.stringify(targets)};` +
|
|
168
|
+
`if(!ts.length)process.exit(1);` +
|
|
169
|
+
`let open=0,left=ts.length;` +
|
|
170
|
+
`const done=()=>{if(--left===0)process.exit(open>0?${EGRESS_PROBE_EXIT_REACHED}:${EGRESS_PROBE_EXIT_BLOCKED})};` +
|
|
171
|
+
`for(const t of ts){const s=net.connect({host:t.host,port:t.port});` +
|
|
172
|
+
`s.setTimeout(${timeoutMs});` +
|
|
173
|
+
`s.on('connect',()=>{open++;s.destroy();done()});` +
|
|
174
|
+
`s.on('timeout',()=>{s.destroy();done()});` +
|
|
175
|
+
`s.on('error',()=>done())}`;
|
|
176
|
+
return `node -e ${shellQuote(script)}`;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Describes the policy this provider applied, for the verdict message.
|
|
180
|
+
* @param policyPorts - Allowed ports, or `undefined` when no policy is applied.
|
|
181
|
+
* @returns A phrase naming the mechanism that was (or was not) in place.
|
|
182
|
+
*/
|
|
183
|
+
function describePolicy(policyPorts) {
|
|
184
|
+
if (!policyPorts)
|
|
185
|
+
return 'no Fly network policy applied by this provider';
|
|
186
|
+
const ports = policyPorts.map((port) => `${port.protocol}/${port.port}`).join(', ');
|
|
187
|
+
return `a Fly network policy allowing egress on ${ports}`;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Maps a probe's exit status onto an {@link EgressVerdict}.
|
|
191
|
+
*
|
|
192
|
+
* The mapping is the whole security contract of this file, so it is total and
|
|
193
|
+
* has exactly one path to each state:
|
|
194
|
+
*
|
|
195
|
+
* | Exit | Verdict | Meaning |
|
|
196
|
+
* |---|---|---|
|
|
197
|
+
* | {@link EGRESS_PROBE_EXIT_REACHED} | `open` | A raw socket reached a public IP. |
|
|
198
|
+
* | {@link EGRESS_PROBE_EXIT_BLOCKED} | `filtered` | Every attempt was refused or timed out. |
|
|
199
|
+
* | anything else | `inconclusive` | The probe did not complete its attempts. |
|
|
200
|
+
*
|
|
201
|
+
* Every other outcome — a missing interpreter (127), a killed process, Fly
|
|
202
|
+
* returning no status at all (-1), an API failure before the probe ever ran — is
|
|
203
|
+
* `inconclusive`. Collapsing any of those into `filtered` is precisely the "I
|
|
204
|
+
* could not look" ⇒ "I looked and it is safe" conflation the capability exists
|
|
205
|
+
* to prevent, and the caller refuses to boot production on anything but
|
|
206
|
+
* `filtered`.
|
|
207
|
+
* @param exitCode - The probe process's exit status.
|
|
208
|
+
* @param context - App, targets and the policy this provider applied.
|
|
209
|
+
* @returns The verdict.
|
|
210
|
+
*/
|
|
211
|
+
export function verdictForProbeExit(exitCode, context) {
|
|
212
|
+
const targets = context.targets.map(formatEgressProbeTarget).join(', ');
|
|
213
|
+
const policy = describePolicy(context.policyPorts);
|
|
214
|
+
if (exitCode === EGRESS_PROBE_EXIT_REACHED) {
|
|
215
|
+
return {
|
|
216
|
+
state: 'open',
|
|
217
|
+
detail: `A Machine in Fly app "${context.app}" (${policy}) opened a raw TCP connection to ` +
|
|
218
|
+
`${targets} — sandbox egress is NOT filtered.`,
|
|
219
|
+
remediation: context.policyPorts
|
|
220
|
+
? 'The policy that is in force still permits the probe’s ports. A Fly network policy ' +
|
|
221
|
+
'matches protocol and port ONLY — it has no host or CIDR matching — so it cannot be a ' +
|
|
222
|
+
'host allowlist. Allow only the port of an egress proxy you control and route sandbox ' +
|
|
223
|
+
'traffic through it. See https://fly.io/docs/machines/guides-examples/network-policies/.'
|
|
224
|
+
: 'Set config.egressAllowedPorts (or FLY_SANDBOX_EGRESS_ALLOWED_PORTS, e.g. ' +
|
|
225
|
+
'"tcp:3128,udp:53") so this provider applies a Fly network policy to every sandbox app. ' +
|
|
226
|
+
'Fly drops everything else in that direction once one egress rule exists. See ' +
|
|
227
|
+
'https://fly.io/docs/machines/guides-examples/network-policies/.',
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (exitCode === EGRESS_PROBE_EXIT_BLOCKED) {
|
|
231
|
+
return {
|
|
232
|
+
state: 'filtered',
|
|
233
|
+
detail: `Raw TCP connects to ${targets} from a Machine in Fly app "${context.app}" ` +
|
|
234
|
+
`(${policy}) were refused or timed out.`,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
state: 'inconclusive',
|
|
239
|
+
detail: `The egress probe in Fly app "${context.app}" exited ${exitCode}, which is neither ` +
|
|
240
|
+
`${EGRESS_PROBE_EXIT_BLOCKED} (every attempt blocked) nor ${EGRESS_PROBE_EXIT_REACHED} ` +
|
|
241
|
+
'(a target was reached) — it did not complete its connection attempts, so nothing was ' +
|
|
242
|
+
'observed about egress.',
|
|
243
|
+
remediation: 'Check that the probe image can run `node -e` (override it with config.egressProbeImage), ' +
|
|
244
|
+
'and that the Fly API token may create Machines in the probe app.',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Narrows an unknown value to a plain object.
|
|
249
|
+
* @param value - The value to test.
|
|
250
|
+
* @returns `true` when it is a non-null, non-array object.
|
|
251
|
+
*/
|
|
252
|
+
function isRecord(value) {
|
|
253
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Finds an existing policy's id in a LIST response, so the next write updates it
|
|
257
|
+
* instead of stacking duplicates.
|
|
258
|
+
*
|
|
259
|
+
* Neither the guide nor the announcement specifies what
|
|
260
|
+
* `GET /v1/apps/{app}/network_policies/` returns, and the endpoint is absent
|
|
261
|
+
* from Fly's OpenAPI specification — so this accepts a bare array or an object
|
|
262
|
+
* wrapping one under any of the plausible keys, and returns `undefined` for
|
|
263
|
+
* anything it does not recognize. `undefined` is safe: the caller then POSTs
|
|
264
|
+
* without an `id`, which still applies the policy.
|
|
265
|
+
* @param response - The raw parsed LIST response.
|
|
266
|
+
* @param name - The policy name to match.
|
|
267
|
+
* @returns The matching policy's id, or `undefined`.
|
|
268
|
+
*/
|
|
269
|
+
export function extractPolicyId(response, name) {
|
|
270
|
+
const list = Array.isArray(response)
|
|
271
|
+
? response
|
|
272
|
+
: isRecord(response)
|
|
273
|
+
? [response.policies, response.network_policies, response.data].find(Array.isArray)
|
|
274
|
+
: undefined;
|
|
275
|
+
if (!Array.isArray(list))
|
|
276
|
+
return undefined;
|
|
277
|
+
for (const entry of list) {
|
|
278
|
+
if (isRecord(entry) && entry.name === name && typeof entry.id === 'string' && entry.id) {
|
|
279
|
+
return entry.id;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
package/dist/exec.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command execution on a Fly Machine, including the detach-and-poll path that
|
|
3
|
+
* works around Fly's 60-second exec ceiling.
|
|
4
|
+
*
|
|
5
|
+
* **The constraint.** `POST /v1/apps/{app}/machines/{id}/exec` is a
|
|
6
|
+
* request/response call whose `timeout` field is capped by Fly at 60 seconds —
|
|
7
|
+
* a longer value is rejected outright with
|
|
8
|
+
* `{"error":"timeout must not exceed 60 seconds"}`
|
|
9
|
+
* (https://community.fly.io/t/extending-timeout-of-execute-command-machines-api-endpoint/26074).
|
|
10
|
+
* A sandbox routinely runs commands far past that (`npm install`, a production
|
|
11
|
+
* build), so a provider that only ever calls exec directly would fail every one
|
|
12
|
+
* of them. That is not an acceptable gap — it is the common case.
|
|
13
|
+
*
|
|
14
|
+
* **The mapping.** Commands whose budget fits inside the ceiling run directly.
|
|
15
|
+
* Anything longer is written to a script file inside the Machine, launched
|
|
16
|
+
* detached with its stdout/stderr redirected to files and its exit status
|
|
17
|
+
* written to a third file, and then POLLED with short direct execs until the
|
|
18
|
+
* status file appears. The caller sees one `ExecResult` either way; the only
|
|
19
|
+
* observable difference is that output arrives at the end rather than streaming
|
|
20
|
+
* (which the core `exec` contract does not offer anyway).
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
import type { ExecOptions, ExecResult } from '@molecule/api-code-sandbox';
|
|
25
|
+
import type { FlyExecResponse } from './types.js';
|
|
26
|
+
/**
|
|
27
|
+
* Fly's hard ceiling on the exec endpoint's `timeout` field, in seconds.
|
|
28
|
+
* Verified against Fly staff guidance (see the module docs); a larger value is
|
|
29
|
+
* rejected by the API rather than clamped.
|
|
30
|
+
*/
|
|
31
|
+
export declare const FLY_EXEC_MAX_TIMEOUT_SECONDS = 60;
|
|
32
|
+
/**
|
|
33
|
+
* Largest budget still run as a single direct exec, in seconds. Kept below
|
|
34
|
+
* {@link FLY_EXEC_MAX_TIMEOUT_SECONDS} so the API's own timeout is never the
|
|
35
|
+
* thing that fires first — the caller's timeout should be.
|
|
36
|
+
*/
|
|
37
|
+
export declare const DIRECT_EXEC_BUDGET_SECONDS = 55;
|
|
38
|
+
/**
|
|
39
|
+
* Cap on captured stdout/stderr per detached command, in bytes. The exec
|
|
40
|
+
* response carries output inline as JSON, so an unbounded `cat` of a build log
|
|
41
|
+
* would try to materialize the whole thing in one response body.
|
|
42
|
+
*/
|
|
43
|
+
export declare const MAX_CAPTURED_OUTPUT_BYTES: number;
|
|
44
|
+
/**
|
|
45
|
+
* Exit code reported when a command's outcome is genuinely unknown — it was
|
|
46
|
+
* still running when the caller's budget expired, or Fly returned no status.
|
|
47
|
+
* Distinct from every real shell exit status (0-255), matching the Docker
|
|
48
|
+
* provider's convention so callers can tell "unfinished" from "failed".
|
|
49
|
+
*/
|
|
50
|
+
export declare const INDETERMINATE_EXIT_CODE = -1;
|
|
51
|
+
/** Runs one exec call against a Machine. Injected so this module stays transport-free. */
|
|
52
|
+
export type RawExec = (command: string[], timeoutSeconds: number) => Promise<FlyExecResponse>;
|
|
53
|
+
/**
|
|
54
|
+
* Normalizes a Fly exec response into the core `ExecResult`.
|
|
55
|
+
*
|
|
56
|
+
* Fly reports `exit_signal` separately from `exit_code`; a signalled process has
|
|
57
|
+
* no meaningful exit code, so it is rendered the way a POSIX shell does, as
|
|
58
|
+
* `128 + signal`.
|
|
59
|
+
* @param response - The raw Fly exec response.
|
|
60
|
+
* @returns The normalized result.
|
|
61
|
+
*/
|
|
62
|
+
export declare function toExecResult(response: FlyExecResponse): ExecResult;
|
|
63
|
+
/**
|
|
64
|
+
* Builds the shell script body for a command: change directory, export the
|
|
65
|
+
* requested environment, then run the command.
|
|
66
|
+
* @param command - The shell command to run.
|
|
67
|
+
* @param opts - Optional cwd and environment.
|
|
68
|
+
* @param defaultCwd - Directory used when `opts.cwd` is unset.
|
|
69
|
+
* @returns The script source, newline separated.
|
|
70
|
+
*/
|
|
71
|
+
export declare function buildScript(command: string, opts: ExecOptions | undefined, defaultCwd: string): string;
|
|
72
|
+
/**
|
|
73
|
+
* Executes a command on a Machine, choosing the direct or detached strategy
|
|
74
|
+
* based on the caller's time budget.
|
|
75
|
+
*
|
|
76
|
+
* @param rawExec - Transport callback issuing one Fly exec call.
|
|
77
|
+
* @param command - The shell command to run.
|
|
78
|
+
* @param opts - Core exec options (cwd, env, timeout in ms).
|
|
79
|
+
* @param defaultCwd - Working directory used when `opts.cwd` is unset.
|
|
80
|
+
* @returns The command's stdout, stderr and exit code.
|
|
81
|
+
* @throws {Error} When the command is too large to launch (see
|
|
82
|
+
* {@link MAX_SCRIPT_BASE64_BYTES}), or when the transport itself fails.
|
|
83
|
+
*/
|
|
84
|
+
export declare function execCommand(rawExec: RawExec, command: string, opts: ExecOptions | undefined, defaultCwd: string): Promise<ExecResult>;
|
|
85
|
+
//# sourceMappingURL=exec.d.ts.map
|
|
@@ -0,0 +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;AAWxD;;;;;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,CAYlE;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,WAAW,GAAG,SAAS,EAC7B,UAAU,EAAE,MAAM,GACjB,MAAM,CAGR;AAaD;;;;;;;;;;;GAWG;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,CAUrB"}
|
package/dist/exec.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command execution on a Fly Machine, including the detach-and-poll path that
|
|
3
|
+
* works around Fly's 60-second exec ceiling.
|
|
4
|
+
*
|
|
5
|
+
* **The constraint.** `POST /v1/apps/{app}/machines/{id}/exec` is a
|
|
6
|
+
* request/response call whose `timeout` field is capped by Fly at 60 seconds —
|
|
7
|
+
* a longer value is rejected outright with
|
|
8
|
+
* `{"error":"timeout must not exceed 60 seconds"}`
|
|
9
|
+
* (https://community.fly.io/t/extending-timeout-of-execute-command-machines-api-endpoint/26074).
|
|
10
|
+
* A sandbox routinely runs commands far past that (`npm install`, a production
|
|
11
|
+
* build), so a provider that only ever calls exec directly would fail every one
|
|
12
|
+
* of them. That is not an acceptable gap — it is the common case.
|
|
13
|
+
*
|
|
14
|
+
* **The mapping.** Commands whose budget fits inside the ceiling run directly.
|
|
15
|
+
* Anything longer is written to a script file inside the Machine, launched
|
|
16
|
+
* detached with its stdout/stderr redirected to files and its exit status
|
|
17
|
+
* written to a third file, and then POLLED with short direct execs until the
|
|
18
|
+
* status file appears. The caller sees one `ExecResult` either way; the only
|
|
19
|
+
* observable difference is that output arrives at the end rather than streaming
|
|
20
|
+
* (which the core `exec` contract does not offer anyway).
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
import { getLogger } from '@molecule/api-bond';
|
|
25
|
+
import { t } from '@molecule/api-i18n';
|
|
26
|
+
import { renderEnvExports, shellQuote } from './utilities.js';
|
|
27
|
+
const logger = getLogger();
|
|
28
|
+
/**
|
|
29
|
+
* Fly's hard ceiling on the exec endpoint's `timeout` field, in seconds.
|
|
30
|
+
* Verified against Fly staff guidance (see the module docs); a larger value is
|
|
31
|
+
* rejected by the API rather than clamped.
|
|
32
|
+
*/
|
|
33
|
+
export const FLY_EXEC_MAX_TIMEOUT_SECONDS = 60;
|
|
34
|
+
/**
|
|
35
|
+
* Largest budget still run as a single direct exec, in seconds. Kept below
|
|
36
|
+
* {@link FLY_EXEC_MAX_TIMEOUT_SECONDS} so the API's own timeout is never the
|
|
37
|
+
* thing that fires first — the caller's timeout should be.
|
|
38
|
+
*/
|
|
39
|
+
export const DIRECT_EXEC_BUDGET_SECONDS = 55;
|
|
40
|
+
/** Timeout applied to the short bookkeeping execs (launch, poll, collect), in seconds. */
|
|
41
|
+
const CONTROL_EXEC_TIMEOUT_SECONDS = 30;
|
|
42
|
+
/** Interval between status-file polls for a detached command, in ms. */
|
|
43
|
+
const POLL_INTERVAL_MS = 2000;
|
|
44
|
+
/** Default wall-clock budget for a command with no `opts.timeout`, in ms. */
|
|
45
|
+
const DEFAULT_TIMEOUT_MS = 600_000;
|
|
46
|
+
/**
|
|
47
|
+
* Cap on captured stdout/stderr per detached command, in bytes. The exec
|
|
48
|
+
* response carries output inline as JSON, so an unbounded `cat` of a build log
|
|
49
|
+
* would try to materialize the whole thing in one response body.
|
|
50
|
+
*/
|
|
51
|
+
export const MAX_CAPTURED_OUTPUT_BYTES = 5 * 1024 * 1024;
|
|
52
|
+
/**
|
|
53
|
+
* Cap on the base64-encoded launcher script. `sh -c` receives it as a single
|
|
54
|
+
* argument, which Linux limits to MAX_ARG_STRLEN (128 KB).
|
|
55
|
+
*/
|
|
56
|
+
const MAX_SCRIPT_BASE64_BYTES = 60_000;
|
|
57
|
+
/** Directory inside the Machine holding detached-command bookkeeping files. */
|
|
58
|
+
const WORK_DIR = '/tmp/.mol-exec';
|
|
59
|
+
/**
|
|
60
|
+
* Exit code reported when a command's outcome is genuinely unknown — it was
|
|
61
|
+
* still running when the caller's budget expired, or Fly returned no status.
|
|
62
|
+
* Distinct from every real shell exit status (0-255), matching the Docker
|
|
63
|
+
* provider's convention so callers can tell "unfinished" from "failed".
|
|
64
|
+
*/
|
|
65
|
+
export const INDETERMINATE_EXIT_CODE = -1;
|
|
66
|
+
/**
|
|
67
|
+
* Normalizes a Fly exec response into the core `ExecResult`.
|
|
68
|
+
*
|
|
69
|
+
* Fly reports `exit_signal` separately from `exit_code`; a signalled process has
|
|
70
|
+
* no meaningful exit code, so it is rendered the way a POSIX shell does, as
|
|
71
|
+
* `128 + signal`.
|
|
72
|
+
* @param response - The raw Fly exec response.
|
|
73
|
+
* @returns The normalized result.
|
|
74
|
+
*/
|
|
75
|
+
export function toExecResult(response) {
|
|
76
|
+
const exitCode = typeof response.exit_code === 'number'
|
|
77
|
+
? response.exit_code
|
|
78
|
+
: typeof response.exit_signal === 'number' && response.exit_signal > 0
|
|
79
|
+
? 128 + response.exit_signal
|
|
80
|
+
: INDETERMINATE_EXIT_CODE;
|
|
81
|
+
return {
|
|
82
|
+
stdout: response.stdout ?? '',
|
|
83
|
+
stderr: response.stderr ?? '',
|
|
84
|
+
exitCode,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Builds the shell script body for a command: change directory, export the
|
|
89
|
+
* requested environment, then run the command.
|
|
90
|
+
* @param command - The shell command to run.
|
|
91
|
+
* @param opts - Optional cwd and environment.
|
|
92
|
+
* @param defaultCwd - Directory used when `opts.cwd` is unset.
|
|
93
|
+
* @returns The script source, newline separated.
|
|
94
|
+
*/
|
|
95
|
+
export function buildScript(command, opts, defaultCwd) {
|
|
96
|
+
const cwd = opts?.cwd ?? defaultCwd;
|
|
97
|
+
return [`cd ${shellQuote(cwd)} || exit 127`, ...renderEnvExports(opts?.env), command].join('\n');
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Generates an id for one detached run's bookkeeping files. Hex only, so it is
|
|
101
|
+
* always safe to interpolate into a shell command unquoted.
|
|
102
|
+
* @returns A 16-character hex id.
|
|
103
|
+
*/
|
|
104
|
+
function newRunId() {
|
|
105
|
+
let id = '';
|
|
106
|
+
while (id.length < 16)
|
|
107
|
+
id += Math.floor(Math.random() * 0xffffffff).toString(16);
|
|
108
|
+
return id.slice(0, 16);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Executes a command on a Machine, choosing the direct or detached strategy
|
|
112
|
+
* based on the caller's time budget.
|
|
113
|
+
*
|
|
114
|
+
* @param rawExec - Transport callback issuing one Fly exec call.
|
|
115
|
+
* @param command - The shell command to run.
|
|
116
|
+
* @param opts - Core exec options (cwd, env, timeout in ms).
|
|
117
|
+
* @param defaultCwd - Working directory used when `opts.cwd` is unset.
|
|
118
|
+
* @returns The command's stdout, stderr and exit code.
|
|
119
|
+
* @throws {Error} When the command is too large to launch (see
|
|
120
|
+
* {@link MAX_SCRIPT_BASE64_BYTES}), or when the transport itself fails.
|
|
121
|
+
*/
|
|
122
|
+
export async function execCommand(rawExec, command, opts, defaultCwd) {
|
|
123
|
+
const budgetMs = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
124
|
+
const script = buildScript(command, opts, defaultCwd);
|
|
125
|
+
if (budgetMs <= DIRECT_EXEC_BUDGET_SECONDS * 1000) {
|
|
126
|
+
const seconds = Math.max(1, Math.min(Math.ceil(budgetMs / 1000), FLY_EXEC_MAX_TIMEOUT_SECONDS));
|
|
127
|
+
return toExecResult(await rawExec(['sh', '-c', script], seconds));
|
|
128
|
+
}
|
|
129
|
+
return await execDetached(rawExec, script, budgetMs);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Runs a script detached inside the Machine and polls for its completion.
|
|
133
|
+
*
|
|
134
|
+
* The script is delivered base64-encoded so that no amount of quoting in the
|
|
135
|
+
* caller's command can break out of the launcher; the launcher itself only ever
|
|
136
|
+
* interpolates a hex run id.
|
|
137
|
+
* @param rawExec - Transport callback issuing one Fly exec call.
|
|
138
|
+
* @param script - The script body to run.
|
|
139
|
+
* @param budgetMs - Wall-clock budget before the result is reported indeterminate.
|
|
140
|
+
* @returns The command's captured output and exit code.
|
|
141
|
+
* @throws {Error} When the encoded script exceeds the single-argument limit, or
|
|
142
|
+
* when the launcher itself fails to start.
|
|
143
|
+
*/
|
|
144
|
+
async function execDetached(rawExec, script, budgetMs) {
|
|
145
|
+
const runId = newRunId();
|
|
146
|
+
const base64 = Buffer.from(script, 'utf8').toString('base64');
|
|
147
|
+
if (base64.length > MAX_SCRIPT_BASE64_BYTES) {
|
|
148
|
+
throw new Error(t('codeSandbox.flyio.error.commandTooLarge', { limit: String(MAX_SCRIPT_BASE64_BYTES) }, {
|
|
149
|
+
defaultValue: `Command is too large to launch on a Fly Machine (encoded ${base64.length} bytes, limit ${MAX_SCRIPT_BASE64_BYTES}). ` +
|
|
150
|
+
'Write it to a file with writeFile() and exec that file instead.',
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
const scriptPath = `${WORK_DIR}/${runId}.sh`;
|
|
154
|
+
const outPath = `${WORK_DIR}/${runId}.out`;
|
|
155
|
+
const errPath = `${WORK_DIR}/${runId}.err`;
|
|
156
|
+
const rcPath = `${WORK_DIR}/${runId}.rc`;
|
|
157
|
+
// The `&` must apply to the nohup line ALONE. In POSIX sh, `a && b &`
|
|
158
|
+
// backgrounds the whole AND-list, which would race the script write against
|
|
159
|
+
// the launch, so these are separate statements rather than one chain.
|
|
160
|
+
const launcher = [
|
|
161
|
+
`mkdir -p ${WORK_DIR} || exit 1`,
|
|
162
|
+
`printf %s ${shellQuote(base64)} | base64 -d > ${scriptPath} || exit 1`,
|
|
163
|
+
`nohup sh -c 'sh ${scriptPath} > ${outPath} 2> ${errPath}; printf %s "$?" > ${rcPath}' > /dev/null 2>&1 < /dev/null &`,
|
|
164
|
+
`printf %s ${runId}`,
|
|
165
|
+
].join('\n');
|
|
166
|
+
const launch = toExecResult(await rawExec(['sh', '-c', launcher], CONTROL_EXEC_TIMEOUT_SECONDS));
|
|
167
|
+
if (launch.exitCode !== 0) {
|
|
168
|
+
throw new Error(t('codeSandbox.flyio.error.launchFailed', { error: launch.stderr || launch.stdout }, {
|
|
169
|
+
defaultValue: `Failed to launch detached command on Fly Machine: ${launch.stderr || launch.stdout}`,
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
const deadline = Date.now() + budgetMs;
|
|
173
|
+
let rc = null;
|
|
174
|
+
while (Date.now() < deadline) {
|
|
175
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
176
|
+
const poll = toExecResult(await rawExec(['sh', '-c', `cat ${rcPath} 2>/dev/null`], Math.max(1, Math.min(CONTROL_EXEC_TIMEOUT_SECONDS, FLY_EXEC_MAX_TIMEOUT_SECONDS))));
|
|
177
|
+
const value = poll.stdout.trim();
|
|
178
|
+
if (value) {
|
|
179
|
+
rc = value;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const collect = toExecResult(await rawExec([
|
|
184
|
+
'sh',
|
|
185
|
+
'-c',
|
|
186
|
+
`head -c ${MAX_CAPTURED_OUTPUT_BYTES} ${outPath} 2>/dev/null; printf '\\n__MOL_STDERR__\\n'; head -c ${MAX_CAPTURED_OUTPUT_BYTES} ${errPath} 2>/dev/null`,
|
|
187
|
+
], CONTROL_EXEC_TIMEOUT_SECONDS));
|
|
188
|
+
const separator = collect.stdout.indexOf('\n__MOL_STDERR__\n');
|
|
189
|
+
const stdout = separator === -1 ? collect.stdout : collect.stdout.slice(0, separator);
|
|
190
|
+
const stderr = separator === -1 ? '' : collect.stdout.slice(separator + '\n__MOL_STDERR__\n'.length);
|
|
191
|
+
if (rc === null) {
|
|
192
|
+
// Still running at the deadline. The bookkeeping files are deliberately NOT
|
|
193
|
+
// removed — the process owns them and will keep writing.
|
|
194
|
+
logger.warn('Fly detached command exceeded its budget and is still running', {
|
|
195
|
+
runId,
|
|
196
|
+
budgetMs,
|
|
197
|
+
});
|
|
198
|
+
return { stdout, stderr, exitCode: INDETERMINATE_EXIT_CODE };
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
await rawExec(['sh', '-c', `rm -f ${WORK_DIR}/${runId}.*`], CONTROL_EXEC_TIMEOUT_SECONDS);
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
// Best-effort cleanup of four files under /tmp. The command itself already
|
|
205
|
+
// completed and its result is in hand; failing the call over leftover
|
|
206
|
+
// scratch files would turn a success into an error.
|
|
207
|
+
logger.debug('Failed to clean up Fly detached command scratch files', { runId, error });
|
|
208
|
+
}
|
|
209
|
+
const parsed = Number.parseInt(rc, 10);
|
|
210
|
+
return {
|
|
211
|
+
stdout,
|
|
212
|
+
stderr,
|
|
213
|
+
exitCode: Number.isFinite(parsed) ? parsed : INDETERMINATE_EXIT_CODE,
|
|
214
|
+
};
|
|
215
|
+
}
|