@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.
- package/README.md +52 -7
- package/dist/agent.js +5 -2
- package/dist/docker-capability.d.ts +99 -0
- package/dist/docker-capability.js +351 -0
- package/dist/docker-command.d.ts +30 -0
- package/dist/docker-command.js +91 -0
- package/dist/docker-probe-image.d.ts +36 -0
- package/dist/docker-probe-image.js +148 -0
- package/dist/docker-status.d.ts +85 -18
- package/dist/docker-status.js +93 -36
- package/dist/environment-inventory.d.ts +51 -6
- package/dist/environment-inventory.js +75 -18
- package/dist/harness-server.js +7 -1
- package/dist/infra-standup.d.ts +15 -12
- package/dist/infra-standup.js +58 -23
- package/dist/job.d.ts +10 -0
- package/package.json +5 -5
- package/src/agent.ts +5 -2
- package/src/docker-capability.ts +518 -0
- package/src/docker-command.ts +117 -0
- package/src/docker-probe-image.ts +171 -0
- package/src/docker-status.ts +134 -37
- package/src/environment-inventory.ts +129 -31
- package/src/harness-server.ts +7 -1
- package/src/infra-standup.ts +61 -23
- package/src/job.ts +10 -0
|
@@ -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
|
+
}
|