@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.
@@ -0,0 +1,97 @@
1
+ /** The tag the probe image is loaded under. Removed again once the check has answered. */
2
+ export declare const PROBE_IMAGE_TAG = "cat-factory-docker-probe:1";
3
+ /**
4
+ * What the container must print for the check to pass.
5
+ *
6
+ * A marker on stdout rather than a zero exit status: the point of the check is that a process
7
+ * inside the container actually ran, and only output it produced proves that. An exit status is
8
+ * the daemon's word for it.
9
+ */
10
+ export declare const PROBE_SENTINEL = "cat-factory-docker-probe-ok";
11
+ /** The argv the probe container runs. `busybox` dispatches on its own name, so this is an echo. */
12
+ export declare const PROBE_COMMAND: readonly string[];
13
+ /**
14
+ * What the egress container prints for each observation: the marker, then the exit STATUS of the
15
+ * command that made it.
16
+ *
17
+ * The status rather than a pass/fail marker, because the two failures need different answers.
18
+ * A refused connection is evidence about the network; a 127 is busybox saying it has no such
19
+ * applet, which is evidence about the platform's own probe image and may never be reported as a
20
+ * network that is not there.
21
+ */
22
+ export declare const EGRESS_TCP_MARKER = "cat-factory-egress-tcp=";
23
+ export declare const EGRESS_DNS_MARKER = "cat-factory-egress-dns=";
24
+ /** Where the egress check aims: a raw address, plus a name to resolve. */
25
+ export interface EgressTarget {
26
+ host: string;
27
+ port: number;
28
+ dnsName: string;
29
+ }
30
+ /**
31
+ * Read the configured target, or say why it cannot be used.
32
+ *
33
+ * Validated rather than trusted, and strictly, for two reasons that both matter. The host and the
34
+ * name are interpolated into a `sh -c` script INSIDE the probe container, so anything else there
35
+ * would be running whatever a deployment's environment happened to hold; and a target that is
36
+ * quietly wrong produces a confident `blocked` about a daemon that is fine, which is the exact
37
+ * class of lie this whole module exists to remove. A rejected setting is REPORTED as a check that
38
+ * could not be carried out, never silently swapped for the default: an operator who pointed this
39
+ * at an address their network permits is entitled to find out that it was ignored.
40
+ */
41
+ export declare function parseEgressTarget(target: string, dnsName: string): {
42
+ target: EgressTarget;
43
+ } | {
44
+ invalid: string;
45
+ };
46
+ /**
47
+ * The argv the egress container runs: connect, say what that returned, resolve, say the same.
48
+ *
49
+ * Both observations are made and BOTH are reported, because they fail for different reasons and
50
+ * have different fixes. A connect to a raw address needs only a route; a lookup needs the
51
+ * daemon's embedded resolver to be reachable and to forward. Reporting only the first would call
52
+ * a container with a working route and broken DNS "reachable", and nothing an agent fetches by
53
+ * name would work there.
54
+ *
55
+ * Every applet is called by its full path (`/busybox nc`) rather than by name. The image holds
56
+ * one file and no PATH, and busybox's standalone-shell dispatch is a build-time option nothing
57
+ * here may assume.
58
+ *
59
+ * The connect is the half that is easy to get silently wrong, and `nc -w SEC` alone gets it
60
+ * wrong. busybox documents that flag as the timeout for connects AND FINAL NET READS: once stdin
61
+ * hits EOF `nc` half-closes and then waits to be spoken to, so a connect that SUCCEEDED to a peer
62
+ * which expects the client to speak first (every TLS port, the default `1.1.1.1:443` included)
63
+ * hits the alarm and exits non-zero. Read off the exit status alone that is indistinguishable
64
+ * from a refusal, so a working network reports a route that is not there. `-z` means "connect,
65
+ * then stop", which is the question being asked, so it is used wherever the payload's busybox was
66
+ * built with it; where it was not, the connect runs with no `-w`, which is the build whose `nc`
67
+ * exits on its own when stdin closes.
68
+ *
69
+ * Both halves are wrapped in `${busybox} timeout` either way, since a blackholed route is silent
70
+ * rather than refused and the applet's own ceiling is the thing this comment exists because we
71
+ * cannot assume.
72
+ */
73
+ export declare function buildEgressCommand(target: EgressTarget): readonly string[];
74
+ /** The docker name for the architecture THIS process's payload is built for, when there is one. */
75
+ export declare function payloadArchitecture(arch?: string): string | undefined;
76
+ interface TarEntry {
77
+ name: string;
78
+ content: Buffer;
79
+ mode: number;
80
+ }
81
+ /** A whole tar stream: the members, then the two zero blocks that terminate one. */
82
+ export declare function tarArchive(entries: readonly TarEntry[]): Buffer;
83
+ /**
84
+ * Assemble the docker-archive `docker load` reads, from one statically linked binary.
85
+ *
86
+ * Classic (v1) docker-archive rather than OCI layout: `docker load` accepts both on every engine
87
+ * this image can run against, and the v1 shape is three files with no blob directory to get
88
+ * wrong. The layer digest is the sha256 of the UNCOMPRESSED layer tar, which is what
89
+ * `rootfs.diff_ids` means; an engine that disagrees with it refuses the load, which the caller
90
+ * reads as could-not-determine rather than as a broken daemon.
91
+ *
92
+ * `architecture` is the DAEMON's own word for its architecture, in docker's vocabulary, so
93
+ * nothing here decides it (see {@link PAYLOAD_ARCHITECTURES}). The result is byte-stable for one
94
+ * `(payload, architecture)` pair, which is what lets the caller build it once per container.
95
+ */
96
+ export declare function buildProbeArchive(payload: Buffer, architecture: string): Buffer;
97
+ export {};
@@ -0,0 +1,283 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { scrubbedExcerpt } from './redact.js';
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
+ /** The tag the probe image is loaded under. Removed again once the check has answered. */
30
+ export const PROBE_IMAGE_TAG = 'cat-factory-docker-probe:1';
31
+ /** Where the payload binary lands inside the probe image, and what the container is asked to run. */
32
+ const PROBE_BINARY_PATH = 'busybox';
33
+ /**
34
+ * What the container must print for the check to pass.
35
+ *
36
+ * A marker on stdout rather than a zero exit status: the point of the check is that a process
37
+ * inside the container actually ran, and only output it produced proves that. An exit status is
38
+ * the daemon's word for it.
39
+ */
40
+ export const PROBE_SENTINEL = 'cat-factory-docker-probe-ok';
41
+ /** The argv the probe container runs. `busybox` dispatches on its own name, so this is an echo. */
42
+ export const PROBE_COMMAND = [`/${PROBE_BINARY_PATH}`, 'echo', PROBE_SENTINEL];
43
+ // ---------------------------------------------------------------------------
44
+ // The second thing the same image is asked, and the one the marker run above structurally cannot
45
+ // answer: whether a container started on this daemon can reach the NETWORK.
46
+ //
47
+ // Loading and running a local image needs no network at all, so a daemon whose nested containers
48
+ // are cut off passes the marker run exactly as a working one does. That is not hypothetical: the
49
+ // published executor image ran its rootless daemon with `--iptables=false`, which drops the
50
+ // MASQUERADE rule for the bridge, and every nested container on it had no egress whatsoever
51
+ // (issue #2173). The harness reported `dockerDaemon: "usable"` throughout, and each agent
52
+ // discovered otherwise about seven minutes into an `npm ci` inside a `docker build` (issue
53
+ // #2174). An agent TOLD it has no egress can plan around it; an agent told docker works cannot.
54
+ //
55
+ // It runs as its own container rather than as one more command in the marker run, because the
56
+ // marker run is deliberately `--network none`. The two need opposite networking, so they cannot
57
+ // be the same `docker run`, and keeping them apart has a second payoff: a failure of anything
58
+ // below can only ever produce an EGRESS verdict, never a verdict about the daemon.
59
+ // ---------------------------------------------------------------------------
60
+ /**
61
+ * What the egress container prints for each observation: the marker, then the exit STATUS of the
62
+ * command that made it.
63
+ *
64
+ * The status rather than a pass/fail marker, because the two failures need different answers.
65
+ * A refused connection is evidence about the network; a 127 is busybox saying it has no such
66
+ * applet, which is evidence about the platform's own probe image and may never be reported as a
67
+ * network that is not there.
68
+ */
69
+ export const EGRESS_TCP_MARKER = 'cat-factory-egress-tcp=';
70
+ export const EGRESS_DNS_MARKER = 'cat-factory-egress-dns=';
71
+ /** How long the in-container connect may take. Short: a blocked route is silent, not slow. */
72
+ const EGRESS_CONNECT_TIMEOUT_SECONDS = 3;
73
+ /**
74
+ * How long busybox is given to print its own `nc` usage, for the capability check below. Bounded
75
+ * like everything else in that container: an applet that somehow blocks may not take the budget
76
+ * of the measurement it is only a preamble to.
77
+ */
78
+ const EGRESS_USAGE_TIMEOUT_SECONDS = 2;
79
+ /**
80
+ * How long the in-container lookup may take. Its own ceiling because busybox's `nslookup` retries
81
+ * on its own schedule, and an unbounded one would spend the whole check's budget on the half that
82
+ * is the diagnostic rather than the verdict.
83
+ */
84
+ const EGRESS_LOOKUP_TIMEOUT_SECONDS = 6;
85
+ /**
86
+ * How much of a rejected setting is quoted back. Enough to recognise which value was refused,
87
+ * short of letting a pasted blob be most of an agent's system prompt.
88
+ */
89
+ const SETTING_CHARS = 60;
90
+ /** An IPv4 literal. Names are refused on purpose: a target that needs DNS cannot TEST DNS. */
91
+ const IPV4 = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$/;
92
+ /** A hostname, in the narrow shape a DNS lookup can be aimed at. */
93
+ const HOSTNAME = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i;
94
+ /**
95
+ * Read the configured target, or say why it cannot be used.
96
+ *
97
+ * Validated rather than trusted, and strictly, for two reasons that both matter. The host and the
98
+ * name are interpolated into a `sh -c` script INSIDE the probe container, so anything else there
99
+ * would be running whatever a deployment's environment happened to hold; and a target that is
100
+ * quietly wrong produces a confident `blocked` about a daemon that is fine, which is the exact
101
+ * class of lie this whole module exists to remove. A rejected setting is REPORTED as a check that
102
+ * could not be carried out, never silently swapped for the default: an operator who pointed this
103
+ * at an address their network permits is entitled to find out that it was ignored.
104
+ */
105
+ export function parseEgressTarget(target, dnsName) {
106
+ const [host = '', port = '', ...rest] = target.split(':');
107
+ if (rest.length > 0 || !IPV4.test(host)) {
108
+ return {
109
+ invalid: `the platform's egress check is configured with \`${scrubbedExcerpt(target, SETTING_CHARS)}\`, which is not an \`IPv4:port\` address`,
110
+ };
111
+ }
112
+ const parsed = Number(port);
113
+ if (!/^[0-9]{1,5}$/.test(port) || parsed < 1 || parsed > 65535) {
114
+ return {
115
+ invalid: `the platform's egress check is configured with \`${scrubbedExcerpt(target, SETTING_CHARS)}\`, whose port is not a number between 1 and 65535`,
116
+ };
117
+ }
118
+ if (!HOSTNAME.test(dnsName)) {
119
+ return {
120
+ invalid: `the platform's egress check is configured to resolve \`${scrubbedExcerpt(dnsName, SETTING_CHARS)}\`, which is not a hostname`,
121
+ };
122
+ }
123
+ return { target: { host, port: parsed, dnsName } };
124
+ }
125
+ /**
126
+ * The argv the egress container runs: connect, say what that returned, resolve, say the same.
127
+ *
128
+ * Both observations are made and BOTH are reported, because they fail for different reasons and
129
+ * have different fixes. A connect to a raw address needs only a route; a lookup needs the
130
+ * daemon's embedded resolver to be reachable and to forward. Reporting only the first would call
131
+ * a container with a working route and broken DNS "reachable", and nothing an agent fetches by
132
+ * name would work there.
133
+ *
134
+ * Every applet is called by its full path (`/busybox nc`) rather than by name. The image holds
135
+ * one file and no PATH, and busybox's standalone-shell dispatch is a build-time option nothing
136
+ * here may assume.
137
+ *
138
+ * The connect is the half that is easy to get silently wrong, and `nc -w SEC` alone gets it
139
+ * wrong. busybox documents that flag as the timeout for connects AND FINAL NET READS: once stdin
140
+ * hits EOF `nc` half-closes and then waits to be spoken to, so a connect that SUCCEEDED to a peer
141
+ * which expects the client to speak first (every TLS port, the default `1.1.1.1:443` included)
142
+ * hits the alarm and exits non-zero. Read off the exit status alone that is indistinguishable
143
+ * from a refusal, so a working network reports a route that is not there. `-z` means "connect,
144
+ * then stop", which is the question being asked, so it is used wherever the payload's busybox was
145
+ * built with it; where it was not, the connect runs with no `-w`, which is the build whose `nc`
146
+ * exits on its own when stdin closes.
147
+ *
148
+ * Both halves are wrapped in `${busybox} timeout` either way, since a blackholed route is silent
149
+ * rather than refused and the applet's own ceiling is the thing this comment exists because we
150
+ * cannot assume.
151
+ */
152
+ export function buildEgressCommand(target) {
153
+ const busybox = `/${PROBE_BINARY_PATH}`;
154
+ const bounded = (seconds, command) => `${busybox} timeout ${seconds} ${command}`;
155
+ const where = `${target.host} ${target.port}`;
156
+ const connectSeconds = EGRESS_CONNECT_TIMEOUT_SECONDS + 1;
157
+ const connect = [
158
+ 'nc_z=no',
159
+ `case "$(${bounded(EGRESS_USAGE_TIMEOUT_SECONDS, `${busybox} nc`)} 2>&1)" in *-z*) nc_z=yes ;; esac`,
160
+ 'if [ "$nc_z" = yes ]; then',
161
+ ` ${bounded(connectSeconds, `${busybox} nc -w ${EGRESS_CONNECT_TIMEOUT_SECONDS} -z ${where}`)} >/dev/null 2>&1`,
162
+ 'else',
163
+ ` ${bounded(connectSeconds, `${busybox} nc ${where}`)} </dev/null >/dev/null 2>&1`,
164
+ 'fi',
165
+ ].join('\n');
166
+ const resolve = bounded(EGRESS_LOOKUP_TIMEOUT_SECONDS, `${busybox} nslookup ${target.dnsName}`);
167
+ return [
168
+ busybox,
169
+ 'sh',
170
+ '-c',
171
+ [
172
+ `${connect}\necho "${EGRESS_TCP_MARKER}$?"`,
173
+ `${resolve} >/dev/null 2>&1; echo "${EGRESS_DNS_MARKER}$?"`,
174
+ ].join('\n'),
175
+ ];
176
+ }
177
+ /**
178
+ * Node's architecture names mapped onto the docker name for the same machine.
179
+ *
180
+ * This names THE PAYLOAD, never the daemon. `process.arch` is the architecture of the harness
181
+ * process, and the binary it hands over is built for that; the daemon it is measured against
182
+ * answers for itself (`docker version --format {{.Server.Arch}}`), and the caller compares the
183
+ * two rather than assuming they agree. An external `DOCKER_HOST` is a first-class path here, and
184
+ * an arm64 harness talking to an amd64 sidecar shares nothing with it but the socket.
185
+ *
186
+ * That comparison is also what makes two of these entries safe. `process.arch` reports `ppc64` on
187
+ * both endiannesses and `arm` with no variant, so those rows are a HYPOTHESIS about the payload,
188
+ * not a claim: a machine the guess is wrong about answers with a different name and the check
189
+ * reports that it could not be carried out. Nothing here may produce a verdict about the daemon.
190
+ * An architecture nothing maps does the same, which is why `386` was worth adding rather than
191
+ * leaving to a fallback: the mapping is unambiguous and its absence cost a real check.
192
+ */
193
+ const PAYLOAD_ARCHITECTURES = {
194
+ x64: 'amd64',
195
+ ia32: '386',
196
+ arm64: 'arm64',
197
+ arm: 'arm',
198
+ s390x: 's390x',
199
+ ppc64: 'ppc64le',
200
+ riscv64: 'riscv64',
201
+ };
202
+ /** The docker name for the architecture THIS process's payload is built for, when there is one. */
203
+ export function payloadArchitecture(arch = process.arch) {
204
+ return PAYLOAD_ARCHITECTURES[arch];
205
+ }
206
+ const TAR_BLOCK = 512;
207
+ /** A fixed timestamp everywhere a tar or an image config wants one, so the archive is byte-stable. */
208
+ const EPOCH = '1970-01-01T00:00:00Z';
209
+ /**
210
+ * One ustar header block.
211
+ *
212
+ * The checksum is summed with its own field read as eight SPACES and only then written back
213
+ * over it, which is the format's own rule and the one detail a hand-rolled writer gets wrong. A
214
+ * header whose checksum covers its own checksum bytes is rejected by every reader, and
215
+ * `docker load` reports that as an unreadable archive, which this module's caller would then
216
+ * have to decide was not the daemon's fault.
217
+ */
218
+ function tarHeader(name, size, mode) {
219
+ const header = Buffer.alloc(TAR_BLOCK);
220
+ header.write(name, 0, 100, 'utf8');
221
+ header.write(octalField(mode, 8), 100, 8, 'latin1');
222
+ header.write(octalField(0, 8), 108, 8, 'latin1'); // uid
223
+ header.write(octalField(0, 8), 116, 8, 'latin1'); // gid
224
+ header.write(octalField(size, 12), 124, 12, 'latin1');
225
+ header.write(octalField(0, 12), 136, 12, 'latin1'); // mtime
226
+ header.write(' ', 148, 8, 'latin1');
227
+ header.write('0', 156, 1, 'latin1'); // typeflag: a regular file
228
+ header.write('ustar\0', 257, 6, 'latin1');
229
+ header.write('00', 263, 2, 'latin1');
230
+ let sum = 0;
231
+ for (const byte of header)
232
+ sum += byte;
233
+ header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'latin1');
234
+ return header;
235
+ }
236
+ /** A numeric tar field: zero-padded octal in `width - 1` characters, then a NUL. */
237
+ function octalField(value, width) {
238
+ return `${value.toString(8).padStart(width - 1, '0')}\0`;
239
+ }
240
+ /** One tar member: its header, its content, and the padding up to the next 512-byte block. */
241
+ function tarMember(entry) {
242
+ const padding = (TAR_BLOCK - (entry.content.length % TAR_BLOCK)) % TAR_BLOCK;
243
+ return Buffer.concat([
244
+ tarHeader(entry.name, entry.content.length, entry.mode),
245
+ entry.content,
246
+ Buffer.alloc(padding),
247
+ ]);
248
+ }
249
+ /** A whole tar stream: the members, then the two zero blocks that terminate one. */
250
+ export function tarArchive(entries) {
251
+ return Buffer.concat([...entries.map(tarMember), Buffer.alloc(TAR_BLOCK * 2)]);
252
+ }
253
+ /**
254
+ * Assemble the docker-archive `docker load` reads, from one statically linked binary.
255
+ *
256
+ * Classic (v1) docker-archive rather than OCI layout: `docker load` accepts both on every engine
257
+ * this image can run against, and the v1 shape is three files with no blob directory to get
258
+ * wrong. The layer digest is the sha256 of the UNCOMPRESSED layer tar, which is what
259
+ * `rootfs.diff_ids` means; an engine that disagrees with it refuses the load, which the caller
260
+ * reads as could-not-determine rather than as a broken daemon.
261
+ *
262
+ * `architecture` is the DAEMON's own word for its architecture, in docker's vocabulary, so
263
+ * nothing here decides it (see {@link PAYLOAD_ARCHITECTURES}). The result is byte-stable for one
264
+ * `(payload, architecture)` pair, which is what lets the caller build it once per container.
265
+ */
266
+ export function buildProbeArchive(payload, architecture) {
267
+ const layer = tarArchive([{ name: PROBE_BINARY_PATH, content: payload, mode: 0o755 }]);
268
+ const diffId = `sha256:${createHash('sha256').update(layer).digest('hex')}`;
269
+ const config = Buffer.from(JSON.stringify({
270
+ architecture,
271
+ os: 'linux',
272
+ created: EPOCH,
273
+ config: {},
274
+ rootfs: { type: 'layers', diff_ids: [diffId] },
275
+ history: [{ created: EPOCH, created_by: 'cat-factory docker capability probe' }],
276
+ }));
277
+ const manifest = Buffer.from(JSON.stringify([{ Config: 'config.json', RepoTags: [PROBE_IMAGE_TAG], Layers: ['layer.tar'] }]));
278
+ return tarArchive([
279
+ { name: 'config.json', content: config, mode: 0o644 },
280
+ { name: 'layer.tar', content: layer, mode: 0o644 },
281
+ { name: 'manifest.json', content: manifest, mode: 0o644 },
282
+ ]);
283
+ }
@@ -1,3 +1,5 @@
1
+ import { type DockerWorkload } from './docker-capability.js';
2
+ import { type Logger } from './logger.js';
1
3
  /**
2
4
  * Where `entrypoint.sh` records its verdict. The two halves of one contract: change this and the
3
5
  * `DOCKER_STATUS_FILE` default in `entrypoint.sh` together. `HARNESS_DOCKER_STATUS_FILE`
@@ -29,7 +31,20 @@ export type DockerSource =
29
31
  export interface DockerStatus {
30
32
  available: boolean | undefined;
31
33
  source: DockerSource;
32
- /** Why, in the entrypoint's own closed vocabulary (`serving`/`failed`/`missing`/…). */
34
+ /**
35
+ * Why, in the entrypoint's own closed vocabulary (`serving`, `serving-without-nat`,
36
+ * `still-starting`, `failed`, `missing`, `unreachable`, `probing`).
37
+ *
38
+ * Reported and never branched on, which is what lets the entrypoint add a word without anything
39
+ * here having to know it. Two are worth knowing about. `serving-without-nat`: the daemon that
40
+ * manages its own firewall rules exited without serving, so the one that came up runs with
41
+ * `--iptables=false` and its NESTED containers have no egress. That is a CAUSE, and the only
42
+ * place one exists; what MEASURES the consequence is the egress half of the workload check
43
+ * (docker-capability.ts), from inside a container, with no way to learn why. `still-starting`
44
+ * is a daemon that had not answered when the boot budget ran out and is STILL RUNNING, which is
45
+ * the one absence that routinely stops being true: it is exactly what the live re-probe below
46
+ * exists to catch.
47
+ */
33
48
  reason: string;
34
49
  /** A human detail for the failing cases: the dockerd log tail, or what was unreachable. */
35
50
  detail?: string;
@@ -58,32 +73,97 @@ export declare function readDockerStatus(path?: string): Promise<DockerStatus>;
58
73
  * adding a source without a sentence stops building.
59
74
  */
60
75
  export declare function describeDockerAbsence(status: DockerStatus): string;
61
- /** Whether a daemon is answering RIGHT NOW. Injected so the unit suite can state either answer. */
62
- export type DockerProbe = () => Promise<boolean>;
63
76
  /**
64
- * The default {@link DockerProbe}: `docker version` talks to the SERVER, unlike the client-only
65
- * `docker --version`, which answers happily with no daemon at all.
77
+ * What a daemon can do RIGHT NOW. Injected so the unit suite can state every answer.
78
+ *
79
+ * It answers with a WORKLOAD rather than with a boolean, and that is the correction this type
80
+ * carries. It used to be `docker version`, which proves the daemon is serving; a stand-up needs
81
+ * a daemon that can materialise an image, and a sandboxed rootless daemon routinely serves while
82
+ * being unable to (issue #2120). Running compose against that one produced a mount error the
83
+ * agent had to interpret, from the one mechanism whose entire job is to say why infra did not
84
+ * come up.
85
+ *
86
+ * The weaker fact did not go away, though: it rides `daemonAnswered` on the `unknown` arm, since
87
+ * the workload check establishes it on its way past and {@link resolveDockerVerdict} still needs
88
+ * it. Takes the job's signal, because this is a live check on the critical path of a run that can
89
+ * be cancelled under it.
90
+ */
91
+ export type DockerProbe = (signal?: AbortSignal) => Promise<DockerWorkload>;
92
+ /**
93
+ * The default {@link DockerProbe}: the process-wide workload probe, which loads a one-layer
94
+ * image and runs a container from it, memoised per container.
95
+ *
96
+ * Named for what it answers. It was `probeDockerServing`, which is the fact this module exists to
97
+ * say is not enough.
66
98
  */
67
- export declare const probeDockerServing: DockerProbe;
99
+ export declare const probeLiveDockerCapability: DockerProbe;
100
+ /**
101
+ * The sentence for a daemon that is serving and cannot run anything. It names what was tried,
102
+ * because "docker is unavailable" against a daemon the agent can see answering reads as a bug in
103
+ * the platform rather than as the sandbox limit it is.
104
+ */
105
+ export declare function describeDockerUnusable(workload: {
106
+ detail: string;
107
+ }): string;
68
108
  /** What a stand-up is entitled to conclude about the daemon at the moment it is about to run. */
69
109
  export interface DockerVerdict {
70
- /** Three-valued exactly as {@link DockerStatus.available}, and read the same way. */
110
+ /**
111
+ * Whether a stand-up may PROCEED, three-valued exactly as {@link DockerStatus.available} and
112
+ * read the same way. It is the decision, not a description of the daemon: a daemon that is
113
+ * answering and cannot run a container is `false` here and `daemon: true` below, and a record
114
+ * that reported the first as the second would send an operator to restart a daemon that is
115
+ * already up.
116
+ */
71
117
  available: boolean | undefined;
72
- /** Set only for a CONFIRMED absence: the sentence to refuse with. Absent means proceed. */
118
+ /**
119
+ * Set only for a CONFIRMED negative: the sentence to refuse with. Absent means proceed.
120
+ *
121
+ * Two causes reach it and they read differently on purpose. Nothing is answering here, and
122
+ * something is answering here but cannot run a container: an operator sent to restart a daemon
123
+ * that is already up would find nothing wrong with it.
124
+ */
73
125
  refusal?: string;
126
+ /**
127
+ * Whether a daemon ANSWERED the live check. Absent when nothing was checked (an undecided
128
+ * record probes nothing) or when nothing answered, so it is never read as a decided `false`.
129
+ */
130
+ daemon?: boolean;
131
+ /** What a real container did on it, when one was tried. Absent when nothing was measured. */
132
+ workload?: DockerWorkload;
74
133
  }
75
134
  /**
76
- * Resolve what to do now, from what boot recorded plus what a daemon says today.
135
+ * Resolve what to do now, from what boot recorded plus what the daemon can do today.
136
+ *
137
+ * `entrypoint.sh` probes once, at boot, within a bounded wait, and it probes for a SOCKET. Two
138
+ * things follow, and the branches below are one each.
139
+ *
140
+ * A recorded absence is a HYPOTHESIS. A container outlives its boot: a warm pool serves many jobs
141
+ * from one, and a sidecar daemon that took longer than the wait allows is serving perfectly well
142
+ * by the second job. Refusing off the record alone latches that container into refusing local
143
+ * infra that works, for its whole life, with a stale sentence explaining why. The record is still
144
+ * what supplies the cause and the daemon's own log tail, which no probe can reconstruct.
145
+ *
146
+ * A recorded PRESENCE is a hypothesis too, and that half was missing. `serving` is not `usable`:
147
+ * a rootless daemon in a sandbox answers while unable to mount any image layer, so compose ran
148
+ * and died on a mount error the agent then had to interpret. So the probe is consulted in both
149
+ * directions, and it runs a real container rather than asking the daemon about itself.
77
150
  *
78
- * `entrypoint.sh` probes once, at boot, within a bounded wait. A container outlives that: a warm
79
- * pool serves many jobs from one, and a sidecar daemon that took longer than the wait allows is
80
- * serving perfectly well by the second job. Refusing off the recorded verdict alone latches that
81
- * container into refusing local infra that in fact works, for its whole life, with a stale
82
- * sentence explaining why. So a recorded absence is a HYPOTHESIS here, and the live probe settles
83
- * it; the recorded verdict is still what supplies the cause and the daemon's own log tail, which
84
- * no probe can reconstruct.
151
+ * A check that could not be CARRIED OUT settles nothing, and the cheap fact is what decides
152
+ * there. Falling straight back to the boot record would re-latch the very refusal the paragraph
153
+ * above rules out: the four ways the workload check can come back undeterminable (no payload in
154
+ * this image variant, an architecture it is not built for, a `docker load` the engine refuses, a
155
+ * timeout) have nothing to do with whether a daemon is up, so a warm container whose sidecar
156
+ * arrived late would be denied local infra for the rest of its life over a stale sentence. So a
157
+ * daemon that ANSWERED contradicts a recorded absence exactly as the old `docker version` probe
158
+ * did, and only a check that never reached a daemon at all leaves the record to decide.
85
159
  *
86
- * Only a recorded `false` is re-confirmed. "Not decided" keeps attempting exactly as before: the
87
- * point of the third value is that nothing turns it into a refusal, and a probe here would.
160
+ * "Not decided" still keeps attempting, untouched. The point of the third value is that NOTHING
161
+ * turns it into a refusal: the entrypoint's bounded wait may still be running, and a workload
162
+ * probe against a daemon that has not finished starting fails for a reason that says nothing
163
+ * about what it will do a second later.
88
164
  */
89
- export declare function resolveDockerVerdict(status: DockerStatus, probe?: DockerProbe): Promise<DockerVerdict>;
165
+ export declare function resolveDockerVerdict(status: DockerStatus, opts?: {
166
+ probe?: DockerProbe;
167
+ signal?: AbortSignal;
168
+ logger?: Logger;
169
+ }): Promise<DockerVerdict>;