@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,171 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// The one-container image the platform runs to find out whether this machine's Docker daemon
|
|
5
|
+
// can actually run a container, built here in memory rather than pulled.
|
|
6
|
+
//
|
|
7
|
+
// It exists because `docker info` answers a different question from the one every caller
|
|
8
|
+
// actually asks. A daemon that ANSWERS is not a daemon that WORKS: this container's rootless
|
|
9
|
+
// daemon runs inside whatever sandbox the deployment gave it, and a nested user namespace
|
|
10
|
+
// routinely refuses the overlay mount every image materialisation needs. The daemon serves
|
|
11
|
+
// happily, `docker version` reports a server, and `docker pull` of a multi-layer image,
|
|
12
|
+
// `docker run` of a single-layer one and `docker build` all fail with the same EINVAL. Issue
|
|
13
|
+
// #2120 is three agents in one run each discovering that for themselves, against a system
|
|
14
|
+
// prompt that told them, as stated fact, that Docker worked here.
|
|
15
|
+
//
|
|
16
|
+
// Why the payload is BUILT and not pulled: a probe that needs the network answers a question
|
|
17
|
+
// about the registry as much as about the daemon, cannot run in a sandbox with no egress, and
|
|
18
|
+
// costs the job its first turn. This one is a single layer holding one statically linked
|
|
19
|
+
// binary already in the image, assembled into a docker-archive tar and handed to `docker load`
|
|
20
|
+
// on stdin, so the whole check is local and takes about as long as starting one container.
|
|
21
|
+
//
|
|
22
|
+
// ONE layer, deliberately. The reported failure kills `docker run` of a single-layer image too
|
|
23
|
+
// (the container's own writable layer is already a second overlay lower dir), so one layer is
|
|
24
|
+
// enough to detect it, and it keeps `docker load` (the step this file could plausibly get WRONG)
|
|
25
|
+
// as small as it can be. That matters because the caller reads a load failure as "could
|
|
26
|
+
// not determine" and a RUN failure as "this daemon cannot run containers": a bug in the archive
|
|
27
|
+
// below must never be able to tell an agent that a working daemon is broken.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** The tag the probe image is loaded under. Removed again once the check has answered. */
|
|
31
|
+
export const PROBE_IMAGE_TAG = 'cat-factory-docker-probe:1'
|
|
32
|
+
|
|
33
|
+
/** Where the payload binary lands inside the probe image, and what the container is asked to run. */
|
|
34
|
+
const PROBE_BINARY_PATH = 'busybox'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What the container must print for the check to pass.
|
|
38
|
+
*
|
|
39
|
+
* A marker on stdout rather than a zero exit status: the point of the check is that a process
|
|
40
|
+
* inside the container actually ran, and only output it produced proves that. An exit status is
|
|
41
|
+
* the daemon's word for it.
|
|
42
|
+
*/
|
|
43
|
+
export const PROBE_SENTINEL = 'cat-factory-docker-probe-ok'
|
|
44
|
+
|
|
45
|
+
/** The argv the probe container runs. `busybox` dispatches on its own name, so this is an echo. */
|
|
46
|
+
export const PROBE_COMMAND: readonly string[] = [`/${PROBE_BINARY_PATH}`, 'echo', PROBE_SENTINEL]
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Node's architecture names mapped onto the docker name for the same machine.
|
|
50
|
+
*
|
|
51
|
+
* This names THE PAYLOAD, never the daemon. `process.arch` is the architecture of the harness
|
|
52
|
+
* process, and the binary it hands over is built for that; the daemon it is measured against
|
|
53
|
+
* answers for itself (`docker version --format {{.Server.Arch}}`), and the caller compares the
|
|
54
|
+
* two rather than assuming they agree. An external `DOCKER_HOST` is a first-class path here, and
|
|
55
|
+
* an arm64 harness talking to an amd64 sidecar shares nothing with it but the socket.
|
|
56
|
+
*
|
|
57
|
+
* That comparison is also what makes two of these entries safe. `process.arch` reports `ppc64` on
|
|
58
|
+
* both endiannesses and `arm` with no variant, so those rows are a HYPOTHESIS about the payload,
|
|
59
|
+
* not a claim: a machine the guess is wrong about answers with a different name and the check
|
|
60
|
+
* reports that it could not be carried out. Nothing here may produce a verdict about the daemon.
|
|
61
|
+
* An architecture nothing maps does the same, which is why `386` was worth adding rather than
|
|
62
|
+
* leaving to a fallback: the mapping is unambiguous and its absence cost a real check.
|
|
63
|
+
*/
|
|
64
|
+
const PAYLOAD_ARCHITECTURES: Readonly<Record<string, string>> = {
|
|
65
|
+
x64: 'amd64',
|
|
66
|
+
ia32: '386',
|
|
67
|
+
arm64: 'arm64',
|
|
68
|
+
arm: 'arm',
|
|
69
|
+
s390x: 's390x',
|
|
70
|
+
ppc64: 'ppc64le',
|
|
71
|
+
riscv64: 'riscv64',
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The docker name for the architecture THIS process's payload is built for, when there is one. */
|
|
75
|
+
export function payloadArchitecture(arch: string = process.arch): string | undefined {
|
|
76
|
+
return PAYLOAD_ARCHITECTURES[arch]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const TAR_BLOCK = 512
|
|
80
|
+
|
|
81
|
+
/** A fixed timestamp everywhere a tar or an image config wants one, so the archive is byte-stable. */
|
|
82
|
+
const EPOCH = '1970-01-01T00:00:00Z'
|
|
83
|
+
|
|
84
|
+
interface TarEntry {
|
|
85
|
+
name: string
|
|
86
|
+
content: Buffer
|
|
87
|
+
mode: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One ustar header block.
|
|
92
|
+
*
|
|
93
|
+
* The checksum is summed with its own field read as eight SPACES and only then written back
|
|
94
|
+
* over it, which is the format's own rule and the one detail a hand-rolled writer gets wrong. A
|
|
95
|
+
* header whose checksum covers its own checksum bytes is rejected by every reader, and
|
|
96
|
+
* `docker load` reports that as an unreadable archive, which this module's caller would then
|
|
97
|
+
* have to decide was not the daemon's fault.
|
|
98
|
+
*/
|
|
99
|
+
function tarHeader(name: string, size: number, mode: number): Buffer {
|
|
100
|
+
const header = Buffer.alloc(TAR_BLOCK)
|
|
101
|
+
header.write(name, 0, 100, 'utf8')
|
|
102
|
+
header.write(octalField(mode, 8), 100, 8, 'latin1')
|
|
103
|
+
header.write(octalField(0, 8), 108, 8, 'latin1') // uid
|
|
104
|
+
header.write(octalField(0, 8), 116, 8, 'latin1') // gid
|
|
105
|
+
header.write(octalField(size, 12), 124, 12, 'latin1')
|
|
106
|
+
header.write(octalField(0, 12), 136, 12, 'latin1') // mtime
|
|
107
|
+
header.write(' ', 148, 8, 'latin1')
|
|
108
|
+
header.write('0', 156, 1, 'latin1') // typeflag: a regular file
|
|
109
|
+
header.write('ustar\0', 257, 6, 'latin1')
|
|
110
|
+
header.write('00', 263, 2, 'latin1')
|
|
111
|
+
let sum = 0
|
|
112
|
+
for (const byte of header) sum += byte
|
|
113
|
+
header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'latin1')
|
|
114
|
+
return header
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A numeric tar field: zero-padded octal in `width - 1` characters, then a NUL. */
|
|
118
|
+
function octalField(value: number, width: number): string {
|
|
119
|
+
return `${value.toString(8).padStart(width - 1, '0')}\0`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** One tar member: its header, its content, and the padding up to the next 512-byte block. */
|
|
123
|
+
function tarMember(entry: TarEntry): Buffer {
|
|
124
|
+
const padding = (TAR_BLOCK - (entry.content.length % TAR_BLOCK)) % TAR_BLOCK
|
|
125
|
+
return Buffer.concat([
|
|
126
|
+
tarHeader(entry.name, entry.content.length, entry.mode),
|
|
127
|
+
entry.content,
|
|
128
|
+
Buffer.alloc(padding),
|
|
129
|
+
])
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A whole tar stream: the members, then the two zero blocks that terminate one. */
|
|
133
|
+
export function tarArchive(entries: readonly TarEntry[]): Buffer {
|
|
134
|
+
return Buffer.concat([...entries.map(tarMember), Buffer.alloc(TAR_BLOCK * 2)])
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Assemble the docker-archive `docker load` reads, from one statically linked binary.
|
|
139
|
+
*
|
|
140
|
+
* Classic (v1) docker-archive rather than OCI layout: `docker load` accepts both on every engine
|
|
141
|
+
* this image can run against, and the v1 shape is three files with no blob directory to get
|
|
142
|
+
* wrong. The layer digest is the sha256 of the UNCOMPRESSED layer tar, which is what
|
|
143
|
+
* `rootfs.diff_ids` means; an engine that disagrees with it refuses the load, which the caller
|
|
144
|
+
* reads as could-not-determine rather than as a broken daemon.
|
|
145
|
+
*
|
|
146
|
+
* `architecture` is the DAEMON's own word for its architecture, in docker's vocabulary, so
|
|
147
|
+
* nothing here decides it (see {@link PAYLOAD_ARCHITECTURES}). The result is byte-stable for one
|
|
148
|
+
* `(payload, architecture)` pair, which is what lets the caller build it once per container.
|
|
149
|
+
*/
|
|
150
|
+
export function buildProbeArchive(payload: Buffer, architecture: string): Buffer {
|
|
151
|
+
const layer = tarArchive([{ name: PROBE_BINARY_PATH, content: payload, mode: 0o755 }])
|
|
152
|
+
const diffId = `sha256:${createHash('sha256').update(layer).digest('hex')}`
|
|
153
|
+
const config = Buffer.from(
|
|
154
|
+
JSON.stringify({
|
|
155
|
+
architecture,
|
|
156
|
+
os: 'linux',
|
|
157
|
+
created: EPOCH,
|
|
158
|
+
config: {},
|
|
159
|
+
rootfs: { type: 'layers', diff_ids: [diffId] },
|
|
160
|
+
history: [{ created: EPOCH, created_by: 'cat-factory docker capability probe' }],
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
const manifest = Buffer.from(
|
|
164
|
+
JSON.stringify([{ Config: 'config.json', RepoTags: [PROBE_IMAGE_TAG], Layers: ['layer.tar'] }]),
|
|
165
|
+
)
|
|
166
|
+
return tarArchive([
|
|
167
|
+
{ name: 'config.json', content: config, mode: 0o644 },
|
|
168
|
+
{ name: 'layer.tar', content: layer, mode: 0o644 },
|
|
169
|
+
{ name: 'manifest.json', content: manifest, mode: 0o644 },
|
|
170
|
+
])
|
|
171
|
+
}
|
package/src/docker-status.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process'
|
|
2
1
|
import { readFile } from 'node:fs/promises'
|
|
3
|
-
import {
|
|
2
|
+
import { probeDockerWorkload, type DockerWorkload } from './docker-capability.js'
|
|
3
|
+
import { log, type Logger } from './logger.js'
|
|
4
4
|
|
|
5
5
|
// What this container knows about its own Docker daemon, as recorded by `entrypoint.sh`.
|
|
6
6
|
//
|
|
@@ -14,8 +14,12 @@ import { promisify } from 'node:util'
|
|
|
14
14
|
// daemon reads it instead.
|
|
15
15
|
//
|
|
16
16
|
// The recorded verdict describes BOOT, and a container outlives its boot, so nothing refuses on
|
|
17
|
-
// it unconfirmed: `resolveDockerVerdict` re-checks
|
|
18
|
-
//
|
|
17
|
+
// it unconfirmed: `resolveDockerVerdict` re-checks it against a live daemon and keeps the record
|
|
18
|
+
// for what only the record holds, the cause and the daemon's own log tail. What it records is
|
|
19
|
+
// also only that a SOCKET answered, which is a weaker fact than any caller wants, so the live
|
|
20
|
+
// check RUNS A CONTAINER (docker-capability.ts) rather than settling for the daemon's word about
|
|
21
|
+
// itself. It still reports that weaker fact alongside, since a check that could not be carried
|
|
22
|
+
// out is what the boot record has to be read against, and nothing else establishes it.
|
|
19
23
|
//
|
|
20
24
|
// The three-valued shape is deliberate and is the point (CLAUDE.md, "Degrade loudly"): a daemon
|
|
21
25
|
// that FAILED and a daemon nobody asked about are different facts with different correct
|
|
@@ -146,56 +150,149 @@ function unnamedSource(source: never): string {
|
|
|
146
150
|
return `no Docker daemon answered in this container (unrecognised source ${JSON.stringify(source)})`
|
|
147
151
|
}
|
|
148
152
|
|
|
149
|
-
/**
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
/**
|
|
154
|
+
* What a daemon can do RIGHT NOW. Injected so the unit suite can state every answer.
|
|
155
|
+
*
|
|
156
|
+
* It answers with a WORKLOAD rather than with a boolean, and that is the correction this type
|
|
157
|
+
* carries. It used to be `docker version`, which proves the daemon is serving; a stand-up needs
|
|
158
|
+
* a daemon that can materialise an image, and a sandboxed rootless daemon routinely serves while
|
|
159
|
+
* being unable to (issue #2120). Running compose against that one produced a mount error the
|
|
160
|
+
* agent had to interpret, from the one mechanism whose entire job is to say why infra did not
|
|
161
|
+
* come up.
|
|
162
|
+
*
|
|
163
|
+
* The weaker fact did not go away, though: it rides `daemonAnswered` on the `unknown` arm, since
|
|
164
|
+
* the workload check establishes it on its way past and {@link resolveDockerVerdict} still needs
|
|
165
|
+
* it. Takes the job's signal, because this is a live check on the critical path of a run that can
|
|
166
|
+
* be cancelled under it.
|
|
167
|
+
*/
|
|
168
|
+
export type DockerProbe = (signal?: AbortSignal) => Promise<DockerWorkload>
|
|
154
169
|
|
|
155
|
-
|
|
170
|
+
/**
|
|
171
|
+
* The default {@link DockerProbe}: the process-wide workload probe, which loads a one-layer
|
|
172
|
+
* image and runs a container from it, memoised per container.
|
|
173
|
+
*
|
|
174
|
+
* Named for what it answers. It was `probeDockerServing`, which is the fact this module exists to
|
|
175
|
+
* say is not enough.
|
|
176
|
+
*/
|
|
177
|
+
export const probeLiveDockerCapability: DockerProbe = (signal) => probeDockerWorkload(signal)
|
|
156
178
|
|
|
157
179
|
/**
|
|
158
|
-
* The
|
|
159
|
-
*
|
|
180
|
+
* The sentence for a daemon that is serving and cannot run anything. It names what was tried,
|
|
181
|
+
* because "docker is unavailable" against a daemon the agent can see answering reads as a bug in
|
|
182
|
+
* the platform rather than as the sandbox limit it is.
|
|
160
183
|
*/
|
|
161
|
-
export
|
|
162
|
-
|
|
163
|
-
await execFileAsync('docker', ['version', '--format', '{{.Server.Version}}'], {
|
|
164
|
-
timeout: PROBE_TIMEOUT_MS,
|
|
165
|
-
})
|
|
166
|
-
return true
|
|
167
|
-
} catch {
|
|
168
|
-
return false
|
|
169
|
-
}
|
|
184
|
+
export function describeDockerUnusable(workload: { detail: string }): string {
|
|
185
|
+
return `this container's Docker daemon is reachable but cannot run a container (${workload.detail})`
|
|
170
186
|
}
|
|
171
187
|
|
|
172
188
|
/** What a stand-up is entitled to conclude about the daemon at the moment it is about to run. */
|
|
173
189
|
export interface DockerVerdict {
|
|
174
|
-
/**
|
|
190
|
+
/**
|
|
191
|
+
* Whether a stand-up may PROCEED, three-valued exactly as {@link DockerStatus.available} and
|
|
192
|
+
* read the same way. It is the decision, not a description of the daemon: a daemon that is
|
|
193
|
+
* answering and cannot run a container is `false` here and `daemon: true` below, and a record
|
|
194
|
+
* that reported the first as the second would send an operator to restart a daemon that is
|
|
195
|
+
* already up.
|
|
196
|
+
*/
|
|
175
197
|
available: boolean | undefined
|
|
176
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* Set only for a CONFIRMED negative: the sentence to refuse with. Absent means proceed.
|
|
200
|
+
*
|
|
201
|
+
* Two causes reach it and they read differently on purpose. Nothing is answering here, and
|
|
202
|
+
* something is answering here but cannot run a container: an operator sent to restart a daemon
|
|
203
|
+
* that is already up would find nothing wrong with it.
|
|
204
|
+
*/
|
|
177
205
|
refusal?: string
|
|
206
|
+
/**
|
|
207
|
+
* Whether a daemon ANSWERED the live check. Absent when nothing was checked (an undecided
|
|
208
|
+
* record probes nothing) or when nothing answered, so it is never read as a decided `false`.
|
|
209
|
+
*/
|
|
210
|
+
daemon?: boolean
|
|
211
|
+
/** What a real container did on it, when one was tried. Absent when nothing was measured. */
|
|
212
|
+
workload?: DockerWorkload
|
|
178
213
|
}
|
|
179
214
|
|
|
180
215
|
/**
|
|
181
|
-
* Resolve what to do now, from what boot recorded plus what
|
|
216
|
+
* Resolve what to do now, from what boot recorded plus what the daemon can do today.
|
|
217
|
+
*
|
|
218
|
+
* `entrypoint.sh` probes once, at boot, within a bounded wait, and it probes for a SOCKET. Two
|
|
219
|
+
* things follow, and the branches below are one each.
|
|
182
220
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* it; the recorded verdict is still what supplies the cause and the daemon's own log tail, which
|
|
189
|
-
* no probe can reconstruct.
|
|
221
|
+
* A recorded absence is a HYPOTHESIS. A container outlives its boot: a warm pool serves many jobs
|
|
222
|
+
* from one, and a sidecar daemon that took longer than the wait allows is serving perfectly well
|
|
223
|
+
* by the second job. Refusing off the record alone latches that container into refusing local
|
|
224
|
+
* infra that works, for its whole life, with a stale sentence explaining why. The record is still
|
|
225
|
+
* what supplies the cause and the daemon's own log tail, which no probe can reconstruct.
|
|
190
226
|
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
227
|
+
* A recorded PRESENCE is a hypothesis too, and that half was missing. `serving` is not `usable`:
|
|
228
|
+
* a rootless daemon in a sandbox answers while unable to mount any image layer, so compose ran
|
|
229
|
+
* and died on a mount error the agent then had to interpret. So the probe is consulted in both
|
|
230
|
+
* directions, and it runs a real container rather than asking the daemon about itself.
|
|
231
|
+
*
|
|
232
|
+
* A check that could not be CARRIED OUT settles nothing, and the cheap fact is what decides
|
|
233
|
+
* there. Falling straight back to the boot record would re-latch the very refusal the paragraph
|
|
234
|
+
* above rules out: the four ways the workload check can come back undeterminable (no payload in
|
|
235
|
+
* this image variant, an architecture it is not built for, a `docker load` the engine refuses, a
|
|
236
|
+
* timeout) have nothing to do with whether a daemon is up, so a warm container whose sidecar
|
|
237
|
+
* arrived late would be denied local infra for the rest of its life over a stale sentence. So a
|
|
238
|
+
* daemon that ANSWERED contradicts a recorded absence exactly as the old `docker version` probe
|
|
239
|
+
* did, and only a check that never reached a daemon at all leaves the record to decide.
|
|
240
|
+
*
|
|
241
|
+
* "Not decided" still keeps attempting, untouched. The point of the third value is that NOTHING
|
|
242
|
+
* turns it into a refusal: the entrypoint's bounded wait may still be running, and a workload
|
|
243
|
+
* probe against a daemon that has not finished starting fails for a reason that says nothing
|
|
244
|
+
* about what it will do a second later.
|
|
193
245
|
*/
|
|
194
246
|
export async function resolveDockerVerdict(
|
|
195
247
|
status: DockerStatus,
|
|
196
|
-
|
|
248
|
+
opts: { probe?: DockerProbe; signal?: AbortSignal; logger?: Logger } = {},
|
|
197
249
|
): Promise<DockerVerdict> {
|
|
198
|
-
if (status.available
|
|
199
|
-
|
|
200
|
-
|
|
250
|
+
if (status.available === undefined) return { available: undefined }
|
|
251
|
+
const workload = await askTotally(
|
|
252
|
+
opts.probe ?? probeLiveDockerCapability,
|
|
253
|
+
opts.signal,
|
|
254
|
+
opts.logger,
|
|
255
|
+
)
|
|
256
|
+
if (workload.status === 'usable') return { available: true, daemon: true, workload }
|
|
257
|
+
if (workload.status === 'unusable') {
|
|
258
|
+
return {
|
|
259
|
+
available: false,
|
|
260
|
+
refusal: describeDockerUnusable(workload),
|
|
261
|
+
daemon: true,
|
|
262
|
+
workload,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (workload.daemonAnswered) return { available: true, daemon: true, workload }
|
|
266
|
+
return status.available
|
|
267
|
+
? { available: true, workload }
|
|
268
|
+
: { available: false, refusal: describeDockerAbsence(status), workload }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Call the probe and answer even if it throws.
|
|
273
|
+
*
|
|
274
|
+
* The default probe is total by construction and says so, but this is the seam an injected one
|
|
275
|
+
* arrives through, and the caller is a stand-up documented as best-effort: a throw here would
|
|
276
|
+
* fail a job over the mechanism whose whole purpose is to make a failure legible. A throw settles
|
|
277
|
+
* nothing about the daemon, so it becomes the same value as any other check that could not be
|
|
278
|
+
* carried out and the boot record decides, exactly as it did before the probe existed.
|
|
279
|
+
*/
|
|
280
|
+
async function askTotally(
|
|
281
|
+
probe: DockerProbe,
|
|
282
|
+
signal: AbortSignal | undefined,
|
|
283
|
+
logger: Logger | undefined,
|
|
284
|
+
): Promise<DockerWorkload> {
|
|
285
|
+
try {
|
|
286
|
+
return await probe(signal)
|
|
287
|
+
} catch (err) {
|
|
288
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
289
|
+
;(logger ?? log).warn('docker: the live daemon check threw; falling back to the boot record', {
|
|
290
|
+
error: message,
|
|
291
|
+
})
|
|
292
|
+
return {
|
|
293
|
+
status: 'unknown',
|
|
294
|
+
reason: `the live Docker check could not be carried out (${message})`,
|
|
295
|
+
daemonAnswered: false,
|
|
296
|
+
}
|
|
297
|
+
}
|
|
201
298
|
}
|
|
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process'
|
|
|
2
2
|
import { promisify } from 'node:util'
|
|
3
3
|
import { log, type Logger } from './logger.js'
|
|
4
4
|
import { harnessListenPort } from './harness-port.js'
|
|
5
|
+
import { probeDockerWorkload, type DockerWorkload } from './docker-capability.js'
|
|
5
6
|
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// What this machine actually has, probed ONCE per job and stated to the agent.
|
|
@@ -32,6 +33,12 @@ import { harnessListenPort } from './harness-port.js'
|
|
|
32
33
|
// a daemon this machine is CONFIGURED for but which has not answered yet is a fourth state
|
|
33
34
|
// that resolves to `unknown`, never to the absence a refused connection looks like: the
|
|
34
35
|
// image's daemon is started in the background and the job begins before it is ready.
|
|
36
|
+
// - A daemon that ANSWERS is still not a daemon that WORKS, which is the same mistake one level
|
|
37
|
+
// in. A rootless daemon nested in a sandbox serves while its snapshotter cannot mount any
|
|
38
|
+
// image layer, so `docker info` succeeds and `docker build` / `docker run` / `docker pull`
|
|
39
|
+
// all fail (issue #2120). Only a container that RAN settles that, so the reachable case is
|
|
40
|
+
// split by a real workload (docker-capability.ts) into `usable`, `unusable`, and a daemon
|
|
41
|
+
// that answered while the check itself could not be carried out.
|
|
35
42
|
//
|
|
36
43
|
// Deliberately NOT here: the agent's own tools (web search, file tools, MCP servers). Those are
|
|
37
44
|
// the CLI's, they differ per harness, and each is already stated where it is true. Claiming one
|
|
@@ -68,6 +75,28 @@ export type ToolPresence =
|
|
|
68
75
|
| { status: 'absent' }
|
|
69
76
|
| { status: 'unknown'; reason: string }
|
|
70
77
|
|
|
78
|
+
/**
|
|
79
|
+
* What this machine's Docker daemon is good for, which is FIVE answers and not three.
|
|
80
|
+
*
|
|
81
|
+
* `absent` and `unknown` mean for the daemon what they mean for any other tool. The three that
|
|
82
|
+
* are particular to Docker split the case where a daemon ANSWERED, because answering is not the
|
|
83
|
+
* question anyone is asking:
|
|
84
|
+
*
|
|
85
|
+
* - `usable`: a container was built and run on it here. `docker build` / `run` / `compose`
|
|
86
|
+
* work, and this is the only state that may say so.
|
|
87
|
+
* - `unusable`: a container could NOT be run, with the daemon serving throughout. The state
|
|
88
|
+
* issue #2120 is about; stated as a prohibition, with the cause.
|
|
89
|
+
* - `serving`: it answered, and the workload check could not be carried out (no payload on
|
|
90
|
+
* this machine, an unmapped architecture, a timeout). Neither of the other two,
|
|
91
|
+
* and rendered as "try it if you need it".
|
|
92
|
+
*/
|
|
93
|
+
export type DockerCapability =
|
|
94
|
+
| { status: 'usable'; server?: string }
|
|
95
|
+
| { status: 'unusable'; server?: string; detail: string }
|
|
96
|
+
| { status: 'serving'; server?: string; reason: string }
|
|
97
|
+
| { status: 'absent' }
|
|
98
|
+
| { status: 'unknown'; reason: string }
|
|
99
|
+
|
|
71
100
|
/** One probed entry: the name the agent would type, and what came back. */
|
|
72
101
|
export interface ProbedTool {
|
|
73
102
|
name: string
|
|
@@ -80,12 +109,13 @@ export interface ProbedTool {
|
|
|
80
109
|
export interface EnvironmentInventory {
|
|
81
110
|
tools: ProbedTool[]
|
|
82
111
|
/**
|
|
83
|
-
*
|
|
84
|
-
* image's `entrypoint.sh` starts a rootless daemon BEST-EFFORT and execs
|
|
85
|
-
* waiting for it, so at job start this probe is the only thing that knows
|
|
86
|
-
* "has not answered yet" is one of its answers (see {@link probeDockerDaemon})
|
|
112
|
+
* What the Docker daemon is good for: not the CLI's presence, and not merely whether the
|
|
113
|
+
* daemon answered. The image's `entrypoint.sh` starts a rootless daemon BEST-EFFORT and execs
|
|
114
|
+
* the server without waiting for it, so at job start this probe is the only thing that knows
|
|
115
|
+
* how that went; "has not answered yet" is one of its answers (see {@link probeDockerDaemon})
|
|
116
|
+
* and "answered, but cannot run a container" is another (see {@link DockerCapability}).
|
|
87
117
|
*/
|
|
88
|
-
dockerDaemon:
|
|
118
|
+
dockerDaemon: DockerCapability
|
|
89
119
|
/**
|
|
90
120
|
* The port the harness's own job server holds in this network namespace. Not probed: the
|
|
91
121
|
* process reads its own {@link harnessListenPort}, which is the only honest answer when a
|
|
@@ -310,6 +340,18 @@ export interface ProbeEnvironmentOptions {
|
|
|
310
340
|
* only so the suite can assert the rendered line without an ambient `PORT` deciding its text.
|
|
311
341
|
*/
|
|
312
342
|
harnessPort?: number
|
|
343
|
+
/**
|
|
344
|
+
* Whether the daemon can actually RUN a container, defaulting to the process-wide probe
|
|
345
|
+
* (docker-capability.ts). Asked only once a daemon has answered, since there is nothing to run
|
|
346
|
+
* a workload on otherwise, and memoised per container so a warm pool pays for it once.
|
|
347
|
+
*/
|
|
348
|
+
workload?: (signal?: AbortSignal) => Promise<DockerWorkload>
|
|
349
|
+
/**
|
|
350
|
+
* The job's signal, forwarded to the probes that spawn something. The workload check starts a
|
|
351
|
+
* CONTAINER, so a cancelled job must stop paying for it rather than hold the daemon for the
|
|
352
|
+
* rest of its budget.
|
|
353
|
+
*/
|
|
354
|
+
signal?: AbortSignal
|
|
313
355
|
}
|
|
314
356
|
|
|
315
357
|
/**
|
|
@@ -346,11 +388,35 @@ export async function probeEnvironment(
|
|
|
346
388
|
presence: toolPresence(await run(probe.command, probe.args)),
|
|
347
389
|
})),
|
|
348
390
|
),
|
|
349
|
-
|
|
391
|
+
probeDockerCapability(run, opts),
|
|
350
392
|
])
|
|
351
393
|
return { tools, dockerDaemon, harnessPort: opts.harnessPort ?? harnessListenPort() }
|
|
352
394
|
}
|
|
353
395
|
|
|
396
|
+
/**
|
|
397
|
+
* The daemon's full answer: whether one is reachable, and then whether it can run a container.
|
|
398
|
+
*
|
|
399
|
+
* The two steps are kept apart because they fail for unrelated reasons and only the FIRST has a
|
|
400
|
+
* cheap answer. A daemon nobody can reach has no workload to run, so the check that costs a
|
|
401
|
+
* container start is asked only where there is something to ask it of; a daemon that answered
|
|
402
|
+
* carries its server version into every one of the three states that follow it, because the
|
|
403
|
+
* agent reading the line is entitled to know which daemon the verdict is about.
|
|
404
|
+
*/
|
|
405
|
+
async function probeDockerCapability(
|
|
406
|
+
run: ProbeRunner,
|
|
407
|
+
opts: ProbeEnvironmentOptions,
|
|
408
|
+
): Promise<DockerCapability> {
|
|
409
|
+
const daemon = await probeDockerDaemon(run, opts)
|
|
410
|
+
if (daemon.status === 'absent') return { status: 'absent' }
|
|
411
|
+
if (daemon.status === 'unknown') return { status: 'unknown', reason: daemon.reason }
|
|
412
|
+
const server = daemon.version ? { server: daemon.version } : {}
|
|
413
|
+
const workload = await (opts.workload ?? probeDockerWorkload)(opts.signal)
|
|
414
|
+
if (workload.status === 'usable') return { status: 'usable', ...server }
|
|
415
|
+
if (workload.status === 'unusable')
|
|
416
|
+
return { status: 'unusable', ...server, detail: workload.detail }
|
|
417
|
+
return { status: 'serving', ...server, reason: workload.reason }
|
|
418
|
+
}
|
|
419
|
+
|
|
354
420
|
/**
|
|
355
421
|
* Ask the daemon itself, and do not mistake a daemon that is STARTING for one that is not there.
|
|
356
422
|
*
|
|
@@ -482,26 +548,57 @@ function harnessPortLine(port: number): string {
|
|
|
482
548
|
)
|
|
483
549
|
}
|
|
484
550
|
|
|
485
|
-
/**
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
551
|
+
/**
|
|
552
|
+
* The Docker line, which says something different in each of the five cases, and is TOTAL over
|
|
553
|
+
* them: adding a state without deciding what an agent should do about it stops the build.
|
|
554
|
+
*
|
|
555
|
+
* Only `usable` may claim the commands work, and it may only be reached by having RUN one. The
|
|
556
|
+
* line that used to stand here made that claim off `docker info` alone, which is how every agent
|
|
557
|
+
* in a run was told, as fact, that a daemon which could not mount a single image layer would
|
|
558
|
+
* build and run one.
|
|
559
|
+
*/
|
|
560
|
+
function dockerDaemonLine(daemon: DockerCapability): string {
|
|
561
|
+
const server = 'server' in daemon && daemon.server ? ` (server ${daemon.server})` : ''
|
|
562
|
+
switch (daemon.status) {
|
|
563
|
+
case 'usable':
|
|
564
|
+
return (
|
|
565
|
+
`A Docker daemon is reachable${server} and the platform ran a container on it: ` +
|
|
566
|
+
'`docker build`, `docker run` and `docker compose up` work here.'
|
|
567
|
+
)
|
|
568
|
+
case 'unusable':
|
|
569
|
+
return (
|
|
570
|
+
`A Docker daemon is reachable${server} but it CANNOT run a container: the platform ` +
|
|
571
|
+
`built a one-layer image and tried to run it here, and that failed (${daemon.detail}). ` +
|
|
572
|
+
'`docker build`, `docker run`, `docker pull` of a multi-layer image and ' +
|
|
573
|
+
'`docker compose up` all fail for the same reason, so there is nothing to retry and no ' +
|
|
574
|
+
'flag that works around it. Produce the Dockerfile or compose file you were asked for, ' +
|
|
575
|
+
'say in one line that it could not be built or run here, and move on.'
|
|
576
|
+
)
|
|
577
|
+
case 'serving':
|
|
578
|
+
return (
|
|
579
|
+
`A Docker daemon is reachable${server}, but whether it can actually build or run an ` +
|
|
580
|
+
`image was NOT established (${daemon.reason}). Reaching the daemon is not the same fact: ` +
|
|
581
|
+
'a sandboxed one answers while being unable to mount any image layer. Try it if you need ' +
|
|
582
|
+
'it, and do not read a failure as a defect in the work.'
|
|
583
|
+
)
|
|
584
|
+
case 'unknown':
|
|
585
|
+
return (
|
|
586
|
+
'Whether a Docker daemon is reachable could not be determined ' +
|
|
587
|
+
`(${daemon.reason}): try it if you need it, and do not read a failure as a defect in the work.`
|
|
588
|
+
)
|
|
589
|
+
case 'absent':
|
|
590
|
+
return (
|
|
591
|
+
'NO Docker daemon is reachable: `docker build`, `docker run` and `docker compose up` ' +
|
|
592
|
+
'will fail here whatever the CLI reports. Produce the Dockerfile or compose file you ' +
|
|
593
|
+
'were asked for, say in one line that you could not build it here, and move on.'
|
|
594
|
+
)
|
|
595
|
+
default:
|
|
596
|
+
return unnamedCapability(daemon)
|
|
499
597
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function unnamedCapability(daemon: never): string {
|
|
601
|
+
return `Whether a Docker daemon is reachable could not be determined (the platform reported an unrecognised verdict ${JSON.stringify(daemon)}): try it if you need it.`
|
|
505
602
|
}
|
|
506
603
|
|
|
507
604
|
/**
|
|
@@ -520,15 +617,16 @@ function dockerDaemonLine(daemon: ToolPresence): string {
|
|
|
520
617
|
*/
|
|
521
618
|
export async function appendEnvironmentInventory(
|
|
522
619
|
systemPrompt: string,
|
|
523
|
-
opts: {
|
|
620
|
+
opts: { log?: Logger; run?: ProbeRunner } & ProbeEnvironmentOptions = {},
|
|
524
621
|
): Promise<string> {
|
|
525
622
|
const logger = opts.log ?? log
|
|
623
|
+
// Everything that is not this function's OWN is forwarded by construction, rather than key by
|
|
624
|
+
// key. The list of copied keys silently dropped `workload`, whose whole point is that a suite
|
|
625
|
+
// can inject one: a test driving THIS entry point (the only one `handleAgent` uses) got the
|
|
626
|
+
// real probe instead, which starts a container on whatever machine the suite runs on.
|
|
627
|
+
const { log: _log, run, ...probeOptions } = opts
|
|
526
628
|
try {
|
|
527
|
-
const inventory = await probeEnvironment(
|
|
528
|
-
...(opts.sleep ? { sleep: opts.sleep } : {}),
|
|
529
|
-
...(opts.daemonExpected === undefined ? {} : { daemonExpected: opts.daemonExpected }),
|
|
530
|
-
...(opts.harnessPort === undefined ? {} : { harnessPort: opts.harnessPort }),
|
|
531
|
-
})
|
|
629
|
+
const inventory = await probeEnvironment(run ?? spawnProbeRunner(opts.signal), probeOptions)
|
|
532
630
|
logger.info('agent: probed the environment', {
|
|
533
631
|
installed: inventory.tools
|
|
534
632
|
.filter((t) => t.presence.status === 'present')
|
package/src/harness-server.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { handleAgent } from './agent.js'
|
|
|
6
6
|
import { handleInline } from './inline.js'
|
|
7
7
|
import { redactSecrets } from './git.js'
|
|
8
8
|
import { readDockerStatus } from './docker-status.js'
|
|
9
|
+
import { reportedDockerWorkload } from './docker-capability.js'
|
|
9
10
|
import { harnessListenPort } from './harness-port.js'
|
|
10
11
|
import { JobRegistry, loadRunnerLimits, type JobResultBase, type RunOptions } from './runner.js'
|
|
11
12
|
import { log } from './logger.js'
|
|
@@ -141,11 +142,16 @@ const server = createServer((req, res) => {
|
|
|
141
142
|
// would spawn a process per poll to answer a question this endpoint is not the one to act
|
|
142
143
|
// on; the stand-up re-confirms a recorded absence at the moment it matters
|
|
143
144
|
// (`resolveDockerVerdict`), so a stale negative here never becomes a stale refusal there.
|
|
145
|
+
//
|
|
146
|
+
// `workload` is the other half, and the reason the block used to mislead: what the record
|
|
147
|
+
// says is `serving`, and serving is not usable. It reports the last measurement any job
|
|
148
|
+
// took (docker-capability.ts) and NEVER takes one itself, for the same polling reason,
|
|
149
|
+
// which is why `unmeasured` is one of the words it can answer.
|
|
144
150
|
return send(res, 200, {
|
|
145
151
|
status: 'ok',
|
|
146
152
|
...(HARNESS_VERSION ? { version: HARNESS_VERSION } : {}),
|
|
147
153
|
capabilities: HARNESS_BODY_CAPABILITIES,
|
|
148
|
-
docker: await readDockerStatus(),
|
|
154
|
+
docker: { ...(await readDockerStatus()), workload: reportedDockerWorkload() },
|
|
149
155
|
})
|
|
150
156
|
}
|
|
151
157
|
// All non-health endpoints are gated by the optional shared secret.
|