@cat-factory/executor-harness 1.145.1 → 1.149.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 +106 -8
- package/dist/agent.js +8 -2
- package/dist/docker-capability.d.ts +158 -0
- package/dist/docker-capability.js +510 -0
- package/dist/docker-command.d.ts +30 -0
- package/dist/docker-command.js +91 -0
- package/dist/docker-probe-image.d.ts +97 -0
- package/dist/docker-probe-image.js +283 -0
- package/dist/docker-status.d.ts +99 -19
- package/dist/docker-status.js +93 -36
- package/dist/environment-inventory.d.ts +57 -6
- package/dist/environment-inventory.js +149 -17
- package/dist/harness-server.js +7 -1
- package/dist/infra-standup.d.ts +15 -12
- package/dist/infra-standup.js +73 -23
- package/dist/job.d.ts +20 -0
- package/dist/redact.d.ts +15 -0
- package/dist/redact.js +18 -0
- package/package.json +5 -5
- package/src/agent.ts +8 -2
- package/src/docker-capability.ts +767 -0
- package/src/docker-command.ts +117 -0
- package/src/docker-probe-image.ts +328 -0
- package/src/docker-status.ts +148 -38
- package/src/environment-inventory.ts +216 -30
- package/src/harness-server.ts +7 -1
- package/src/infra-standup.ts +77 -23
- package/src/job.ts +20 -0
- package/src/redact.ts +19 -0
|
@@ -0,0 +1,767 @@
|
|
|
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
|
+
buildEgressCommand,
|
|
9
|
+
buildProbeArchive,
|
|
10
|
+
EGRESS_DNS_MARKER,
|
|
11
|
+
EGRESS_TCP_MARKER,
|
|
12
|
+
type EgressTarget,
|
|
13
|
+
parseEgressTarget,
|
|
14
|
+
payloadArchitecture,
|
|
15
|
+
PROBE_COMMAND,
|
|
16
|
+
PROBE_IMAGE_TAG,
|
|
17
|
+
PROBE_SENTINEL,
|
|
18
|
+
} from './docker-probe-image.js'
|
|
19
|
+
import { log, type Logger } from './logger.js'
|
|
20
|
+
import { scrubbedExcerpt } from './redact.js'
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Whether this machine's Docker daemon can RUN A CONTAINER, as opposed to merely answering.
|
|
24
|
+
//
|
|
25
|
+
// `docker info` and `docker version` talk to the daemon, and for a long time everything here
|
|
26
|
+
// treated an answer from one as proof that `docker build`, `docker run` and `docker compose up`
|
|
27
|
+
// work. They are different facts. A rootless daemon nested inside a sandboxed container serves
|
|
28
|
+
// perfectly while its snapshotter cannot mount a single image layer, so every one of those
|
|
29
|
+
// commands fails with the same EINVAL from `mount(2)` (issue #2120). The harness stated the
|
|
30
|
+
// wrong one of the two in every agent's system prompt, in a block that also says not to spend
|
|
31
|
+
// turns re-checking it, so three agents in one run each paid to disprove it.
|
|
32
|
+
//
|
|
33
|
+
// The answer is a real workload: load a one-layer image built in this process
|
|
34
|
+
// (docker-probe-image.ts) and run a container from it that has to print a marker. That is the
|
|
35
|
+
// smallest thing that exercises the whole path an agent's `docker run` takes, and it needs no
|
|
36
|
+
// registry, no network and no second image in the container.
|
|
37
|
+
//
|
|
38
|
+
// THREE answers, and which failure maps to which is the whole design:
|
|
39
|
+
//
|
|
40
|
+
// - `usable`: a container ran and printed the marker. Nothing else proves this.
|
|
41
|
+
// - `unusable`: the container did NOT run, and the daemon is what refused it. A DECIDED
|
|
42
|
+
// negative, and the only one anything states to an agent as a prohibition.
|
|
43
|
+
// - `unknown`: the check could not be carried out: no payload on this machine (the native
|
|
44
|
+
// host transport, where the harness runs on a developer's laptop), a daemon
|
|
45
|
+
// whose architecture the payload is not built for, `docker load` refusing the
|
|
46
|
+
// archive, the probe binary failing to exec, a timeout, a cancelled job.
|
|
47
|
+
// Every one of those is a fact about THE CHECK, and reading it as a fact about
|
|
48
|
+
// the daemon would trade this module's original lie for its mirror image.
|
|
49
|
+
//
|
|
50
|
+
// The `unknown` arm carries {@link DockerWorkload.daemonAnswered} because that distinction is
|
|
51
|
+
// load-bearing one level up: `resolveDockerVerdict` needs the cheap fact this check establishes
|
|
52
|
+
// on its way past (a daemon is answering RIGHT NOW) to keep a warm container out of a stale boot
|
|
53
|
+
// record's refusal, and only the check knows whether it ever got that far.
|
|
54
|
+
//
|
|
55
|
+
// `usable` then carries a SECOND fact, because running a container and reaching the network from
|
|
56
|
+
// inside one are different things and only the first of them was ever measured. A local image
|
|
57
|
+
// loads and runs with no network at all, so a daemon whose nested containers are cut off passes
|
|
58
|
+
// this check exactly as a working one does: the published image ran its daemon with
|
|
59
|
+
// `--iptables=false` and every `docker build` that fetched a dependency was guaranteed to fail,
|
|
60
|
+
// while the harness reported `usable` and told each agent that `docker build` works here (issue
|
|
61
|
+
// #2174). It is a separate field rather than a fourth status because it answers a separate
|
|
62
|
+
// question, with its own three outcomes and its own way of being undeterminable, and because the
|
|
63
|
+
// two have different consequences: no egress is a constraint to plan around, where an unusable
|
|
64
|
+
// daemon is a prohibition.
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What a NESTED container could reach, measured from inside one.
|
|
69
|
+
*
|
|
70
|
+
* Measured there and nowhere else, which is the whole point. The harness container's own network
|
|
71
|
+
* is fine in both cases: it resolves and fetches normally, and the daemon pulls images
|
|
72
|
+
* successfully, so every check one layer out reports a working network over a daemon whose
|
|
73
|
+
* containers have none.
|
|
74
|
+
*
|
|
75
|
+
* `reachable` needs BOTH halves. A route with no DNS is not a network an agent can use: nothing
|
|
76
|
+
* it installs is fetched by address, so `blocked` is the honest verdict and the detail names DNS
|
|
77
|
+
* as the half to fix. The reverse (a name resolved, the configured address refused) is
|
|
78
|
+
* `undetermined` instead of `blocked`, because a resolved name proves a path out exists and the
|
|
79
|
+
* likeliest cause is that this deployment filters the address the check was pointed at.
|
|
80
|
+
*/
|
|
81
|
+
export type ContainerEgress =
|
|
82
|
+
/** A container opened a TCP connection out AND resolved a public name. */
|
|
83
|
+
| { status: 'reachable' }
|
|
84
|
+
/** It could not get out. `detail` names how far it got, since the two have different fixes. */
|
|
85
|
+
| { status: 'blocked'; detail: string }
|
|
86
|
+
/** The check could not be carried out, or could not be read as evidence about the network. */
|
|
87
|
+
| {
|
|
88
|
+
status: 'undetermined'
|
|
89
|
+
reason: string
|
|
90
|
+
/**
|
|
91
|
+
* Whether asking again could give a different answer.
|
|
92
|
+
*
|
|
93
|
+
* Load-bearing, because the probe re-measures an undetermined egress and re-measuring
|
|
94
|
+
* means two container starts plus an image load, per job, on the critical path ahead of
|
|
95
|
+
* the clone. Most of the ways to land here cannot change while this container lives: a
|
|
96
|
+
* rejected `HARNESS_DOCKER_EGRESS_TARGET`, a payload with no `nc` applet, an address this
|
|
97
|
+
* deployment filters. Latching those is not a stale verdict, it is the same measurement
|
|
98
|
+
* with the same inputs; re-running it forever costs every job about forty seconds for a
|
|
99
|
+
* value that is settled. Only a genuinely transient failure (a timeout, a cancelled job,
|
|
100
|
+
* a daemon that could not attach the bridge yet) is worth asking about again.
|
|
101
|
+
*/
|
|
102
|
+
recheck: boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** What one measurement concluded. See the three answers above; nothing collapses them. */
|
|
106
|
+
export type DockerWorkload =
|
|
107
|
+
| { status: 'usable'; egress: ContainerEgress }
|
|
108
|
+
| { status: 'unusable'; detail: string }
|
|
109
|
+
| {
|
|
110
|
+
status: 'unknown'
|
|
111
|
+
reason: string
|
|
112
|
+
/**
|
|
113
|
+
* Whether a daemon ANSWERED before the check ran out of things it could do. A weaker fact
|
|
114
|
+
* than the check exists to establish, and the one a stale boot record must be read against.
|
|
115
|
+
*/
|
|
116
|
+
daemonAnswered: boolean
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The statically linked binary the probe image is built from, overridable for an image variant
|
|
121
|
+
* that ships it elsewhere. Absent is a supported answer, not a failure: under
|
|
122
|
+
* `LOCAL_NATIVE_AGENTS` the harness runs on a developer's machine that never saw this image.
|
|
123
|
+
*/
|
|
124
|
+
const PAYLOAD_PATH = process.env.HARNESS_DOCKER_PROBE_BINARY?.trim() || '/bin/busybox'
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Where the egress check aims, overridable for a deployment whose network permits something else.
|
|
128
|
+
*
|
|
129
|
+
* A raw IPv4 address rather than a name, so the connect answers a question about ROUTING alone:
|
|
130
|
+
* pointing it at a hostname would make every verdict depend on DNS, which is the other half and
|
|
131
|
+
* is measured separately. `1.1.1.1:443` is an anycast address that answers TLS from everywhere
|
|
132
|
+
* and belongs to no API this repo calls; the name is npm's because npm is what the outage
|
|
133
|
+
* actually broke. Neither is validated here (see `parseEgressTarget`), so a rejected setting is
|
|
134
|
+
* reported rather than replaced.
|
|
135
|
+
*
|
|
136
|
+
* Both defaults aim at the PUBLIC internet, and a deployment that deliberately has none should
|
|
137
|
+
* point these at what it does run (an internal registry mirror and its own DNS zone). Left at
|
|
138
|
+
* the defaults there, the measurement is honest but narrow: it establishes that a container
|
|
139
|
+
* cannot reach these two, which is why the prompt built from a `blocked` verdict says which
|
|
140
|
+
* targets were tried rather than that nothing at all is reachable.
|
|
141
|
+
*/
|
|
142
|
+
const EGRESS_TARGET = process.env.HARNESS_DOCKER_EGRESS_TARGET?.trim() || '1.1.1.1:443'
|
|
143
|
+
const EGRESS_DNS_NAME = process.env.HARNESS_DOCKER_EGRESS_DNS_NAME?.trim() || 'registry.npmjs.org'
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The ceiling on ONE WHOLE measurement, shared out across the docker commands it makes: each
|
|
147
|
+
* gets what is left of it, down to {@link MIN_COMMAND_MS}.
|
|
148
|
+
*
|
|
149
|
+
* One budget rather than a per-command ceiling, because a per-command one multiplies: three
|
|
150
|
+
* commands at 30s each is a minute and a half of dead time on a wedged daemon, on the critical
|
|
151
|
+
* path ahead of the clone. Sized for a WEDGED daemon and not for a slow one: the payload is a
|
|
152
|
+
* couple of megabytes already on local disk, so a daemon that works answers in about the time it
|
|
153
|
+
* takes to start one container, and a daemon that cannot mount fails immediately.
|
|
154
|
+
*
|
|
155
|
+
* What it does NOT do is bound the cost per JOB, and the comment that once claimed so was wrong.
|
|
156
|
+
* A POSITIVE verdict is memoised for the container's life; a negative is deliberately
|
|
157
|
+
* re-measured (see {@link createDockerWorkloadProbe}), and two independent sites ask per job (the
|
|
158
|
+
* environment inventory and the compose stand-up), so a serving-but-wedged daemon costs this
|
|
159
|
+
* twice per job. That is the price of not latching a warm container into a stale refusal, which
|
|
160
|
+
* is why the budget is the size it is and why an abandoned job stops paying it at once.
|
|
161
|
+
*/
|
|
162
|
+
const WORKLOAD_BUDGET_MS = 20_000
|
|
163
|
+
|
|
164
|
+
/** The floor on one command's share of the budget, so an exhausted budget still gets an answer. */
|
|
165
|
+
const MIN_COMMAND_MS = 1_000
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The ceiling on the egress container, which gets its OWN budget rather than a share of the one
|
|
169
|
+
* above.
|
|
170
|
+
*
|
|
171
|
+
* The argument for a single shared budget is that a per-command ceiling multiplies on a WEDGED
|
|
172
|
+
* daemon, and that argument does not reach here: this container is started only after another one
|
|
173
|
+
* has already run to completion, so the daemon is known to work by the time it is spawned. What
|
|
174
|
+
* it does have to allow for is a check that is SUPPOSED to be slow in the failing case, since a
|
|
175
|
+
* blocked route is silent rather than refused and both in-container timeouts have to expire.
|
|
176
|
+
* Taking that out of the workload budget would have starved the step this whole module exists
|
|
177
|
+
* for; leaving it unbounded would hand a wedged network the whole job.
|
|
178
|
+
*/
|
|
179
|
+
const EGRESS_BUDGET_MS = 20_000
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The ceiling on removing the probe image again.
|
|
183
|
+
*
|
|
184
|
+
* Its own, and deliberately NOT given the caller's abort signal: the image is the platform's, and
|
|
185
|
+
* a cancelled job is the one case where nobody is left to clean up after it. Bounded separately
|
|
186
|
+
* so a wedged daemon cannot turn the cleanup into a second full budget.
|
|
187
|
+
*/
|
|
188
|
+
const CLEANUP_TIMEOUT_MS = 5_000
|
|
189
|
+
|
|
190
|
+
/** How much of a failing command's output is kept. It is quoted into an agent's system prompt. */
|
|
191
|
+
const DETAIL_CHARS = 300
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A one-slot memo for the assembled archive.
|
|
195
|
+
*
|
|
196
|
+
* The archive is byte-stable for one `(payload path, architecture)` pair by construction
|
|
197
|
+
* (docker-probe-image.ts pins every timestamp for exactly this reason), and neither half changes
|
|
198
|
+
* within a process. Since a NEGATIVE verdict is re-measured on purpose and two sites ask per job,
|
|
199
|
+
* without this the same two megabytes are re-read and re-sha256'd four times per job for a value
|
|
200
|
+
* that cannot differ. One slot rather than a map: there is only ever one key.
|
|
201
|
+
*/
|
|
202
|
+
export interface ProbeArchiveMemo {
|
|
203
|
+
read(key: string): Buffer | undefined
|
|
204
|
+
write(key: string, archive: Buffer): void
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Build a {@link ProbeArchiveMemo}. Supplied only by {@link realDeps}, so a test memoises nothing. */
|
|
208
|
+
export function oneSlotArchiveMemo(): ProbeArchiveMemo {
|
|
209
|
+
let held: { key: string; archive: Buffer } | undefined
|
|
210
|
+
return {
|
|
211
|
+
read: (key) => (held?.key === key ? held.archive : undefined),
|
|
212
|
+
write: (key, archive) => {
|
|
213
|
+
held = { key, archive }
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** What a measurement needs from the machine, so a test can supply all of it. */
|
|
219
|
+
export interface DockerWorkloadDeps {
|
|
220
|
+
/** Read the probe payload. Rejecting (ENOENT) is the supported "this machine has none". */
|
|
221
|
+
readPayload: (path: string) => Promise<Buffer>
|
|
222
|
+
payloadPath: string
|
|
223
|
+
runDocker: DockerCommandRunner
|
|
224
|
+
/** This process's architecture, as `process.arch` spells it: the PAYLOAD's, never the daemon's. */
|
|
225
|
+
arch: string
|
|
226
|
+
/** Where the egress container aims, as configured. Validated at use, never here. */
|
|
227
|
+
egress: { target: string; dnsName: string }
|
|
228
|
+
logger?: Logger
|
|
229
|
+
archives?: ProbeArchiveMemo
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const realDeps: DockerWorkloadDeps = {
|
|
233
|
+
readPayload: (path) => readFile(path),
|
|
234
|
+
payloadPath: PAYLOAD_PATH,
|
|
235
|
+
runDocker: spawnDockerCommand,
|
|
236
|
+
arch: process.arch,
|
|
237
|
+
egress: { target: EGRESS_TARGET, dnsName: EGRESS_DNS_NAME },
|
|
238
|
+
archives: oneSlotArchiveMemo(),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Carry out one measurement. Pure of caching, so the suite states every branch directly.
|
|
243
|
+
*
|
|
244
|
+
* TOTAL: it never rejects, whatever happens inside it. The thing it replaced was total by
|
|
245
|
+
* construction (a `try/catch` around one `execFile`), and it is consulted from a stand-up path
|
|
246
|
+
* documented as best-effort, so a throw here would fail a job over a probe whose whole purpose is
|
|
247
|
+
* to make a failure legible. A throw is also, by definition, the platform's own machinery
|
|
248
|
+
* breaking, which is the `unknown` disposition and never the `unusable` one.
|
|
249
|
+
*
|
|
250
|
+
* That asymmetry is the design. Only the RUN produces `unusable`; everything before it produces
|
|
251
|
+
* `unknown`, because everything before it is the platform's own machinery and a bug in it must be
|
|
252
|
+
* able to say "I could not tell" and never "your daemon is broken". The load step in particular is
|
|
253
|
+
* the one this repo wrote itself.
|
|
254
|
+
*/
|
|
255
|
+
export async function measureDockerWorkload(
|
|
256
|
+
deps: DockerWorkloadDeps = realDeps,
|
|
257
|
+
signal?: AbortSignal,
|
|
258
|
+
): Promise<DockerWorkload> {
|
|
259
|
+
const seen = { daemonAnswered: false }
|
|
260
|
+
try {
|
|
261
|
+
return await measure(deps, seen, signal)
|
|
262
|
+
} catch (err) {
|
|
263
|
+
const cause = describeThrown(err)
|
|
264
|
+
;(deps.logger ?? log).warn('docker capability: the container check itself fell over', {
|
|
265
|
+
error: cause,
|
|
266
|
+
})
|
|
267
|
+
return undeterminable(
|
|
268
|
+
`the platform's own container check could not be completed (${cause})`,
|
|
269
|
+
seen.daemonAnswered,
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function measure(
|
|
275
|
+
deps: DockerWorkloadDeps,
|
|
276
|
+
seen: { daemonAnswered: boolean },
|
|
277
|
+
signal?: AbortSignal,
|
|
278
|
+
): Promise<DockerWorkload> {
|
|
279
|
+
const deadline = Date.now() + WORKLOAD_BUDGET_MS
|
|
280
|
+
const share = (): number => Math.max(MIN_COMMAND_MS, deadline - Date.now())
|
|
281
|
+
const command = (args: string[], stdin?: Buffer): Promise<CommandOutcome> =>
|
|
282
|
+
deps.runDocker(args, {
|
|
283
|
+
...(stdin ? { stdin } : {}),
|
|
284
|
+
...(signal ? { signal } : {}),
|
|
285
|
+
timeoutMs: share(),
|
|
286
|
+
...(deps.logger ? { logger: deps.logger } : {}),
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
// Ask the DAEMON which architecture it runs, rather than assuming it shares this process's.
|
|
290
|
+
// An external `DOCKER_HOST` is a supported path, and an arm64 harness against an amd64 sidecar
|
|
291
|
+
// (or a remote x86_64 daemon reached from an arm64 laptop) shares nothing with it but a socket:
|
|
292
|
+
// declaring the wrong one in the image config gets the run refused, which would report a
|
|
293
|
+
// perfectly good daemon as one that cannot run containers. It is also the cheapest proof that a
|
|
294
|
+
// daemon is answering at all, which is the fact `resolveDockerVerdict` reads back.
|
|
295
|
+
const asked = await command(['version', '--format', '{{.Server.Arch}}'])
|
|
296
|
+
if (asked.outcome !== 'ran' || asked.code !== 0) {
|
|
297
|
+
return undeterminable(
|
|
298
|
+
`no Docker daemon answered the platform's container check (${describeOutcome(asked)})`,
|
|
299
|
+
false,
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
seen.daemonAnswered = true
|
|
303
|
+
const daemonArch = asked.stdout.trim()
|
|
304
|
+
if (!/^[a-z0-9_]+$/.test(daemonArch)) {
|
|
305
|
+
return undeterminable(
|
|
306
|
+
`the Docker daemon did not name an architecture the platform can build an image for (${scrubbedExcerpt(daemonArch, 40) || 'it answered nothing'})`,
|
|
307
|
+
true,
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
const payloadArch = payloadArchitecture(deps.arch)
|
|
311
|
+
if (!payloadArch) {
|
|
312
|
+
return undeterminable(
|
|
313
|
+
`the platform has no container check for the ${deps.arch} architecture`,
|
|
314
|
+
true,
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
if (payloadArch !== daemonArch) {
|
|
318
|
+
return undeterminable(
|
|
319
|
+
`the platform's container check is built for ${payloadArch} and this daemon runs ${daemonArch}`,
|
|
320
|
+
true,
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const assembled = await assembleArchive(deps, daemonArch)
|
|
325
|
+
if ('reason' in assembled) return undeterminable(assembled.reason, true)
|
|
326
|
+
|
|
327
|
+
const load = await command(['load'], assembled.archive)
|
|
328
|
+
if (load.outcome !== 'ran' || load.code !== 0) {
|
|
329
|
+
return undeterminable(
|
|
330
|
+
`the platform could not load its own probe image (${describeOutcome(load)})`,
|
|
331
|
+
true,
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const run = await command([
|
|
336
|
+
'run',
|
|
337
|
+
'--rm',
|
|
338
|
+
'--pull',
|
|
339
|
+
'never',
|
|
340
|
+
'--network',
|
|
341
|
+
'none',
|
|
342
|
+
PROBE_IMAGE_TAG,
|
|
343
|
+
...PROBE_COMMAND,
|
|
344
|
+
])
|
|
345
|
+
// A daemon that ran that container has answered the first question, and only then is there a
|
|
346
|
+
// second one worth asking. An `unusable` daemon cannot run the egress container either, and a
|
|
347
|
+
// check that could not be carried out has nothing to measure egress against.
|
|
348
|
+
const verdict = classifyRun(run)
|
|
349
|
+
const measured: DockerWorkload =
|
|
350
|
+
verdict.status === 'usable'
|
|
351
|
+
? { status: 'usable', egress: await measureEgress(deps, signal) }
|
|
352
|
+
: verdict
|
|
353
|
+
// After both runs, whatever the verdict is: the probe image is the platform's, and an agent
|
|
354
|
+
// that runs `docker images` should not have to wonder whose it is.
|
|
355
|
+
await removeProbeImage(deps)
|
|
356
|
+
return measured
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Run the second container and read what it reached.
|
|
361
|
+
*
|
|
362
|
+
* On the DEFAULT network, deliberately, which is the one thing that separates it from the marker
|
|
363
|
+
* run above (`--network none`). What an agent's own `docker build` and `docker run` get is the
|
|
364
|
+
* bridge, and the bridge is exactly what a daemon started with `--iptables=false` fails to NAT.
|
|
365
|
+
*
|
|
366
|
+
* Never concludes anything about the DAEMON. Every failure here is either evidence about the
|
|
367
|
+
* network or evidence about this check, and the caller has already established that the daemon
|
|
368
|
+
* runs containers.
|
|
369
|
+
*/
|
|
370
|
+
async function measureEgress(
|
|
371
|
+
deps: DockerWorkloadDeps,
|
|
372
|
+
signal?: AbortSignal,
|
|
373
|
+
): Promise<ContainerEgress> {
|
|
374
|
+
const setting = parseEgressTarget(deps.egress.target, deps.egress.dnsName)
|
|
375
|
+
// A rejected setting is read from this container's own environment, so it answers the same way
|
|
376
|
+
// on every job: latched rather than re-measured, which would otherwise spend two container
|
|
377
|
+
// starts per job re-reading one unchanged string.
|
|
378
|
+
if ('invalid' in setting) {
|
|
379
|
+
return { status: 'undetermined', reason: setting.invalid, recheck: false }
|
|
380
|
+
}
|
|
381
|
+
const run = await deps.runDocker(
|
|
382
|
+
['run', '--rm', '--pull', 'never', PROBE_IMAGE_TAG, ...buildEgressCommand(setting.target)],
|
|
383
|
+
{
|
|
384
|
+
...(signal ? { signal } : {}),
|
|
385
|
+
timeoutMs: EGRESS_BUDGET_MS,
|
|
386
|
+
...(deps.logger ? { logger: deps.logger } : {}),
|
|
387
|
+
},
|
|
388
|
+
)
|
|
389
|
+
return classifyEgress(run, setting.target)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* What the egress container's output proves, over the four combinations its two markers can
|
|
394
|
+
* carry.
|
|
395
|
+
*
|
|
396
|
+
* Read off the STATUS each command printed rather than off the run's own exit code, because the
|
|
397
|
+
* two failures that look alike from outside need opposite answers: a refused connection is
|
|
398
|
+
* evidence about the network, and a 126/127 is busybox saying the image has no such applet, which
|
|
399
|
+
* is evidence about the platform's own payload and may never be reported as a network that is not
|
|
400
|
+
* there.
|
|
401
|
+
*/
|
|
402
|
+
function classifyEgress(run: CommandOutcome, target: EgressTarget): ContainerEgress {
|
|
403
|
+
const where = `${target.host}:${target.port}`
|
|
404
|
+
if (run.outcome === 'failed') {
|
|
405
|
+
return {
|
|
406
|
+
status: 'undetermined',
|
|
407
|
+
reason: `the platform's egress check did not run (${run.reason})`,
|
|
408
|
+
// A spawn failure or a timeout is about this attempt and not about the container.
|
|
409
|
+
recheck: true,
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const tcp = readMarker(run.stdout, EGRESS_TCP_MARKER)
|
|
413
|
+
const dns = readMarker(run.stdout, EGRESS_DNS_MARKER)
|
|
414
|
+
if (tcp === undefined || dns === undefined) {
|
|
415
|
+
// Nothing was measured, and the two ways to get here need different words. `docker run`
|
|
416
|
+
// failing at the DAEMON level (125, or its own "no such image") means the container never
|
|
417
|
+
// started, so the network was not the thing that did not answer; a container that started
|
|
418
|
+
// and printed something unrecognisable is the platform's own payload misbehaving. Reporting
|
|
419
|
+
// the first as "printed no verdict" tells an operator to go looking at the check's output
|
|
420
|
+
// for a container that produced none.
|
|
421
|
+
const refused =
|
|
422
|
+
platformSideRunFailure(run) ?? (run.code === 125 ? daemonRefusedEgressRun : undefined)
|
|
423
|
+
return refused
|
|
424
|
+
? {
|
|
425
|
+
status: 'undetermined',
|
|
426
|
+
reason: `the platform's egress container did not start (${refused}: ${describeOutcome(run)})`,
|
|
427
|
+
// The daemon just ran the marker container, so a bridge it could not attach now is
|
|
428
|
+
// the sort of thing that can differ on the next job.
|
|
429
|
+
recheck: true,
|
|
430
|
+
}
|
|
431
|
+
: {
|
|
432
|
+
status: 'undetermined',
|
|
433
|
+
reason: `the platform's egress check printed no verdict (${describeOutcome(run)})`,
|
|
434
|
+
// Same image, same argv, same output: asking again re-reads the same non-answer.
|
|
435
|
+
recheck: false,
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if ([tcp, dns].some((code) => code === 126 || code === 127)) {
|
|
439
|
+
return {
|
|
440
|
+
status: 'undetermined',
|
|
441
|
+
reason:
|
|
442
|
+
"the platform's egress check could not run inside its own probe container (the payload " +
|
|
443
|
+
'has no `nc` or `nslookup` applet)',
|
|
444
|
+
// A fact about the image this repo builds, which does not change under a running container.
|
|
445
|
+
recheck: false,
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (tcp === 0 && dns === 0) return { status: 'reachable' }
|
|
449
|
+
if (tcp === 0) {
|
|
450
|
+
return {
|
|
451
|
+
status: 'blocked',
|
|
452
|
+
detail: `a container reached ${where} but could not resolve ${target.dnsName}: the route out works and DNS does not`,
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (dns === 0) {
|
|
456
|
+
// A resolved name proves a path out of the container exists, so the connect failing is far
|
|
457
|
+
// more likely to be about the ADDRESS than about the network. Saying "blocked" here would
|
|
458
|
+
// condemn a working sandbox over a target it happens to filter.
|
|
459
|
+
return {
|
|
460
|
+
status: 'undetermined',
|
|
461
|
+
reason:
|
|
462
|
+
`a container resolved ${target.dnsName} but could not connect to ${where}, so this ` +
|
|
463
|
+
'deployment probably filters that address; point HARNESS_DOCKER_EGRESS_TARGET at one it permits',
|
|
464
|
+
// A filtered address is a standing property of the network this container sits in.
|
|
465
|
+
recheck: false,
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
status: 'blocked',
|
|
470
|
+
detail: `a container could reach neither ${where} nor ${target.dnsName}, the two the platform is configured to try`,
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** What a 125 from the egress run means, kept beside the other platform-side run messages. */
|
|
475
|
+
const daemonRefusedEgressRun = 'the daemon refused to create or start it'
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The exit status printed after `marker`, or undefined when the container never printed one.
|
|
479
|
+
*
|
|
480
|
+
* The LAST occurrence wins, so a marker that somehow reached the stream twice is read at its
|
|
481
|
+
* final value rather than at whichever came first.
|
|
482
|
+
*/
|
|
483
|
+
function readMarker(stdout: string, marker: string): number | undefined {
|
|
484
|
+
const status = [...stdout.matchAll(new RegExp(`${marker}(\\d{1,3})`, 'g'))].pop()?.[1]
|
|
485
|
+
return status === undefined ? undefined : Number(status)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* What the container RUN proves. The one step allowed to conclude `unusable`, and even here not
|
|
490
|
+
* every non-zero exit is evidence about the daemon.
|
|
491
|
+
*
|
|
492
|
+
* Docker splits its own failures from the container's by exit code: 125 is `docker run` itself
|
|
493
|
+
* failing, 126 is a command that could not be invoked and 127 one that was not found. The last
|
|
494
|
+
* two are facts about THE PAYLOAD, a binary this platform put in an image it built, and the
|
|
495
|
+
* container had to be created and started to produce them. 125 covers both the daemon refusing to
|
|
496
|
+
* create the container (the verdict this whole module exists for) and the tag not being there to
|
|
497
|
+
* run, which is our own load, so that one is split by what docker SAID.
|
|
498
|
+
*
|
|
499
|
+
* Its own return type, and not {@link DockerWorkload}: what the marker run establishes is a
|
|
500
|
+
* daemon that runs containers, which is the first half of a `usable` verdict and not the whole
|
|
501
|
+
* of one. Naming the intermediate is what stops it being returned as a finished answer with the
|
|
502
|
+
* egress half silently absent.
|
|
503
|
+
*/
|
|
504
|
+
type RunVerdict = { status: 'usable' } | Exclude<DockerWorkload, { status: 'usable' }>
|
|
505
|
+
|
|
506
|
+
function classifyRun(run: CommandOutcome): RunVerdict {
|
|
507
|
+
if (run.outcome === 'failed') {
|
|
508
|
+
return undeterminable(`the platform's container check did not run (${run.reason})`, true)
|
|
509
|
+
}
|
|
510
|
+
if (run.code === 0) {
|
|
511
|
+
if (run.stdout.includes(PROBE_SENTINEL)) return { status: 'usable' }
|
|
512
|
+
// Nothing explains this: the container was reported as having run cleanly and produced none
|
|
513
|
+
// of the output it exists to produce. That is a fact about the check, not about the daemon.
|
|
514
|
+
return undeterminable(
|
|
515
|
+
"the platform's probe container exited cleanly without printing its marker",
|
|
516
|
+
true,
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
const ours = platformSideRunFailure(run)
|
|
520
|
+
return ours
|
|
521
|
+
? undeterminable(`${ours} (${describeOutcome(run)})`, true)
|
|
522
|
+
: { status: 'unusable', detail: describeOutcome(run) }
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Messages that name the PLATFORM's half of a failed run rather than the daemon's. */
|
|
526
|
+
const PLATFORM_SIDE_RUN_MESSAGES: readonly { pattern: RegExp; cause: string }[] = [
|
|
527
|
+
{
|
|
528
|
+
pattern: /no such image|unable to find image/i,
|
|
529
|
+
cause: "the platform's probe image was not there to be run",
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
pattern: /exec format error/i,
|
|
533
|
+
cause: "the platform's probe binary cannot be executed on this daemon's machine",
|
|
534
|
+
},
|
|
535
|
+
]
|
|
536
|
+
|
|
537
|
+
function platformSideRunFailure(run: {
|
|
538
|
+
code: number
|
|
539
|
+
stdout: string
|
|
540
|
+
stderr: string
|
|
541
|
+
}): string | undefined {
|
|
542
|
+
if (run.code === 126 || run.code === 127) {
|
|
543
|
+
return "the platform's probe binary could not be invoked inside the container"
|
|
544
|
+
}
|
|
545
|
+
const said = `${run.stderr}\n${run.stdout}`
|
|
546
|
+
return PLATFORM_SIDE_RUN_MESSAGES.find((m) => m.pattern.test(said))?.cause
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Assemble the archive for the daemon's architecture, reusing the last one built.
|
|
551
|
+
*
|
|
552
|
+
* A read that fails is classified rather than asserted away: `HARNESS_DOCKER_PROBE_BINARY`
|
|
553
|
+
* pointing at a directory, at a path this user may not read, or at a failing mount is a
|
|
554
|
+
* misconfiguration an operator can fix, and "this machine does not have it" states the opposite
|
|
555
|
+
* fact. The sentence goes into an agent's system prompt and into `GET /health`, so a discarded
|
|
556
|
+
* cause is a cause nobody ever sees.
|
|
557
|
+
*/
|
|
558
|
+
async function assembleArchive(
|
|
559
|
+
deps: DockerWorkloadDeps,
|
|
560
|
+
architecture: string,
|
|
561
|
+
): Promise<{ archive: Buffer } | { reason: string }> {
|
|
562
|
+
const key = `${deps.payloadPath}::${architecture}`
|
|
563
|
+
const held = deps.archives?.read(key)
|
|
564
|
+
if (held) return { archive: held }
|
|
565
|
+
let payload: Buffer
|
|
566
|
+
try {
|
|
567
|
+
payload = await deps.readPayload(deps.payloadPath)
|
|
568
|
+
} catch (err) {
|
|
569
|
+
return { reason: describePayloadFailure(err, deps.payloadPath) }
|
|
570
|
+
}
|
|
571
|
+
const archive = buildProbeArchive(payload, architecture)
|
|
572
|
+
deps.archives?.write(key, archive)
|
|
573
|
+
return { archive }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function describePayloadFailure(err: unknown, path: string): string {
|
|
577
|
+
const needs = `the platform's own container check needs ${path}`
|
|
578
|
+
switch ((err as NodeJS.ErrnoException).code) {
|
|
579
|
+
case 'ENOENT':
|
|
580
|
+
case 'ENOTDIR':
|
|
581
|
+
return `${needs}, which this machine does not have`
|
|
582
|
+
case 'EACCES':
|
|
583
|
+
case 'EPERM':
|
|
584
|
+
return `${needs}, which it is not permitted to read`
|
|
585
|
+
case 'EISDIR':
|
|
586
|
+
return `${needs} to be a file, and it is a directory`
|
|
587
|
+
default:
|
|
588
|
+
return `${needs}, which could not be read (${describeThrown(err)})`
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Remove the probe image, and SAY SO when that did not work.
|
|
594
|
+
*
|
|
595
|
+
* The one line above it promises an agent will never find a `cat-factory-docker-probe` and wonder
|
|
596
|
+
* whose it is, and the daemon has two ordinary ways to refuse: a `--rm` teardown still in flight
|
|
597
|
+
* holds the image ("image is being used by stopped container"), and a wedged daemon does not
|
|
598
|
+
* answer at all. Discarding the outcome left both silent, so the promise was unverifiable in
|
|
599
|
+
* exactly the states that break it.
|
|
600
|
+
*/
|
|
601
|
+
async function removeProbeImage(deps: DockerWorkloadDeps): Promise<void> {
|
|
602
|
+
const removed = await deps.runDocker(['image', 'rm', '-f', PROBE_IMAGE_TAG], {
|
|
603
|
+
timeoutMs: CLEANUP_TIMEOUT_MS,
|
|
604
|
+
...(deps.logger ? { logger: deps.logger } : {}),
|
|
605
|
+
})
|
|
606
|
+
if (removed.outcome === 'ran' && removed.code === 0) return
|
|
607
|
+
;(deps.logger ?? log).warn('docker capability: the probe image could not be removed', {
|
|
608
|
+
image: PROBE_IMAGE_TAG,
|
|
609
|
+
error: describeOutcome(removed),
|
|
610
|
+
})
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function undeterminable(reason: string, daemonAnswered: boolean): DockerWorkload {
|
|
614
|
+
return { status: 'unknown', reason, daemonAnswered }
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** A bounded, scrubbed one-line summary of what a command said, for a prompt or a log field. */
|
|
618
|
+
function describeOutcome(outcome: CommandOutcome): string {
|
|
619
|
+
if (outcome.outcome === 'failed') return outcome.reason
|
|
620
|
+
const said = `${outcome.stderr}\n${outcome.stdout}`
|
|
621
|
+
.split('\n')
|
|
622
|
+
.map((line) => line.trim())
|
|
623
|
+
.filter(Boolean)
|
|
624
|
+
.join('; ')
|
|
625
|
+
return bounded(said) || `docker exited ${outcome.code} without saying why`
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** The one describer for a thrown value here: scrubbed and bounded, like any other detail. */
|
|
629
|
+
function describeThrown(err: unknown): string {
|
|
630
|
+
return bounded(err instanceof Error ? err.message : String(err)) || 'it said nothing'
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function bounded(text: string): string {
|
|
634
|
+
return scrubbedExcerpt(text, DETAIL_CHARS)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* A measurement, plus what the last one concluded without taking another.
|
|
639
|
+
*
|
|
640
|
+
* Callable because every caller wants the verdict; `last()` exists for `GET /health`, which is
|
|
641
|
+
* polled and must not spawn a container per poll to answer a question it does not act on.
|
|
642
|
+
*/
|
|
643
|
+
export interface DockerWorkloadProbe {
|
|
644
|
+
(signal?: AbortSignal): Promise<DockerWorkload>
|
|
645
|
+
last(): DockerWorkload | undefined
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** One in-flight measurement and the callers still waiting for it. */
|
|
649
|
+
interface Measurement {
|
|
650
|
+
readonly result: Promise<DockerWorkload>
|
|
651
|
+
readonly cancel: AbortController
|
|
652
|
+
waiters: number
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Build a probe that measures at most once per container for a POSITIVE answer.
|
|
657
|
+
*
|
|
658
|
+
* A daemon that has run a container has proved something that does not stop being true, so that
|
|
659
|
+
* verdict is kept and every later job reads it for free. A negative is NOT kept, for the reason
|
|
660
|
+
* `resolveDockerVerdict` gives about the boot record: a container outlives its boot, a warm pool
|
|
661
|
+
* serves many jobs from one, and a daemon that was not ready for the first job must not latch
|
|
662
|
+
* the whole container into saying so. Re-measuring a negative is cheap; a daemon that cannot
|
|
663
|
+
* mount fails at once.
|
|
664
|
+
*
|
|
665
|
+
* A `usable` verdict whose EGRESS could not be determined is re-measured on the same rule and for
|
|
666
|
+
* the same reason. Whether the bridge is NATed is settled once and for the daemon's life, so a
|
|
667
|
+
* measured `reachable` or `blocked` is kept; a check that timed out measured nothing, and latching
|
|
668
|
+
* that would leave the container permanently unable to say which of the two it is.
|
|
669
|
+
*
|
|
670
|
+
* But only where asking again could ANSWER differently, which is what `ContainerEgress.recheck`
|
|
671
|
+
* carries. Most ways to reach `undetermined` are standing facts about this container: a rejected
|
|
672
|
+
* target setting, a payload with no `nc`, an address the deployment filters. Re-running the whole
|
|
673
|
+
* measurement on those never converges, and it is not cheap: it is `docker version`, the archive,
|
|
674
|
+
* `docker load`, two container starts and an `image rm`, per job, ahead of the clone.
|
|
675
|
+
*
|
|
676
|
+
* Concurrent callers share one in-flight measurement rather than each starting a container, and
|
|
677
|
+
* the measurement is cancelled when the LAST of them has abandoned it. Neither half is optional:
|
|
678
|
+
* one job's abort may not kill a measurement a sibling job is still waiting on (the local native
|
|
679
|
+
* transport serves every concurrent job from one process), and a measurement nobody is waiting
|
|
680
|
+
* for is a container start no job will read, which is what an abandoned run should stop paying
|
|
681
|
+
* for the moment it is abandoned.
|
|
682
|
+
*/
|
|
683
|
+
export function createDockerWorkloadProbe(
|
|
684
|
+
deps: DockerWorkloadDeps = realDeps,
|
|
685
|
+
): DockerWorkloadProbe {
|
|
686
|
+
let latest: DockerWorkload | undefined
|
|
687
|
+
let inFlight: Measurement | undefined
|
|
688
|
+
const begin = (): Measurement => {
|
|
689
|
+
const cancel = new AbortController()
|
|
690
|
+
const measurement: Measurement = {
|
|
691
|
+
cancel,
|
|
692
|
+
waiters: 0,
|
|
693
|
+
result: measureDockerWorkload(deps, cancel.signal).then((verdict) => {
|
|
694
|
+
latest = verdict
|
|
695
|
+
if (inFlight === measurement) inFlight = undefined
|
|
696
|
+
return verdict
|
|
697
|
+
}),
|
|
698
|
+
}
|
|
699
|
+
return measurement
|
|
700
|
+
}
|
|
701
|
+
const probe = (async (signal?: AbortSignal): Promise<DockerWorkload> => {
|
|
702
|
+
if (latest?.status === 'usable' && !isWorthReMeasuring(latest.egress)) return latest
|
|
703
|
+
const measurement = (inFlight ??= begin())
|
|
704
|
+
measurement.waiters += 1
|
|
705
|
+
const watch = signal ? watchAbandonment(signal) : undefined
|
|
706
|
+
try {
|
|
707
|
+
return watch
|
|
708
|
+
? await Promise.race([measurement.result, watch.abandoned])
|
|
709
|
+
: await measurement.result
|
|
710
|
+
} finally {
|
|
711
|
+
watch?.dispose()
|
|
712
|
+
measurement.waiters -= 1
|
|
713
|
+
if (measurement.waiters === 0 && inFlight === measurement) measurement.cancel.abort()
|
|
714
|
+
}
|
|
715
|
+
}) as DockerWorkloadProbe
|
|
716
|
+
probe.last = () => latest
|
|
717
|
+
return probe
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Whether a kept verdict's egress half is one that asking again could still settle. */
|
|
721
|
+
function isWorthReMeasuring(egress: ContainerEgress): boolean {
|
|
722
|
+
return egress.status === 'undetermined' && egress.recheck
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* A verdict for the caller whose job was cancelled while it waited, and the listener teardown
|
|
727
|
+
* that keeps a long-lived native-transport process from accumulating one per job.
|
|
728
|
+
*/
|
|
729
|
+
function watchAbandonment(signal: AbortSignal): {
|
|
730
|
+
abandoned: Promise<DockerWorkload>
|
|
731
|
+
dispose: () => void
|
|
732
|
+
} {
|
|
733
|
+
let give: () => void = () => {}
|
|
734
|
+
const abandoned = new Promise<DockerWorkload>((resolve) => {
|
|
735
|
+
give = () =>
|
|
736
|
+
resolve(
|
|
737
|
+
undeterminable(
|
|
738
|
+
"the job was cancelled before the platform's container check answered",
|
|
739
|
+
false,
|
|
740
|
+
),
|
|
741
|
+
)
|
|
742
|
+
if (signal.aborted) give()
|
|
743
|
+
else signal.addEventListener('abort', give, { once: true })
|
|
744
|
+
})
|
|
745
|
+
return { abandoned, dispose: () => signal.removeEventListener('abort', give) }
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** The process-wide probe. One per container, which is what makes the positive memo worth having. */
|
|
749
|
+
export const probeDockerWorkload: DockerWorkloadProbe = createDockerWorkloadProbe()
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* What `GET /health` reports about the workload check.
|
|
753
|
+
*
|
|
754
|
+
* `unmeasured` is its own word rather than an omitted key or a `null`: this endpoint is polled
|
|
755
|
+
* from boot, so "nothing has needed the daemon yet" is the normal early answer and it must not
|
|
756
|
+
* read as either a broken daemon or a build that cannot report one.
|
|
757
|
+
*/
|
|
758
|
+
export function reportedDockerWorkload(
|
|
759
|
+
probe: DockerWorkloadProbe = probeDockerWorkload,
|
|
760
|
+
): DockerWorkload | { status: 'unmeasured'; reason: string } {
|
|
761
|
+
return (
|
|
762
|
+
probe.last() ?? {
|
|
763
|
+
status: 'unmeasured',
|
|
764
|
+
reason: 'nothing in this container has needed the docker daemon yet',
|
|
765
|
+
}
|
|
766
|
+
)
|
|
767
|
+
}
|