@namzu/sandbox 13.0.0 → 14.0.0
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/CHANGELOG.md +309 -0
- package/README.md +151 -0
- package/dist/backends/firecracker/protocol.d.ts +22 -0
- package/dist/backends/firecracker/protocol.d.ts.map +1 -1
- package/dist/backends/firecracker/protocol.js.map +1 -1
- package/dist/backends/firecracker/transport.d.ts +104 -9
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +139 -13
- package/dist/backends/firecracker/transport.js.map +1 -1
- package/dist/backends/kubernetes/egress-policy.d.ts +219 -0
- package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -0
- package/dist/backends/kubernetes/egress-policy.js +314 -0
- package/dist/backends/kubernetes/egress-policy.js.map +1 -0
- package/dist/backends/kubernetes/index.d.ts +374 -0
- package/dist/backends/kubernetes/index.d.ts.map +1 -0
- package/dist/backends/kubernetes/index.js +671 -0
- package/dist/backends/kubernetes/index.js.map +1 -0
- package/dist/backends/kubernetes/k8s-client.d.ts +125 -0
- package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -0
- package/dist/backends/kubernetes/k8s-client.js +246 -0
- package/dist/backends/kubernetes/k8s-client.js.map +1 -0
- package/dist/backends/kubernetes/lease.d.ts +119 -0
- package/dist/backends/kubernetes/lease.d.ts.map +1 -0
- package/dist/backends/kubernetes/lease.js +151 -0
- package/dist/backends/kubernetes/lease.js.map +1 -0
- package/dist/backends/kubernetes/objects.d.ts +282 -0
- package/dist/backends/kubernetes/objects.d.ts.map +1 -0
- package/dist/backends/kubernetes/objects.js +156 -0
- package/dist/backends/kubernetes/objects.js.map +1 -0
- package/dist/backends/kubernetes/privilege-probe.d.ts +136 -0
- package/dist/backends/kubernetes/privilege-probe.d.ts.map +1 -0
- package/dist/backends/kubernetes/privilege-probe.js +185 -0
- package/dist/backends/kubernetes/privilege-probe.js.map +1 -0
- package/dist/backends/kubernetes/sandbox.d.ts +123 -0
- package/dist/backends/kubernetes/sandbox.d.ts.map +1 -0
- package/dist/backends/kubernetes/sandbox.js +299 -0
- package/dist/backends/kubernetes/sandbox.js.map +1 -0
- package/dist/backends/kubernetes/transport.d.ts +122 -0
- package/dist/backends/kubernetes/transport.d.ts.map +1 -0
- package/dist/backends/kubernetes/transport.js +197 -0
- package/dist/backends/kubernetes/transport.js.map +1 -0
- package/dist/backends/kubernetes/workspace.d.ts +381 -0
- package/dist/backends/kubernetes/workspace.d.ts.map +1 -0
- package/dist/backends/kubernetes/workspace.js +1064 -0
- package/dist/backends/kubernetes/workspace.js.map +1 -0
- package/dist/index.d.ts +132 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +102 -34
- package/dist/index.js.map +1 -1
- package/dist/testing/sandbox-conformance.d.ts +193 -0
- package/dist/testing/sandbox-conformance.d.ts.map +1 -0
- package/dist/testing/sandbox-conformance.js +465 -0
- package/dist/testing/sandbox-conformance.js.map +1 -0
- package/package.json +5 -4
- package/src/backends/firecracker/protocol.ts +27 -0
- package/src/backends/firecracker/transport.ts +199 -28
- package/src/backends/kubernetes/egress-policy.ts +437 -0
- package/src/backends/kubernetes/index.ts +1012 -0
- package/src/backends/kubernetes/k8s-client.ts +352 -0
- package/src/backends/kubernetes/lease.ts +198 -0
- package/src/backends/kubernetes/objects.ts +363 -0
- package/src/backends/kubernetes/privilege-probe.ts +261 -0
- package/src/backends/kubernetes/sandbox.ts +395 -0
- package/src/backends/kubernetes/transport.ts +286 -0
- package/src/backends/kubernetes/workspace.ts +1386 -0
- package/src/index.ts +257 -35
- package/src/testing/sandbox-conformance.ts +667 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The {@link Sandbox} contract, as a suite a backend author runs against
|
|
3
|
+
* their own implementation.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* `@namzu/sdk`'s `Sandbox` interface is the one thing every backend in this
|
|
8
|
+
* package promises to implement the same way — `exec`'s exit codes, the
|
|
9
|
+
* `AbortSignal` contract, a `writeFile`/`readFile` round trip, terminal
|
|
10
|
+
* ownership on `destroy()` — and until now nothing PROVED that two backends
|
|
11
|
+
* agreed on any of it. Each backend carried its own bespoke test file
|
|
12
|
+
* (`sandbox-surface.test.ts`, `backend.test.ts`, …), written by whoever
|
|
13
|
+
* built that backend, checking whatever that author thought to check. A
|
|
14
|
+
* shared contract can be silently narrower than either file: this suite is
|
|
15
|
+
* the thing that would have caught it.
|
|
16
|
+
*
|
|
17
|
+
* ## Why it takes its runner as an argument
|
|
18
|
+
*
|
|
19
|
+
* The same shape as `@namzu/sdk/testing`'s checkpoint-store and provider
|
|
20
|
+
* driver suites, and for the same two reasons: `@namzu/sandbox` gains no
|
|
21
|
+
* test dependency from publishing it, and a caller can pass a RECORDING
|
|
22
|
+
* `describe`/`it` and run the whole contract as ordinary code — which is
|
|
23
|
+
* how `testing/__tests__/conformance-fails-a-broken-sandbox.test.ts`
|
|
24
|
+
* proves a deliberately wrong `Sandbox` fails it.
|
|
25
|
+
*
|
|
26
|
+
* ## What is asserted, and what deliberately is not
|
|
27
|
+
*
|
|
28
|
+
* Contract behaviour only — never a backend-specific object, field or
|
|
29
|
+
* error string. A case never inspects `sandbox.constructor.name`, never
|
|
30
|
+
* matches an error message, and never assumes a particular
|
|
31
|
+
* {@link SandboxEnvironment}. `openTerminal` and `openTcpConnection` are
|
|
32
|
+
* OPTIONAL on {@link Sandbox} by the SDK's own contract — a backend that
|
|
33
|
+
* cannot honour one must omit it rather than accept and ignore it — so a
|
|
34
|
+
* factory whose sandbox omits either capability skips that section rather
|
|
35
|
+
* than failing it. Every other section runs against every sandbox.
|
|
36
|
+
*
|
|
37
|
+
* ## Where it runs today
|
|
38
|
+
*
|
|
39
|
+
* Both `backends/kubernetes/__tests__/conformance.test.ts` and
|
|
40
|
+
* `backends/firecracker/__tests__/conformance.test.ts` call this against a
|
|
41
|
+
* real `agent/agent.cjs` on a loopback socket — proving the suite is
|
|
42
|
+
* backend-agnostic rather than one backend's tests wearing a new name.
|
|
43
|
+
* `packages/sandbox/k8s/scripts/contract-suite.mjs` runs it a third time,
|
|
44
|
+
* against a live cluster.
|
|
45
|
+
*
|
|
46
|
+
* ## Not published from `@namzu/sandbox`'s entry point
|
|
47
|
+
*
|
|
48
|
+
* The package has no `testing` subpath today (unlike `@namzu/sdk`), and
|
|
49
|
+
* this batch does not add one — promoting this to a public import path is
|
|
50
|
+
* a deliberate, separate decision. Within the monorepo a caller imports it
|
|
51
|
+
* by relative path, exactly as the two files above do:
|
|
52
|
+
*
|
|
53
|
+
* ```ts sketch
|
|
54
|
+
* import { defineSandboxConformance } from '../../../testing/sandbox-conformance.js'
|
|
55
|
+
*
|
|
56
|
+
* defineSandboxConformance({
|
|
57
|
+
* describe, it, expect,
|
|
58
|
+
* label: 'my-backend',
|
|
59
|
+
* makeSandbox: async () => ({ sandbox: await myBackend.create(), dispose: async () => {} }),
|
|
60
|
+
* })
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import type {
|
|
65
|
+
OpenTerminalOptions,
|
|
66
|
+
Sandbox,
|
|
67
|
+
SandboxTcpConnectOptions,
|
|
68
|
+
TerminalSession,
|
|
69
|
+
} from '@namzu/sdk'
|
|
70
|
+
import type {
|
|
71
|
+
ConformanceAssertion,
|
|
72
|
+
ConformanceDescribe,
|
|
73
|
+
ConformanceExpect,
|
|
74
|
+
ConformanceIt,
|
|
75
|
+
} from '@namzu/sdk/testing'
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The contract revision these assertions express. Carried on the describe
|
|
79
|
+
* label so a failure is legible on sight as "the sandbox contract", the
|
|
80
|
+
* same convention `PROVIDER_DRIVER_CONTRACT_VERSION` uses — raised only
|
|
81
|
+
* when a case is ADDED or TIGHTENED, never on a rewording.
|
|
82
|
+
*/
|
|
83
|
+
export const SANDBOX_CONTRACT_VERSION = 1
|
|
84
|
+
|
|
85
|
+
/** A sandbox to test, plus whatever teardown building it required. */
|
|
86
|
+
export interface SandboxConformanceHandle {
|
|
87
|
+
readonly sandbox: Sandbox
|
|
88
|
+
/**
|
|
89
|
+
* Called after each case, pass or fail — closes fixture servers, restores
|
|
90
|
+
* environment variables, removes temp directories. Distinct from
|
|
91
|
+
* `sandbox.destroy()`, which the suite calls itself (idempotently) as
|
|
92
|
+
* part of every case's teardown; `dispose` is for what `makeSandbox`
|
|
93
|
+
* itself stood up, not for the sandbox's own lifecycle.
|
|
94
|
+
*/
|
|
95
|
+
dispose?(): void | Promise<void>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build one fresh {@link Sandbox}. Called once per case, so no case can be
|
|
100
|
+
* affected by another's writes, aborts or destroys — the suite never
|
|
101
|
+
* assumes a shared instance and never reuses one across cases.
|
|
102
|
+
*/
|
|
103
|
+
export type MakeSandbox = () => SandboxConformanceHandle | Promise<SandboxConformanceHandle>
|
|
104
|
+
|
|
105
|
+
export interface SandboxConformanceOptions {
|
|
106
|
+
readonly describe: ConformanceDescribe
|
|
107
|
+
readonly it: ConformanceIt
|
|
108
|
+
readonly expect: ConformanceExpect
|
|
109
|
+
readonly makeSandbox: MakeSandbox
|
|
110
|
+
/** Names the backend in test output. Defaults to `sandbox`. */
|
|
111
|
+
readonly label?: string
|
|
112
|
+
/**
|
|
113
|
+
* Whether this backend's guest can run the `openTcpConnection` positive
|
|
114
|
+
* case's listener at all — by default {@link nodeGuestListener}, `node
|
|
115
|
+
* -e`. Defaults to `true`: every backend this suite ships against runs
|
|
116
|
+
* `agent/agent.cjs` in the guest, and that agent IS node, so node on the
|
|
117
|
+
* guest's own `PATH` is a precondition of the agent existing rather than
|
|
118
|
+
* an extra capability this suite demands.
|
|
119
|
+
*
|
|
120
|
+
* Set `false` for a guest that cannot run a listener this way at all
|
|
121
|
+
* (no `openTerminal`, or an image with neither node nor a substitute) —
|
|
122
|
+
* the case then SKIPS, its own title stating why, rather than failing a
|
|
123
|
+
* backend for a capability its contract never promised. A backend that
|
|
124
|
+
* can run *some* listener, just not node, keeps this `true` (or omits
|
|
125
|
+
* it) and supplies {@link SandboxConformanceOptions.guestListenerCommand}
|
|
126
|
+
* instead.
|
|
127
|
+
*/
|
|
128
|
+
readonly guestCanRunNode?: boolean
|
|
129
|
+
/**
|
|
130
|
+
* Overrides the program the `openTcpConnection` positive case starts
|
|
131
|
+
* inside the guest. Defaults to {@link nodeGuestListener}. A guest
|
|
132
|
+
* without node but with, say, busybox `nc` can supply its own command as
|
|
133
|
+
* long as it reports the bound port the way
|
|
134
|
+
* {@link GuestListenerCommand.parsePort} expects.
|
|
135
|
+
*/
|
|
136
|
+
readonly guestListenerCommand?: () => GuestListenerCommand
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Assert `call()` rejects. The contract cares that admission was refused, never the message. */
|
|
140
|
+
async function expectRejects(
|
|
141
|
+
expect: ConformanceExpect,
|
|
142
|
+
call: () => Promise<unknown>,
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
let rejected = false
|
|
145
|
+
try {
|
|
146
|
+
await call()
|
|
147
|
+
} catch {
|
|
148
|
+
rejected = true
|
|
149
|
+
}
|
|
150
|
+
expect(rejected).toBe(true)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Assert `call()` resolves — the inverse check, for the rare case a rejection is the defect. */
|
|
154
|
+
async function expectResolves(
|
|
155
|
+
expect: ConformanceExpect,
|
|
156
|
+
call: () => Promise<unknown>,
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
let threw: unknown
|
|
159
|
+
try {
|
|
160
|
+
await call()
|
|
161
|
+
} catch (error) {
|
|
162
|
+
threw = error
|
|
163
|
+
}
|
|
164
|
+
expect(threw === undefined).toBe(true)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function sleep(ms: number): Promise<void> {
|
|
168
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A program the `openTcpConnection` positive case can start INSIDE a guest
|
|
173
|
+
* through {@link Sandbox.openTerminal}, and dial back into over
|
|
174
|
+
* `openTcpConnection` itself.
|
|
175
|
+
*
|
|
176
|
+
* Starting the listener in the guest — rather than in the orchestrator/test
|
|
177
|
+
* process, which is what this case used to do — is the whole point: a
|
|
178
|
+
* listener on the HOST'S loopback only ever proves anything for a backend
|
|
179
|
+
* whose "guest" happens to share that loopback (a Firecracker fixture over a
|
|
180
|
+
* local socket, a fake-agent-in-this-process kubernetes test). It never
|
|
181
|
+
* proves anything for a real remote guest, which cannot dial the
|
|
182
|
+
* orchestrator's loopback at all — that gap is exactly what let the case
|
|
183
|
+
* pass in every colocated fixture and fail the one time it ran against a
|
|
184
|
+
* live cluster.
|
|
185
|
+
*/
|
|
186
|
+
export interface GuestListenerCommand {
|
|
187
|
+
/** The program `openTerminal` runs as the session's top-level process. */
|
|
188
|
+
readonly command: string
|
|
189
|
+
readonly args: readonly string[]
|
|
190
|
+
/**
|
|
191
|
+
* Reads the port the listener bound out of everything it has printed to
|
|
192
|
+
* its terminal so far. Returns `undefined` until the listener has
|
|
193
|
+
* reported one — the suite polls this as output arrives rather than
|
|
194
|
+
* parsing a single chunk, because a pty may deliver the report split
|
|
195
|
+
* across reads.
|
|
196
|
+
*/
|
|
197
|
+
parsePort(output: string): number | undefined
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** What {@link nodeGuestListener} has its script print once it is bound. */
|
|
201
|
+
const NODE_LISTENER_MARKER = 'namzu-conformance-listening:'
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The default {@link GuestListenerCommand}: `node -e` binding an ephemeral
|
|
205
|
+
* port on the GUEST's own loopback, echoing `conformance-reply:<payload>`
|
|
206
|
+
* back for the first chunk of the one connection it accepts, then reporting
|
|
207
|
+
* the bound port on its own stdout — the only way the host, which cannot
|
|
208
|
+
* inspect a real remote guest's open ports any other way, learns which port
|
|
209
|
+
* to dial.
|
|
210
|
+
*
|
|
211
|
+
* Every backend this suite ships against runs `agent/agent.cjs` in the
|
|
212
|
+
* guest, which is itself node — so node on the guest's `PATH` is not an
|
|
213
|
+
* extra requirement this suite invents, it is a precondition of the agent
|
|
214
|
+
* existing at all. A backend whose guest genuinely cannot run node (or
|
|
215
|
+
* cannot run `openTerminal`) declares that through
|
|
216
|
+
* {@link SandboxConformanceOptions.guestCanRunNode} or supplies its own
|
|
217
|
+
* command via {@link SandboxConformanceOptions.guestListenerCommand}.
|
|
218
|
+
*/
|
|
219
|
+
export function nodeGuestListener(): GuestListenerCommand {
|
|
220
|
+
const script = [
|
|
221
|
+
"const net = require('node:net');",
|
|
222
|
+
'const server = net.createServer((socket) => {',
|
|
223
|
+
" socket.once('data', (chunk) => {",
|
|
224
|
+
" socket.end(Buffer.concat([Buffer.from('conformance-reply:'), chunk]));",
|
|
225
|
+
' });',
|
|
226
|
+
'});',
|
|
227
|
+
"server.listen(0, '127.0.0.1', () => {",
|
|
228
|
+
` process.stdout.write(${JSON.stringify(NODE_LISTENER_MARKER)} + server.address().port + '\\n');`,
|
|
229
|
+
'});',
|
|
230
|
+
].join('\n')
|
|
231
|
+
return {
|
|
232
|
+
command: 'node',
|
|
233
|
+
args: ['-e', script],
|
|
234
|
+
parsePort(output) {
|
|
235
|
+
const marker = output.indexOf(NODE_LISTENER_MARKER)
|
|
236
|
+
if (marker === -1) return undefined
|
|
237
|
+
const match = /\d+/.exec(output.slice(marker + NODE_LISTENER_MARKER.length))
|
|
238
|
+
return match ? Number(match[0]) : undefined
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Start `listener` through `openTerminal`, and resolve once it has reported
|
|
245
|
+
* the port it bound.
|
|
246
|
+
*
|
|
247
|
+
* The returned `stop()` kills the terminal's owned process tree — the exact
|
|
248
|
+
* ownership guarantee the `openTerminal` section above already proves
|
|
249
|
+
* `destroy()` gets for free, used here to tear the listener down without
|
|
250
|
+
* waiting for the whole sandbox to go away.
|
|
251
|
+
*
|
|
252
|
+
* Takes `openTerminal` as a plain function rather than a `Sandbox`, so a
|
|
253
|
+
* unit test can exercise the port-parsing and exit-races above without a
|
|
254
|
+
* `Sandbox` fixture — see `__tests__/guest-listener.test.ts`.
|
|
255
|
+
*/
|
|
256
|
+
export async function startGuestListener(
|
|
257
|
+
openTerminal: (options: OpenTerminalOptions) => Promise<TerminalSession>,
|
|
258
|
+
listener: GuestListenerCommand,
|
|
259
|
+
): Promise<{ readonly port: number; stop(): Promise<void> }> {
|
|
260
|
+
const terminal = await openTerminal({
|
|
261
|
+
command: listener.command,
|
|
262
|
+
args: listener.args,
|
|
263
|
+
size: { cols: 80, rows: 24 },
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
let output = ''
|
|
267
|
+
const port = await new Promise<number>((resolve, reject) => {
|
|
268
|
+
const unsubscribe = terminal.onData((chunk) => {
|
|
269
|
+
output += chunk
|
|
270
|
+
const found = listener.parsePort(output)
|
|
271
|
+
if (found !== undefined) {
|
|
272
|
+
unsubscribe()
|
|
273
|
+
resolve(found)
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
void terminal.exited.then((result) => {
|
|
277
|
+
// A settled promise ignores a later resolve/reject, so this is a
|
|
278
|
+
// no-op on the path where the port was already found and `stop()`
|
|
279
|
+
// is what causes this exit — it only fires the rejection when the
|
|
280
|
+
// listener died before ever reporting a port.
|
|
281
|
+
if (listener.parsePort(output) === undefined) {
|
|
282
|
+
unsubscribe()
|
|
283
|
+
reject(
|
|
284
|
+
new Error(
|
|
285
|
+
`guest listener exited before reporting a port (exit code ${result.exitCode}): ${
|
|
286
|
+
output || '<no output>'
|
|
287
|
+
}`,
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
port,
|
|
296
|
+
async stop() {
|
|
297
|
+
terminal.kill()
|
|
298
|
+
await terminal.exited.catch(() => {})
|
|
299
|
+
},
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Register the {@link Sandbox} contract against one backend.
|
|
305
|
+
*
|
|
306
|
+
* Call it once per backend. It registers cases through the supplied
|
|
307
|
+
* `describe`/`it`; it does not run them.
|
|
308
|
+
*/
|
|
309
|
+
export function defineSandboxConformance(options: SandboxConformanceOptions): void {
|
|
310
|
+
const { describe, it, expect, makeSandbox } = options
|
|
311
|
+
const label = options.label ?? 'sandbox'
|
|
312
|
+
const guestCanRunNode = options.guestCanRunNode ?? true
|
|
313
|
+
const guestListenerCommand = options.guestListenerCommand ?? nodeGuestListener
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Run `body` against a sandbox built for this case alone.
|
|
317
|
+
*
|
|
318
|
+
* Destroys the sandbox itself (idempotent, so a body that already
|
|
319
|
+
* destroyed it costs nothing extra) before calling `dispose`, so a
|
|
320
|
+
* case that forgets to release a pod/microVM does not leak one — the
|
|
321
|
+
* same reasoning `withStore`'s `finally` states for a leaked temp
|
|
322
|
+
* directory: a suite that is expensive to run red is a suite people
|
|
323
|
+
* stop running.
|
|
324
|
+
*/
|
|
325
|
+
const withSandbox = (body: (sandbox: Sandbox) => Promise<void>) => async () => {
|
|
326
|
+
const handle = await makeSandbox()
|
|
327
|
+
try {
|
|
328
|
+
await body(handle.sandbox)
|
|
329
|
+
} finally {
|
|
330
|
+
await handle.sandbox.destroy().catch(() => {})
|
|
331
|
+
await handle.dispose?.()
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
describe(`${label} — sandbox contract v${SANDBOX_CONTRACT_VERSION}`, () => {
|
|
336
|
+
describe('exec', () => {
|
|
337
|
+
it(
|
|
338
|
+
'reports the exit code and streams stdout/stderr as the command runs',
|
|
339
|
+
withSandbox(async (sandbox) => {
|
|
340
|
+
const chunks: { stream: string; data: string }[] = []
|
|
341
|
+
const result = await sandbox.exec(
|
|
342
|
+
'/bin/sh',
|
|
343
|
+
['-c', 'echo conformance-out; echo conformance-err 1>&2; exit 7'],
|
|
344
|
+
{ onOutput: (chunk) => chunks.push({ ...chunk }) },
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
expect(result.exitCode).toBe(7)
|
|
348
|
+
expect(result.timedOut).toBe(false)
|
|
349
|
+
expect(result.stdout).toMatch(/conformance-out/)
|
|
350
|
+
expect(result.stderr).toMatch(/conformance-err/)
|
|
351
|
+
// Streamed, not just present in the final string: a backend
|
|
352
|
+
// that buffers everything until exit and calls `onOutput`
|
|
353
|
+
// once at the end would satisfy the two checks above and
|
|
354
|
+
// fail this one.
|
|
355
|
+
expect(
|
|
356
|
+
chunks.some((c) => c.stream === 'stdout' && c.data.includes('conformance-out')),
|
|
357
|
+
).toBe(true)
|
|
358
|
+
expect(
|
|
359
|
+
chunks.some((c) => c.stream === 'stderr' && c.data.includes('conformance-err')),
|
|
360
|
+
).toBe(true)
|
|
361
|
+
}),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
it(
|
|
365
|
+
'reports busy while a command is in flight and ready once it settles',
|
|
366
|
+
withSandbox(async (sandbox) => {
|
|
367
|
+
let observedBusy = false
|
|
368
|
+
await sandbox.exec('/bin/sh', ['-c', 'echo started; sleep 0.2'], {
|
|
369
|
+
onOutput: (chunk) => {
|
|
370
|
+
if (chunk.data.includes('started')) observedBusy = sandbox.status === 'busy'
|
|
371
|
+
},
|
|
372
|
+
})
|
|
373
|
+
expect(observedBusy).toBe(true)
|
|
374
|
+
expect(sandbox.status).toBe('ready')
|
|
375
|
+
}),
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
it(
|
|
379
|
+
'honours an AbortSignal: the process is really terminated, never a partial success',
|
|
380
|
+
withSandbox(async (sandbox) => {
|
|
381
|
+
// The contract (`SandboxExecOptions.signal`'s own doc comment):
|
|
382
|
+
// a backend that accepts the signal must terminate the process
|
|
383
|
+
// it owns, or prove admission never happened; it must never
|
|
384
|
+
// silently ignore the signal and let the command run to
|
|
385
|
+
// completion while reporting as though it had been cancelled.
|
|
386
|
+
// The command below writes a marker file a moment after
|
|
387
|
+
// printing "ready" — if the process is genuinely killed on
|
|
388
|
+
// abort, that write never happens. That is the decisive
|
|
389
|
+
// check; whatever the settled promise looks like is a second,
|
|
390
|
+
// weaker one.
|
|
391
|
+
const marker = 'conformance-abort-marker.txt'
|
|
392
|
+
const caller = new AbortController()
|
|
393
|
+
let signalReady: (() => void) | undefined
|
|
394
|
+
const ready = new Promise<void>((resolve) => {
|
|
395
|
+
signalReady = resolve
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
const running = sandbox.exec(
|
|
399
|
+
'/bin/sh',
|
|
400
|
+
[
|
|
401
|
+
'-c',
|
|
402
|
+
`trap '' TERM; (trap '' TERM; sleep 0.4; printf late > ${marker}) & echo ready; wait`,
|
|
403
|
+
],
|
|
404
|
+
{
|
|
405
|
+
signal: caller.signal,
|
|
406
|
+
onOutput: (chunk) => {
|
|
407
|
+
if (chunk.stream === 'stdout' && chunk.data.includes('ready')) signalReady?.()
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
)
|
|
411
|
+
await ready
|
|
412
|
+
caller.abort(new Error('conformance suite cancelled this command'))
|
|
413
|
+
|
|
414
|
+
// Resolve OR reject are both compliant — a backend that cannot
|
|
415
|
+
// confirm the kill may refuse instead of reporting a result it
|
|
416
|
+
// is not sure of. What is never compliant is reporting a clean,
|
|
417
|
+
// unaborted-looking success.
|
|
418
|
+
let settled: { exitCode: number; signal?: string } | undefined
|
|
419
|
+
try {
|
|
420
|
+
settled = await running
|
|
421
|
+
} catch {
|
|
422
|
+
settled = undefined
|
|
423
|
+
}
|
|
424
|
+
if (settled !== undefined) {
|
|
425
|
+
expect(settled.exitCode === 0 && settled.signal === undefined).toBe(false)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Long enough that an un-killed process would have finished its
|
|
429
|
+
// sleep and written the file.
|
|
430
|
+
await sleep(900)
|
|
431
|
+
await expectRejects(expect, () => sandbox.readFile(marker))
|
|
432
|
+
}),
|
|
433
|
+
)
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
describe('file IO', () => {
|
|
437
|
+
it(
|
|
438
|
+
'round-trips a UTF-8 string through writeFile/readFile',
|
|
439
|
+
withSandbox(async (sandbox) => {
|
|
440
|
+
await sandbox.writeFile('conformance-notes.txt', 'héllo wörld')
|
|
441
|
+
const read = await sandbox.readFile('conformance-notes.txt')
|
|
442
|
+
expect(read.toString('utf8')).toBe('héllo wörld')
|
|
443
|
+
}),
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
it(
|
|
447
|
+
'round-trips arbitrary binary content byte for byte',
|
|
448
|
+
withSandbox(async (sandbox) => {
|
|
449
|
+
const payload = Buffer.from([0x00, 0xff, 0x10, 0x00, 0x42, 0xfe, 0x7f, 0x80, 0x01])
|
|
450
|
+
await sandbox.writeFile('nested/conformance/blob.bin', payload)
|
|
451
|
+
const read = await sandbox.readFile('nested/conformance/blob.bin')
|
|
452
|
+
// Compared as base64 rather than through a deep-equality
|
|
453
|
+
// matcher: the four matchers this suite is allowed to assume
|
|
454
|
+
// (`toBe`/`toEqual`/`toBeGreaterThan`/`toMatch`) do not
|
|
455
|
+
// guarantee byte-exact `Buffer` comparison across every
|
|
456
|
+
// runner a caller might wire in, and a corrupted byte belongs
|
|
457
|
+
// in the string this failure prints.
|
|
458
|
+
expect(read.toString('base64')).toBe(payload.toString('base64'))
|
|
459
|
+
}),
|
|
460
|
+
)
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
describe('listFiles', () => {
|
|
464
|
+
it(
|
|
465
|
+
'lists written files as absolute paths with their sizes',
|
|
466
|
+
withSandbox(async (sandbox) => {
|
|
467
|
+
const contentA = '123456789'
|
|
468
|
+
const contentB = '42 bytes worth of fixed content!!'
|
|
469
|
+
await sandbox.writeFile('conformance-list/a.txt', contentA)
|
|
470
|
+
await sandbox.writeFile('conformance-list/b.txt', contentB)
|
|
471
|
+
const dir = `${sandbox.rootDir}/conformance-list`
|
|
472
|
+
const files = await sandbox.listFiles(dir)
|
|
473
|
+
const byPath = new Map(files.map((f) => [f.path, f.size]))
|
|
474
|
+
expect(byPath.get(`${dir}/a.txt`)).toBe(Buffer.byteLength(contentA))
|
|
475
|
+
expect(byPath.get(`${dir}/b.txt`)).toBe(Buffer.byteLength(contentB))
|
|
476
|
+
}),
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
it(
|
|
480
|
+
'reports a root that does not exist as empty rather than failing',
|
|
481
|
+
withSandbox(async (sandbox) => {
|
|
482
|
+
const files = await sandbox.listFiles(`${sandbox.rootDir}/conformance-never-created`)
|
|
483
|
+
expect(files.length).toBe(0)
|
|
484
|
+
}),
|
|
485
|
+
)
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Optional on {@link Sandbox} by the SDK's own contract: a backend that
|
|
490
|
+
* cannot provide a real pseudo-terminal must OMIT the method rather
|
|
491
|
+
* than hand back a pipe masquerading as one. So a factory whose
|
|
492
|
+
* sandbox has no `openTerminal` is not in violation of anything — the
|
|
493
|
+
* case below passes vacuously for it, which is the documented
|
|
494
|
+
* skip-if-unavailable this suite promises rather than a silent hole:
|
|
495
|
+
* both shipped backends (kubernetes, firecracker) DO implement it, so
|
|
496
|
+
* in CI this case only ever runs vacuously against a fixture that
|
|
497
|
+
* deliberately declines the capability.
|
|
498
|
+
*/
|
|
499
|
+
describe('openTerminal', () => {
|
|
500
|
+
it(
|
|
501
|
+
'is owned by the sandbox: destroy() kills and awaits every terminal it returned',
|
|
502
|
+
withSandbox(async (sandbox) => {
|
|
503
|
+
if (!sandbox.openTerminal) return
|
|
504
|
+
|
|
505
|
+
const terminal = await sandbox.openTerminal({
|
|
506
|
+
command: '/bin/sh',
|
|
507
|
+
args: ['-c', 'sleep 30'],
|
|
508
|
+
size: { cols: 80, rows: 24 },
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
let exited = false
|
|
512
|
+
void terminal.exited.then(() => {
|
|
513
|
+
exited = true
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
await sandbox.destroy()
|
|
517
|
+
// Nothing awaited in between: awaiting `terminal.exited` here
|
|
518
|
+
// would rescue a `destroy()` that only fired the kill and
|
|
519
|
+
// returned without waiting for it, which is exactly the
|
|
520
|
+
// defect this case exists to catch.
|
|
521
|
+
expect(exited).toBe(true)
|
|
522
|
+
await terminal.exited
|
|
523
|
+
}),
|
|
524
|
+
)
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Same optionality and the same documented skip as `openTerminal`,
|
|
529
|
+
* above: a factory whose sandbox has no `openTcpConnection` passes
|
|
530
|
+
* vacuously. The positive case below adds a second, independent skip
|
|
531
|
+
* axis on top of that — see `guestCanRunNode` and
|
|
532
|
+
* `guestListenerCommand` on {@link SandboxConformanceOptions} — because
|
|
533
|
+
* proving the forward really crosses into a REMOTE guest needs a
|
|
534
|
+
* listener running there, and not every guest can start one the same
|
|
535
|
+
* way.
|
|
536
|
+
*/
|
|
537
|
+
describe('openTcpConnection', () => {
|
|
538
|
+
// The reason for a title, rather than a console message, printing
|
|
539
|
+
// the skip: `ConformanceIt` promises only `(name, body) => unknown`
|
|
540
|
+
// (`contract-suite.mjs`'s own flat recorder has no skip concept
|
|
541
|
+
// either), so the one channel a skip can travel through every
|
|
542
|
+
// runner this suite is ever handed is the case's own name — decided
|
|
543
|
+
// once, here, from options given synchronously to
|
|
544
|
+
// `defineSandboxConformance`, not from anything discovered at run
|
|
545
|
+
// time.
|
|
546
|
+
const positiveCaseTitle = guestCanRunNode
|
|
547
|
+
? 'forwards a bidirectional stream to a service started inside the guest'
|
|
548
|
+
: 'forwards a bidirectional stream to a service started inside the guest (skipped: guestCanRunNode is false)'
|
|
549
|
+
|
|
550
|
+
it(
|
|
551
|
+
positiveCaseTitle,
|
|
552
|
+
withSandbox(async (sandbox) => {
|
|
553
|
+
if (!sandbox.openTcpConnection) return
|
|
554
|
+
if (!guestCanRunNode) return
|
|
555
|
+
const openTerminal = sandbox.openTerminal
|
|
556
|
+
if (!openTerminal) {
|
|
557
|
+
// A backend offering `openTcpConnection` without
|
|
558
|
+
// `openTerminal` has no portable way for this suite to
|
|
559
|
+
// start a guest-side listener — declare the skip
|
|
560
|
+
// explicitly (`guestCanRunNode: false`) rather than
|
|
561
|
+
// leaving the default to discover it here as a failure.
|
|
562
|
+
throw new Error(
|
|
563
|
+
'openTcpConnection conformance: starting a guest-side listener needs openTerminal, ' +
|
|
564
|
+
'which this sandbox does not implement. Pass guestCanRunNode: false to ' +
|
|
565
|
+
'defineSandboxConformance to skip this case with a stated reason, or supply ' +
|
|
566
|
+
'guestListenerCommand for a guest that can run a listener some other way.',
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Called through `.call(sandbox, …)` rather than passed as
|
|
571
|
+
// a bare reference: `openTerminal` may be an ordinary
|
|
572
|
+
// method relying on `this` (a class-based fixture, for
|
|
573
|
+
// instance), and detaching it from `sandbox` would drop
|
|
574
|
+
// that binding.
|
|
575
|
+
const listener = await startGuestListener(
|
|
576
|
+
(terminalOptions) => openTerminal.call(sandbox, terminalOptions),
|
|
577
|
+
guestListenerCommand(),
|
|
578
|
+
)
|
|
579
|
+
try {
|
|
580
|
+
const connection = await sandbox.openTcpConnection({ port: listener.port })
|
|
581
|
+
let received = ''
|
|
582
|
+
const unsubscribe = connection.onData((chunk) => {
|
|
583
|
+
received += Buffer.from(chunk).toString('utf8')
|
|
584
|
+
})
|
|
585
|
+
connection.write('conformance-hello')
|
|
586
|
+
await connection.closed
|
|
587
|
+
expect(received).toBe('conformance-reply:conformance-hello')
|
|
588
|
+
unsubscribe()
|
|
589
|
+
} finally {
|
|
590
|
+
await listener.stop()
|
|
591
|
+
}
|
|
592
|
+
}),
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
it(
|
|
596
|
+
'refuses a non-loopback host',
|
|
597
|
+
withSandbox(async (sandbox) => {
|
|
598
|
+
if (!sandbox.openTcpConnection) return
|
|
599
|
+
|
|
600
|
+
// `SandboxTcpConnectOptions.host` types as loopback-only; the
|
|
601
|
+
// cast is deliberate — this proves the refusal is enforced at
|
|
602
|
+
// RUNTIME, not merely by the type checker a compliant caller
|
|
603
|
+
// could route around with the same cast.
|
|
604
|
+
const nonLoopback = {
|
|
605
|
+
port: 9,
|
|
606
|
+
host: '203.0.113.10',
|
|
607
|
+
} as unknown as SandboxTcpConnectOptions
|
|
608
|
+
await expectRejects(
|
|
609
|
+
expect,
|
|
610
|
+
() =>
|
|
611
|
+
sandbox.openTcpConnection?.(nonLoopback) ?? Promise.reject(new Error('unreachable')),
|
|
612
|
+
)
|
|
613
|
+
}),
|
|
614
|
+
)
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
describe('destroy', () => {
|
|
618
|
+
it(
|
|
619
|
+
'is idempotent, however many times or however concurrently it is called',
|
|
620
|
+
withSandbox(async (sandbox) => {
|
|
621
|
+
await Promise.all([sandbox.destroy(), sandbox.destroy()])
|
|
622
|
+
expect(sandbox.status).toBe('destroyed')
|
|
623
|
+
await expectResolves(expect, () => sandbox.destroy())
|
|
624
|
+
expect(sandbox.status).toBe('destroyed')
|
|
625
|
+
}),
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
it(
|
|
629
|
+
'refuses every call once destroyed, rather than admitting one',
|
|
630
|
+
withSandbox(async (sandbox) => {
|
|
631
|
+
await sandbox.destroy()
|
|
632
|
+
|
|
633
|
+
await expectRejects(expect, () => sandbox.exec('/bin/sh', ['-c', 'true']))
|
|
634
|
+
await expectRejects(expect, () => sandbox.writeFile('x.txt', 'x'))
|
|
635
|
+
await expectRejects(expect, () => sandbox.readFile('x.txt'))
|
|
636
|
+
await expectRejects(expect, () => sandbox.listFiles(sandbox.rootDir))
|
|
637
|
+
// `?.()` rather than an `if` guard around the assertion: it is
|
|
638
|
+
// type-correct whether or not the capability exists, and when
|
|
639
|
+
// it does not exist there is nothing to refuse — the
|
|
640
|
+
// documented skip-if-unavailable this suite promises for
|
|
641
|
+
// every optional capability.
|
|
642
|
+
if (sandbox.openTerminal) {
|
|
643
|
+
await expectRejects(
|
|
644
|
+
expect,
|
|
645
|
+
() =>
|
|
646
|
+
sandbox.openTerminal?.({ size: { cols: 80, rows: 24 } }) ??
|
|
647
|
+
Promise.reject(new Error('unreachable')),
|
|
648
|
+
)
|
|
649
|
+
}
|
|
650
|
+
if (sandbox.openTcpConnection) {
|
|
651
|
+
await expectRejects(
|
|
652
|
+
expect,
|
|
653
|
+
() =>
|
|
654
|
+
sandbox.openTcpConnection?.({ port: 9 }) ??
|
|
655
|
+
Promise.reject(new Error('unreachable')),
|
|
656
|
+
)
|
|
657
|
+
}
|
|
658
|
+
}),
|
|
659
|
+
)
|
|
660
|
+
})
|
|
661
|
+
})
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Re-exported so a caller building a recording harness (as this suite's own
|
|
665
|
+
// negative test does) can type it without reaching into `@namzu/sdk/testing`
|
|
666
|
+
// a second time.
|
|
667
|
+
export type { ConformanceAssertion, ConformanceDescribe, ConformanceExpect, ConformanceIt }
|