@cat-factory/executor-harness 1.145.1 → 1.147.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.
@@ -0,0 +1,518 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import {
3
+ type CommandOutcome,
4
+ type DockerCommandRunner,
5
+ spawnDockerCommand,
6
+ } from './docker-command.js'
7
+ import {
8
+ buildProbeArchive,
9
+ payloadArchitecture,
10
+ PROBE_COMMAND,
11
+ PROBE_IMAGE_TAG,
12
+ PROBE_SENTINEL,
13
+ } from './docker-probe-image.js'
14
+ import { log, type Logger } from './logger.js'
15
+ import { redactSecrets } from './redact.js'
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Whether this machine's Docker daemon can RUN A CONTAINER, as opposed to merely answering.
19
+ //
20
+ // `docker info` and `docker version` talk to the daemon, and for a long time everything here
21
+ // treated an answer from one as proof that `docker build`, `docker run` and `docker compose up`
22
+ // work. They are different facts. A rootless daemon nested inside a sandboxed container serves
23
+ // perfectly while its snapshotter cannot mount a single image layer, so every one of those
24
+ // commands fails with the same EINVAL from `mount(2)` (issue #2120). The harness stated the
25
+ // wrong one of the two in every agent's system prompt, in a block that also says not to spend
26
+ // turns re-checking it, so three agents in one run each paid to disprove it.
27
+ //
28
+ // The answer is a real workload: load a one-layer image built in this process
29
+ // (docker-probe-image.ts) and run a container from it that has to print a marker. That is the
30
+ // smallest thing that exercises the whole path an agent's `docker run` takes, and it needs no
31
+ // registry, no network and no second image in the container.
32
+ //
33
+ // THREE answers, and which failure maps to which is the whole design:
34
+ //
35
+ // - `usable`: a container ran and printed the marker. Nothing else proves this.
36
+ // - `unusable`: the container did NOT run, and the daemon is what refused it. A DECIDED
37
+ // negative, and the only one anything states to an agent as a prohibition.
38
+ // - `unknown`: the check could not be carried out: no payload on this machine (the native
39
+ // host transport, where the harness runs on a developer's laptop), a daemon
40
+ // whose architecture the payload is not built for, `docker load` refusing the
41
+ // archive, the probe binary failing to exec, a timeout, a cancelled job.
42
+ // Every one of those is a fact about THE CHECK, and reading it as a fact about
43
+ // the daemon would trade this module's original lie for its mirror image.
44
+ //
45
+ // The `unknown` arm carries {@link DockerWorkload.daemonAnswered} because that distinction is
46
+ // load-bearing one level up: `resolveDockerVerdict` needs the cheap fact this check establishes
47
+ // on its way past (a daemon is answering RIGHT NOW) to keep a warm container out of a stale boot
48
+ // record's refusal, and only the check knows whether it ever got that far.
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /** What one measurement concluded. See the three answers above; nothing collapses them. */
52
+ export type DockerWorkload =
53
+ | { status: 'usable' }
54
+ | { status: 'unusable'; detail: string }
55
+ | {
56
+ status: 'unknown'
57
+ reason: string
58
+ /**
59
+ * Whether a daemon ANSWERED before the check ran out of things it could do. A weaker fact
60
+ * than the check exists to establish, and the one a stale boot record must be read against.
61
+ */
62
+ daemonAnswered: boolean
63
+ }
64
+
65
+ /**
66
+ * The statically linked binary the probe image is built from, overridable for an image variant
67
+ * that ships it elsewhere. Absent is a supported answer, not a failure: under
68
+ * `LOCAL_NATIVE_AGENTS` the harness runs on a developer's machine that never saw this image.
69
+ */
70
+ const PAYLOAD_PATH = process.env.HARNESS_DOCKER_PROBE_BINARY?.trim() || '/bin/busybox'
71
+
72
+ /**
73
+ * The ceiling on ONE WHOLE measurement, shared out across the docker commands it makes: each
74
+ * gets what is left of it, down to {@link MIN_COMMAND_MS}.
75
+ *
76
+ * One budget rather than a per-command ceiling, because a per-command one multiplies: three
77
+ * commands at 30s each is a minute and a half of dead time on a wedged daemon, on the critical
78
+ * path ahead of the clone. Sized for a WEDGED daemon and not for a slow one: the payload is a
79
+ * couple of megabytes already on local disk, so a daemon that works answers in about the time it
80
+ * takes to start one container, and a daemon that cannot mount fails immediately.
81
+ *
82
+ * What it does NOT do is bound the cost per JOB, and the comment that once claimed so was wrong.
83
+ * A POSITIVE verdict is memoised for the container's life; a negative is deliberately
84
+ * re-measured (see {@link createDockerWorkloadProbe}), and two independent sites ask per job (the
85
+ * environment inventory and the compose stand-up), so a serving-but-wedged daemon costs this
86
+ * twice per job. That is the price of not latching a warm container into a stale refusal, which
87
+ * is why the budget is the size it is and why an abandoned job stops paying it at once.
88
+ */
89
+ const WORKLOAD_BUDGET_MS = 20_000
90
+
91
+ /** The floor on one command's share of the budget, so an exhausted budget still gets an answer. */
92
+ const MIN_COMMAND_MS = 1_000
93
+
94
+ /**
95
+ * The ceiling on removing the probe image again.
96
+ *
97
+ * Its own, and deliberately NOT given the caller's abort signal: the image is the platform's, and
98
+ * a cancelled job is the one case where nobody is left to clean up after it. Bounded separately
99
+ * so a wedged daemon cannot turn the cleanup into a second full budget.
100
+ */
101
+ const CLEANUP_TIMEOUT_MS = 5_000
102
+
103
+ /** How much of a failing command's output is kept. It is quoted into an agent's system prompt. */
104
+ const DETAIL_CHARS = 300
105
+
106
+ /**
107
+ * A one-slot memo for the assembled archive.
108
+ *
109
+ * The archive is byte-stable for one `(payload path, architecture)` pair by construction
110
+ * (docker-probe-image.ts pins every timestamp for exactly this reason), and neither half changes
111
+ * within a process. Since a NEGATIVE verdict is re-measured on purpose and two sites ask per job,
112
+ * without this the same two megabytes are re-read and re-sha256'd four times per job for a value
113
+ * that cannot differ. One slot rather than a map: there is only ever one key.
114
+ */
115
+ export interface ProbeArchiveMemo {
116
+ read(key: string): Buffer | undefined
117
+ write(key: string, archive: Buffer): void
118
+ }
119
+
120
+ /** Build a {@link ProbeArchiveMemo}. Supplied only by {@link realDeps}, so a test memoises nothing. */
121
+ export function oneSlotArchiveMemo(): ProbeArchiveMemo {
122
+ let held: { key: string; archive: Buffer } | undefined
123
+ return {
124
+ read: (key) => (held?.key === key ? held.archive : undefined),
125
+ write: (key, archive) => {
126
+ held = { key, archive }
127
+ },
128
+ }
129
+ }
130
+
131
+ /** What a measurement needs from the machine, so a test can supply all of it. */
132
+ export interface DockerWorkloadDeps {
133
+ /** Read the probe payload. Rejecting (ENOENT) is the supported "this machine has none". */
134
+ readPayload: (path: string) => Promise<Buffer>
135
+ payloadPath: string
136
+ runDocker: DockerCommandRunner
137
+ /** This process's architecture, as `process.arch` spells it: the PAYLOAD's, never the daemon's. */
138
+ arch: string
139
+ logger?: Logger
140
+ archives?: ProbeArchiveMemo
141
+ }
142
+
143
+ const realDeps: DockerWorkloadDeps = {
144
+ readPayload: (path) => readFile(path),
145
+ payloadPath: PAYLOAD_PATH,
146
+ runDocker: spawnDockerCommand,
147
+ arch: process.arch,
148
+ archives: oneSlotArchiveMemo(),
149
+ }
150
+
151
+ /**
152
+ * Carry out one measurement. Pure of caching, so the suite states every branch directly.
153
+ *
154
+ * TOTAL: it never rejects, whatever happens inside it. The thing it replaced was total by
155
+ * construction (a `try/catch` around one `execFile`), and it is consulted from a stand-up path
156
+ * documented as best-effort, so a throw here would fail a job over a probe whose whole purpose is
157
+ * to make a failure legible. A throw is also, by definition, the platform's own machinery
158
+ * breaking, which is the `unknown` disposition and never the `unusable` one.
159
+ *
160
+ * That asymmetry is the design. Only the RUN produces `unusable`; everything before it produces
161
+ * `unknown`, because everything before it is the platform's own machinery and a bug in it must be
162
+ * able to say "I could not tell" and never "your daemon is broken". The load step in particular is
163
+ * the one this repo wrote itself.
164
+ */
165
+ export async function measureDockerWorkload(
166
+ deps: DockerWorkloadDeps = realDeps,
167
+ signal?: AbortSignal,
168
+ ): Promise<DockerWorkload> {
169
+ const seen = { daemonAnswered: false }
170
+ try {
171
+ return await measure(deps, seen, signal)
172
+ } catch (err) {
173
+ const cause = describeThrown(err)
174
+ ;(deps.logger ?? log).warn('docker capability: the container check itself fell over', {
175
+ error: cause,
176
+ })
177
+ return undeterminable(
178
+ `the platform's own container check could not be completed (${cause})`,
179
+ seen.daemonAnswered,
180
+ )
181
+ }
182
+ }
183
+
184
+ async function measure(
185
+ deps: DockerWorkloadDeps,
186
+ seen: { daemonAnswered: boolean },
187
+ signal?: AbortSignal,
188
+ ): Promise<DockerWorkload> {
189
+ const deadline = Date.now() + WORKLOAD_BUDGET_MS
190
+ const share = (): number => Math.max(MIN_COMMAND_MS, deadline - Date.now())
191
+ const command = (args: string[], stdin?: Buffer): Promise<CommandOutcome> =>
192
+ deps.runDocker(args, {
193
+ ...(stdin ? { stdin } : {}),
194
+ ...(signal ? { signal } : {}),
195
+ timeoutMs: share(),
196
+ ...(deps.logger ? { logger: deps.logger } : {}),
197
+ })
198
+
199
+ // Ask the DAEMON which architecture it runs, rather than assuming it shares this process's.
200
+ // An external `DOCKER_HOST` is a supported path, and an arm64 harness against an amd64 sidecar
201
+ // (or a remote x86_64 daemon reached from an arm64 laptop) shares nothing with it but a socket:
202
+ // declaring the wrong one in the image config gets the run refused, which would report a
203
+ // perfectly good daemon as one that cannot run containers. It is also the cheapest proof that a
204
+ // daemon is answering at all, which is the fact `resolveDockerVerdict` reads back.
205
+ const asked = await command(['version', '--format', '{{.Server.Arch}}'])
206
+ if (asked.outcome !== 'ran' || asked.code !== 0) {
207
+ return undeterminable(
208
+ `no Docker daemon answered the platform's container check (${describeOutcome(asked)})`,
209
+ false,
210
+ )
211
+ }
212
+ seen.daemonAnswered = true
213
+ const daemonArch = asked.stdout.trim()
214
+ if (!/^[a-z0-9_]+$/.test(daemonArch)) {
215
+ return undeterminable(
216
+ `the Docker daemon did not name an architecture the platform can build an image for (${redactSecrets(daemonArch).slice(0, 40) || 'it answered nothing'})`,
217
+ true,
218
+ )
219
+ }
220
+ const payloadArch = payloadArchitecture(deps.arch)
221
+ if (!payloadArch) {
222
+ return undeterminable(
223
+ `the platform has no container check for the ${deps.arch} architecture`,
224
+ true,
225
+ )
226
+ }
227
+ if (payloadArch !== daemonArch) {
228
+ return undeterminable(
229
+ `the platform's container check is built for ${payloadArch} and this daemon runs ${daemonArch}`,
230
+ true,
231
+ )
232
+ }
233
+
234
+ const assembled = await assembleArchive(deps, daemonArch)
235
+ if ('reason' in assembled) return undeterminable(assembled.reason, true)
236
+
237
+ const load = await command(['load'], assembled.archive)
238
+ if (load.outcome !== 'ran' || load.code !== 0) {
239
+ return undeterminable(
240
+ `the platform could not load its own probe image (${describeOutcome(load)})`,
241
+ true,
242
+ )
243
+ }
244
+
245
+ const run = await command([
246
+ 'run',
247
+ '--rm',
248
+ '--pull',
249
+ 'never',
250
+ '--network',
251
+ 'none',
252
+ PROBE_IMAGE_TAG,
253
+ ...PROBE_COMMAND,
254
+ ])
255
+ // Before the verdict, and whatever the verdict is: the probe image is the platform's, and an
256
+ // agent that runs `docker images` should not have to wonder whose it is.
257
+ await removeProbeImage(deps)
258
+ return classifyRun(run)
259
+ }
260
+
261
+ /**
262
+ * What the container RUN proves. The one step allowed to conclude `unusable`, and even here not
263
+ * every non-zero exit is evidence about the daemon.
264
+ *
265
+ * Docker splits its own failures from the container's by exit code: 125 is `docker run` itself
266
+ * failing, 126 is a command that could not be invoked and 127 one that was not found. The last
267
+ * two are facts about THE PAYLOAD, a binary this platform put in an image it built, and the
268
+ * container had to be created and started to produce them. 125 covers both the daemon refusing to
269
+ * create the container (the verdict this whole module exists for) and the tag not being there to
270
+ * run, which is our own load, so that one is split by what docker SAID.
271
+ */
272
+ function classifyRun(run: CommandOutcome): DockerWorkload {
273
+ if (run.outcome === 'failed') {
274
+ return undeterminable(`the platform's container check did not run (${run.reason})`, true)
275
+ }
276
+ if (run.code === 0) {
277
+ if (run.stdout.includes(PROBE_SENTINEL)) return { status: 'usable' }
278
+ // Nothing explains this: the container was reported as having run cleanly and produced none
279
+ // of the output it exists to produce. That is a fact about the check, not about the daemon.
280
+ return undeterminable(
281
+ "the platform's probe container exited cleanly without printing its marker",
282
+ true,
283
+ )
284
+ }
285
+ const ours = platformSideRunFailure(run)
286
+ return ours
287
+ ? undeterminable(`${ours} (${describeOutcome(run)})`, true)
288
+ : { status: 'unusable', detail: describeOutcome(run) }
289
+ }
290
+
291
+ /** Messages that name the PLATFORM's half of a failed run rather than the daemon's. */
292
+ const PLATFORM_SIDE_RUN_MESSAGES: readonly { pattern: RegExp; cause: string }[] = [
293
+ {
294
+ pattern: /no such image|unable to find image/i,
295
+ cause: "the platform's probe image was not there to be run",
296
+ },
297
+ {
298
+ pattern: /exec format error/i,
299
+ cause: "the platform's probe binary cannot be executed on this daemon's machine",
300
+ },
301
+ ]
302
+
303
+ function platformSideRunFailure(run: {
304
+ code: number
305
+ stdout: string
306
+ stderr: string
307
+ }): string | undefined {
308
+ if (run.code === 126 || run.code === 127) {
309
+ return "the platform's probe binary could not be invoked inside the container"
310
+ }
311
+ const said = `${run.stderr}\n${run.stdout}`
312
+ return PLATFORM_SIDE_RUN_MESSAGES.find((m) => m.pattern.test(said))?.cause
313
+ }
314
+
315
+ /**
316
+ * Assemble the archive for the daemon's architecture, reusing the last one built.
317
+ *
318
+ * A read that fails is classified rather than asserted away: `HARNESS_DOCKER_PROBE_BINARY`
319
+ * pointing at a directory, at a path this user may not read, or at a failing mount is a
320
+ * misconfiguration an operator can fix, and "this machine does not have it" states the opposite
321
+ * fact. The sentence goes into an agent's system prompt and into `GET /health`, so a discarded
322
+ * cause is a cause nobody ever sees.
323
+ */
324
+ async function assembleArchive(
325
+ deps: DockerWorkloadDeps,
326
+ architecture: string,
327
+ ): Promise<{ archive: Buffer } | { reason: string }> {
328
+ const key = `${deps.payloadPath}::${architecture}`
329
+ const held = deps.archives?.read(key)
330
+ if (held) return { archive: held }
331
+ let payload: Buffer
332
+ try {
333
+ payload = await deps.readPayload(deps.payloadPath)
334
+ } catch (err) {
335
+ return { reason: describePayloadFailure(err, deps.payloadPath) }
336
+ }
337
+ const archive = buildProbeArchive(payload, architecture)
338
+ deps.archives?.write(key, archive)
339
+ return { archive }
340
+ }
341
+
342
+ function describePayloadFailure(err: unknown, path: string): string {
343
+ const needs = `the platform's own container check needs ${path}`
344
+ switch ((err as NodeJS.ErrnoException).code) {
345
+ case 'ENOENT':
346
+ case 'ENOTDIR':
347
+ return `${needs}, which this machine does not have`
348
+ case 'EACCES':
349
+ case 'EPERM':
350
+ return `${needs}, which it is not permitted to read`
351
+ case 'EISDIR':
352
+ return `${needs} to be a file, and it is a directory`
353
+ default:
354
+ return `${needs}, which could not be read (${describeThrown(err)})`
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Remove the probe image, and SAY SO when that did not work.
360
+ *
361
+ * The one line above it promises an agent will never find a `cat-factory-docker-probe` and wonder
362
+ * whose it is, and the daemon has two ordinary ways to refuse: a `--rm` teardown still in flight
363
+ * holds the image ("image is being used by stopped container"), and a wedged daemon does not
364
+ * answer at all. Discarding the outcome left both silent, so the promise was unverifiable in
365
+ * exactly the states that break it.
366
+ */
367
+ async function removeProbeImage(deps: DockerWorkloadDeps): Promise<void> {
368
+ const removed = await deps.runDocker(['image', 'rm', '-f', PROBE_IMAGE_TAG], {
369
+ timeoutMs: CLEANUP_TIMEOUT_MS,
370
+ ...(deps.logger ? { logger: deps.logger } : {}),
371
+ })
372
+ if (removed.outcome === 'ran' && removed.code === 0) return
373
+ ;(deps.logger ?? log).warn('docker capability: the probe image could not be removed', {
374
+ image: PROBE_IMAGE_TAG,
375
+ error: describeOutcome(removed),
376
+ })
377
+ }
378
+
379
+ function undeterminable(reason: string, daemonAnswered: boolean): DockerWorkload {
380
+ return { status: 'unknown', reason, daemonAnswered }
381
+ }
382
+
383
+ /** A bounded, scrubbed one-line summary of what a command said, for a prompt or a log field. */
384
+ function describeOutcome(outcome: CommandOutcome): string {
385
+ if (outcome.outcome === 'failed') return outcome.reason
386
+ const said = `${outcome.stderr}\n${outcome.stdout}`
387
+ .split('\n')
388
+ .map((line) => line.trim())
389
+ .filter(Boolean)
390
+ .join('; ')
391
+ return bounded(said) || `docker exited ${outcome.code} without saying why`
392
+ }
393
+
394
+ /** The one describer for a thrown value here: scrubbed and bounded, like any other detail. */
395
+ function describeThrown(err: unknown): string {
396
+ return bounded(err instanceof Error ? err.message : String(err)) || 'it said nothing'
397
+ }
398
+
399
+ function bounded(text: string): string {
400
+ const scrubbed = redactSecrets(text)
401
+ return scrubbed.length > DETAIL_CHARS ? `${scrubbed.slice(0, DETAIL_CHARS)}…` : scrubbed
402
+ }
403
+
404
+ /**
405
+ * A measurement, plus what the last one concluded without taking another.
406
+ *
407
+ * Callable because every caller wants the verdict; `last()` exists for `GET /health`, which is
408
+ * polled and must not spawn a container per poll to answer a question it does not act on.
409
+ */
410
+ export interface DockerWorkloadProbe {
411
+ (signal?: AbortSignal): Promise<DockerWorkload>
412
+ last(): DockerWorkload | undefined
413
+ }
414
+
415
+ /** One in-flight measurement and the callers still waiting for it. */
416
+ interface Measurement {
417
+ readonly result: Promise<DockerWorkload>
418
+ readonly cancel: AbortController
419
+ waiters: number
420
+ }
421
+
422
+ /**
423
+ * Build a probe that measures at most once per container for a POSITIVE answer.
424
+ *
425
+ * A daemon that has run a container has proved something that does not stop being true, so that
426
+ * verdict is kept and every later job reads it for free. A negative is NOT kept, for the reason
427
+ * `resolveDockerVerdict` gives about the boot record: a container outlives its boot, a warm pool
428
+ * serves many jobs from one, and a daemon that was not ready for the first job must not latch
429
+ * the whole container into saying so. Re-measuring a negative is cheap; a daemon that cannot
430
+ * mount fails at once.
431
+ *
432
+ * Concurrent callers share one in-flight measurement rather than each starting a container, and
433
+ * the measurement is cancelled when the LAST of them has abandoned it. Neither half is optional:
434
+ * one job's abort may not kill a measurement a sibling job is still waiting on (the local native
435
+ * transport serves every concurrent job from one process), and a measurement nobody is waiting
436
+ * for is a container start no job will read, which is what an abandoned run should stop paying
437
+ * for the moment it is abandoned.
438
+ */
439
+ export function createDockerWorkloadProbe(
440
+ deps: DockerWorkloadDeps = realDeps,
441
+ ): DockerWorkloadProbe {
442
+ let latest: DockerWorkload | undefined
443
+ let inFlight: Measurement | undefined
444
+ const begin = (): Measurement => {
445
+ const cancel = new AbortController()
446
+ const measurement: Measurement = {
447
+ cancel,
448
+ waiters: 0,
449
+ result: measureDockerWorkload(deps, cancel.signal).then((verdict) => {
450
+ latest = verdict
451
+ if (inFlight === measurement) inFlight = undefined
452
+ return verdict
453
+ }),
454
+ }
455
+ return measurement
456
+ }
457
+ const probe = (async (signal?: AbortSignal): Promise<DockerWorkload> => {
458
+ if (latest?.status === 'usable') return latest
459
+ const measurement = (inFlight ??= begin())
460
+ measurement.waiters += 1
461
+ const watch = signal ? watchAbandonment(signal) : undefined
462
+ try {
463
+ return watch
464
+ ? await Promise.race([measurement.result, watch.abandoned])
465
+ : await measurement.result
466
+ } finally {
467
+ watch?.dispose()
468
+ measurement.waiters -= 1
469
+ if (measurement.waiters === 0 && inFlight === measurement) measurement.cancel.abort()
470
+ }
471
+ }) as DockerWorkloadProbe
472
+ probe.last = () => latest
473
+ return probe
474
+ }
475
+
476
+ /**
477
+ * A verdict for the caller whose job was cancelled while it waited, and the listener teardown
478
+ * that keeps a long-lived native-transport process from accumulating one per job.
479
+ */
480
+ function watchAbandonment(signal: AbortSignal): {
481
+ abandoned: Promise<DockerWorkload>
482
+ dispose: () => void
483
+ } {
484
+ let give: () => void = () => {}
485
+ const abandoned = new Promise<DockerWorkload>((resolve) => {
486
+ give = () =>
487
+ resolve(
488
+ undeterminable(
489
+ "the job was cancelled before the platform's container check answered",
490
+ false,
491
+ ),
492
+ )
493
+ if (signal.aborted) give()
494
+ else signal.addEventListener('abort', give, { once: true })
495
+ })
496
+ return { abandoned, dispose: () => signal.removeEventListener('abort', give) }
497
+ }
498
+
499
+ /** The process-wide probe. One per container, which is what makes the positive memo worth having. */
500
+ export const probeDockerWorkload: DockerWorkloadProbe = createDockerWorkloadProbe()
501
+
502
+ /**
503
+ * What `GET /health` reports about the workload check.
504
+ *
505
+ * `unmeasured` is its own word rather than an omitted key or a `null`: this endpoint is polled
506
+ * from boot, so "nothing has needed the daemon yet" is the normal early answer and it must not
507
+ * read as either a broken daemon or a build that cannot report one.
508
+ */
509
+ export function reportedDockerWorkload(
510
+ probe: DockerWorkloadProbe = probeDockerWorkload,
511
+ ): DockerWorkload | { status: 'unmeasured'; reason: string } {
512
+ return (
513
+ probe.last() ?? {
514
+ status: 'unmeasured',
515
+ reason: 'nothing in this container has needed the docker daemon yet',
516
+ }
517
+ )
518
+ }
@@ -0,0 +1,117 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { log, type Logger } from './logger.js'
3
+ import { killChildProcess, spawnDetached } from './process.js'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // How the harness runs one `docker …` command ON ITS OWN BEHALF, bounded and abortable.
7
+ //
8
+ // This is NOT a second `runCapturedCommand` (captured-command.ts), which stays the one way the
9
+ // harness runs a DECLARED shell command: that one takes a shell string, merges both streams into
10
+ // one rolling tail and answers with a conventional exit code, because its two callers report a
11
+ // pass/fail plus a tail to a model. The docker checks need the three things it deliberately does
12
+ // not offer: an argv (no shell, so nothing quotes an image tag), a STDIN body (the probe archive
13
+ // is piped to `docker load`), and stdout kept APART from stderr, since the whole evidence that a
14
+ // container ran is a marker on stdout while the evidence of why it did not is on stderr.
15
+ //
16
+ // What it does NOT re-decide is how a child dies: `killChildProcess` owns the SIGTERM→SIGKILL
17
+ // escalation for every process this harness spawns, and a bespoke `SIGKILL` here would be one
18
+ // path whose kill semantics drift from the rest with no test able to see it.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /** How much of each stream is buffered. The TAIL is kept: that is where a failure prints. */
22
+ const OUTPUT_CAP_CHARS = 64 * 1024
23
+
24
+ /** What running one docker command did, kept as raw as the spawn. */
25
+ export type CommandOutcome =
26
+ | { outcome: 'ran'; code: number; stdout: string; stderr: string }
27
+ | { outcome: 'failed'; reason: string }
28
+
29
+ /** What one docker invocation is given. `timeoutMs` is required: an unbounded one has no caller. */
30
+ export interface DockerCommandOptions {
31
+ /** Piped to the command's stdin and closed. */
32
+ stdin?: Buffer
33
+ /** The job's signal. An abandoned job's command is killed rather than left running. */
34
+ signal?: AbortSignal
35
+ timeoutMs: number
36
+ logger?: Logger
37
+ }
38
+
39
+ /** Run one `docker …` command. Injected so the suite drives every branch with no daemon. */
40
+ export type DockerCommandRunner = (
41
+ args: string[],
42
+ opts: DockerCommandOptions,
43
+ ) => Promise<CommandOutcome>
44
+
45
+ /**
46
+ * The real runner: spawn docker, feed it `stdin` when there is any, and report what happened.
47
+ *
48
+ * Never rejects. Every way a spawn can go wrong is one of the two outcomes, because the caller
49
+ * classifies them differently and an exception would collapse that distinction into whichever
50
+ * `catch` caught it first.
51
+ */
52
+ export const spawnDockerCommand: DockerCommandRunner = (args, opts) =>
53
+ new Promise<CommandOutcome>((resolve) => {
54
+ const logger = opts.logger ?? log
55
+ if (opts.signal?.aborted) {
56
+ resolve({ outcome: 'failed', reason: abandonedReason(args) })
57
+ return
58
+ }
59
+ const child = spawn('docker', args, {
60
+ stdio: ['pipe', 'pipe', 'pipe'],
61
+ detached: spawnDetached,
62
+ windowsHide: true,
63
+ })
64
+ let stdout = ''
65
+ let stderr = ''
66
+ let settled = false
67
+ const finish = (result: CommandOutcome): void => {
68
+ if (settled) return
69
+ settled = true
70
+ clearTimeout(timer)
71
+ opts.signal?.removeEventListener('abort', onAbort)
72
+ resolve(result)
73
+ }
74
+ const timer = setTimeout(() => {
75
+ logger.warn('docker: command did not answer in time, killing it', {
76
+ command: args[0] ?? '',
77
+ timeoutMs: opts.timeoutMs,
78
+ })
79
+ killChildProcess(child, undefined, logger)
80
+ finish({
81
+ outcome: 'failed',
82
+ reason: `\`docker ${args[0] ?? ''}\` did not answer within ${Math.round(opts.timeoutMs / 1000)}s`,
83
+ })
84
+ }, opts.timeoutMs)
85
+ timer.unref?.()
86
+ const onAbort = (): void => {
87
+ killChildProcess(child, undefined, logger)
88
+ finish({ outcome: 'failed', reason: abandonedReason(args) })
89
+ }
90
+ opts.signal?.addEventListener('abort', onAbort, { once: true })
91
+ // The tail, not the head: a `docker run` that failed says why in its last lines, and the
92
+ // marker a passing one prints is the whole of its output anyway.
93
+ child.stdout.on('data', (chunk: Buffer) => {
94
+ stdout = (stdout + chunk.toString('utf8')).slice(-OUTPUT_CAP_CHARS)
95
+ })
96
+ child.stderr.on('data', (chunk: Buffer) => {
97
+ stderr = (stderr + chunk.toString('utf8')).slice(-OUTPUT_CAP_CHARS)
98
+ })
99
+ child.on('error', (err: NodeJS.ErrnoException) => {
100
+ finish({
101
+ outcome: 'failed',
102
+ reason:
103
+ err.code === 'ENOENT'
104
+ ? 'the docker CLI is not on PATH'
105
+ : `the docker CLI could not be spawned (${err.code ?? err.message})`,
106
+ })
107
+ })
108
+ child.on('close', (code) => finish({ outcome: 'ran', code: code ?? -1, stdout, stderr }))
109
+ // A daemon that dies mid-load closes the pipe under us; `close` above already reports that,
110
+ // so the EPIPE here has nothing to add and must not become an unhandled error event.
111
+ child.stdin.on('error', () => {})
112
+ child.stdin.end(opts.stdin)
113
+ })
114
+
115
+ function abandonedReason(args: string[]): string {
116
+ return `the job was cancelled before \`docker ${args[0] ?? ''}\` answered`
117
+ }