@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
package/README.md
CHANGED
|
@@ -142,6 +142,19 @@ Three rules bind anything added to it:
|
|
|
142
142
|
installed in this image unconditionally, `entrypoint.sh` starts the rootless daemon best-effort
|
|
143
143
|
and execs the server without waiting for it, so at job start this probe is the only thing that
|
|
144
144
|
knows how that went.
|
|
145
|
+
- **A daemon that ANSWERS is not a daemon that WORKS**, which is the same mistake one level in and
|
|
146
|
+
the one this block used to make. A rootless daemon nested inside a sandbox serves throughout
|
|
147
|
+
while its snapshotter cannot mount an image layer, so `docker info` succeeds and `docker build`,
|
|
148
|
+
`docker run` and `docker pull` of anything multi-layer all fail on one EINVAL. Issue #2120 is
|
|
149
|
+
three agents in a single run each paying to disprove the claim, against a block that also tells
|
|
150
|
+
them not to re-check it. Only a container that RAN settles it, so the reachable case is split by
|
|
151
|
+
a real workload (`src/docker-capability.ts`) into `usable`, `unusable` and a daemon that answered
|
|
152
|
+
while the check could not be carried out. **Only `usable` may say the commands work**, and the
|
|
153
|
+
asymmetry runs the other way too: a failure of the platform's own machinery reports that it
|
|
154
|
+
could not tell, never that the daemon is broken. That covers every step before the run (no probe
|
|
155
|
+
payload on this machine, a daemon whose architecture the payload is not built for, `docker load`
|
|
156
|
+
refusing the archive) AND the halves of a failed run that are ours rather than the daemon's,
|
|
157
|
+
which is what docker's exit 126/127, a tag that did not resolve and an unexecutable payload are.
|
|
145
158
|
- **A daemon that is STARTING is not a daemon that is absent.** Because the entrypoint does not
|
|
146
159
|
wait, the backend dispatches seconds before there is a socket, and `docker info` is then refused
|
|
147
160
|
at once rather than slowly. So a refusal is read against `DOCKER_HOST`, which the entrypoint sets
|
|
@@ -374,19 +387,47 @@ Docker socket would hand the container root on the host.
|
|
|
374
387
|
|
|
375
388
|
- `GET /health` reports it, so an operator (and a boot-time probe) can see what the container
|
|
376
389
|
concluded about itself.
|
|
377
|
-
- The compose stand-up REFUSES on a decided
|
|
390
|
+
- The compose stand-up REFUSES on a decided negative and says why, instead of running compose
|
|
378
391
|
against nothing and handing the agent a connection error to interpret. The refusal rides back on
|
|
379
|
-
the Tester step as
|
|
392
|
+
the Tester step as the cause plus the two facts that decide where a human should look:
|
|
393
|
+
`infraSetup.dockerAvailable` (was anything answering) and `infraSetup.dockerWorkload` (what a
|
|
394
|
+
container did on it). Two fields rather than one, because the daemon has two ways to stop a
|
|
395
|
+
stand-up: nothing to talk to, and a daemon that answers and cannot run a container. Flattening
|
|
396
|
+
the second onto `dockerAvailable: false` renders as "no Docker daemon in the executor" and sends
|
|
397
|
+
an operator to restart a daemon that is already up.
|
|
398
|
+
|
|
399
|
+
**What the entrypoint probes for is a SOCKET, and serving is not usable.** That is the whole of
|
|
400
|
+
what a boot record can know, and it is weaker than what either consumer wants: a rootless daemon in
|
|
401
|
+
a sandbox answers `docker version` while being unable to mount an image, so compose ran and died on
|
|
402
|
+
a mount error inside the one mechanism whose job is to explain why the dependencies did not come up
|
|
403
|
+
(issue #2120). The live half of the verdict is therefore a real workload (load a one-layer image
|
|
404
|
+
and run a container from it, `src/docker-capability.ts`), and `resolveDockerVerdict` consults it in
|
|
405
|
+
BOTH directions: a recorded absence a working daemon contradicts, and a recorded presence that
|
|
406
|
+
cannot run anything. `GET /health` reports the last measurement beside the boot record under
|
|
407
|
+
`docker.workload` and never takes one itself, since it is polled; `unmeasured` is one of its answers.
|
|
408
|
+
|
|
409
|
+
**The weaker fact did not stop mattering, though, and it is what a stale record is read against.**
|
|
410
|
+
A workload check can come back undeterminable for reasons that have nothing to do with whether a
|
|
411
|
+
daemon is up (no probe payload in a deployment's own image variant, an architecture it is not built
|
|
412
|
+
for, a `docker load` the engine refuses, a timeout), so falling straight back to the boot record
|
|
413
|
+
there would re-latch the very refusal the paragraph below rules out. The check therefore reports
|
|
414
|
+
`daemonAnswered` alongside its `unknown`, established on its way past at no extra cost, and a
|
|
415
|
+
daemon that merely ANSWERED overrules a recorded absence exactly as the old `docker version` probe
|
|
416
|
+
did. Only a check that never reached a daemon at all leaves the record to decide.
|
|
417
|
+
|
|
418
|
+
The workload check is memoised per container for a POSITIVE answer only. A daemon that has run a
|
|
419
|
+
container proved something that does not stop being true; a negative is re-measured for the same
|
|
420
|
+
reason a recorded absence is, and it fails fast anyway.
|
|
380
421
|
|
|
381
422
|
The verdict is three-valued, and that is the point. `false` is a decided absence. `undefined` is
|
|
382
423
|
"nothing decided" — the probe is still in flight, or nothing recorded anything at all, which is the
|
|
383
424
|
normal state under the native host transport (`LOCAL_NATIVE_AGENTS`) where the harness runs on a
|
|
384
|
-
developer's machine with no entrypoint. Undecided attempts the stand-up
|
|
385
|
-
refuses
|
|
425
|
+
developer's machine with no entrypoint. Undecided attempts the stand-up, and nothing probes it into
|
|
426
|
+
a refusal; only a decided negative refuses.
|
|
386
427
|
|
|
387
428
|
What is recorded describes BOOT, and a container outlives its boot: a warm pool serves many jobs
|
|
388
429
|
from one, and a sidecar daemon that took longer to come up than the entrypoint's bounded wait
|
|
389
|
-
allows is serving perfectly well by the second job. So a recorded
|
|
430
|
+
allows is serving perfectly well by the second job. So a recorded verdict is a hypothesis, not the
|
|
390
431
|
refusal: `resolveDockerVerdict` re-checks it against a live daemon at the moment a stand-up is
|
|
391
432
|
about to run, and the record supplies what only the record holds, the cause and the daemon's own
|
|
392
433
|
log tail. `GET /health` deliberately keeps reporting the boot record rather than probing per poll,
|
|
@@ -438,10 +479,13 @@ verdict is stated.
|
|
|
438
479
|
| `src/bootstrap-mode.ts` | The repo-bootstrap MODE: clone-a-reference-or-scaffold → run the agent → refuse to push an empty tree → reinit + force-push to the pre-created target repo. |
|
|
439
480
|
| `src/artifact-upload.ts` | The OUTBOUND half of the artifact seam: parses the body's `artifactUpload` and projects it onto the agent's env as `ARTIFACT_UPLOAD_URL` / `ARTIFACT_UPLOAD_TOKEN`, registering the token for redaction first. Passes through what the body carries and decides nothing: which kinds get the seam is the backend's call. |
|
|
440
481
|
| `src/codex-images.ts` | Codex's own `image_gen` output, staged where the agent can reach it: creates `$CODEX_HOME/generated_images` as a symlink into `.cat-context/binary-output/generated/` before the CLI starts, sweeps anything a failed redirect left behind, and unlinks (never follows) the redirect at teardown — a failed unlink is REPORTED, because that unlink is what stops the recursive delete reaching the checkout. Exists because codex exposes no path for what it generated AND `$CODEX_HOME` holds the run's decrypted credential, so neither asking the agent nor sending it there is available. |
|
|
441
|
-
| `src/environment-inventory.ts` | What the MACHINE holds, probed once per job and appended to the agent's system prompt as an ENVIRONMENT INVENTORY block. The only layer that can state it: the backend composes its prompt before a transport is chosen, and the same body serves this image, a deployment's own variant and the developer's laptop under `LOCAL_NATIVE_AGENTS`. Three-valued on purpose, so a probe that failed renders as unknown rather than as an absence
|
|
482
|
+
| `src/environment-inventory.ts` | What the MACHINE holds, probed once per job and appended to the agent's system prompt as an ENVIRONMENT INVENTORY block. The only layer that can state it: the backend composes its prompt before a transport is chosen, and the same body serves this image, a deployment's own variant and the developer's laptop under `LOCAL_NATIVE_AGENTS`. Three-valued on purpose, so a probe that failed renders as unknown rather than as an absence; Docker gets FIVE, because a daemon that answers `docker info` is not a daemon that can run a container. See [The environment is probed once, not by the agent](#the-environment-is-probed-once-not-by-the-agent). |
|
|
442
483
|
| `src/agent-shared.ts` | The few helpers every agent MODE shares (effort-report folding, the capability fields forwarded to `runAgentInWorkspace`). |
|
|
443
484
|
| `src/logger.ts` | Structured logging. |
|
|
444
|
-
| `src/docker-status.ts` | This container's own verdict about its Docker daemon, as recorded by `entrypoint.sh`. Three-valued on purpose: a daemon that FAILED and a daemon nobody asked about are different facts, and only a DECIDED
|
|
485
|
+
| `src/docker-status.ts` | This container's own verdict about its Docker daemon, as recorded by `entrypoint.sh`. Three-valued on purpose: a daemon that FAILED and a daemon nobody asked about are different facts, and only a DECIDED negative refuses a stand-up. See [Local infra: the container's Docker daemon](#local-infra-the-containers-docker-daemon). |
|
|
486
|
+
| `src/docker-capability.ts` | Whether the daemon can RUN A CONTAINER, which is the fact every caller wanted and `docker info` does not answer. Loads a one-layer image built in-process and runs it; `usable` / `unusable` / `unknown`, and only the container RUN may produce the middle one, AND only where the daemon is what refused it (docker's 126/127, a tag that did not resolve and an unexecutable payload are the platform's own machinery, so they say "could not tell"). Total: it answers even if it throws. One budget for the whole pass, cancelled when the last caller abandons it, memoised per container for a positive and re-measured for a negative. |
|
|
487
|
+
| `src/docker-probe-image.ts` | The one-layer docker-archive that check loads, assembled here from a statically linked binary already in the image, so the whole thing is local: no registry, no network, no second image. Pure, byte-stable, and it names the architecture the DAEMON reported rather than this process's. |
|
|
488
|
+
| `src/docker-command.ts` | How the harness runs one `docker …` command on its own behalf: an argv (no shell), a stdin body, stdout kept apart from stderr, one required timeout, the job's signal, and `killChildProcess` for the kill. Not a second `captured-command.ts`, which stays the one way a DECLARED shell command runs; the header says which of its choices this needs to differ on and why. |
|
|
445
489
|
| `src/agent-env.ts` | The env for anything the harness spawns into the agent's CHECKOUT: its own environment minus the variables that are facts about the HARNESS. Today that is `NODE_ENV` (the harness runs in production mode, and an inherited `NODE_ENV=production` makes npm omit devDependencies in a checkout that never asked for it) and `PORT` (the port this harness is listening on, so a service that reads it would bind the one address in the container's network namespace that is already taken). |
|
|
446
490
|
|
|
447
491
|
## Runner lifecycle knobs
|
|
@@ -468,6 +512,7 @@ runner):
|
|
|
468
512
|
| `HARNESS_TRANSCRIPT_ROOT` | `<tmpdir>/cf-agent-transcripts` | Where retained session transcripts are moved to (one dir per run). Meaningful only on a reused (warm-pool) container; a per-run container is torn down with the job. The TTL sweep deletes only dirs it created (each carries a `.cf-retained` marker), so pointing this at a shared directory never touches unrelated content, though a dedicated dir is still recommended. An override on a different filesystem than the config home falls back to copy-then-remove. |
|
|
469
513
|
| `HARNESS_DOCKER_READY_TIMEOUT_SECONDS` | `60` | How long `entrypoint.sh` waits for the container's Docker daemon before recording it unavailable. Only a HUNG daemon pays this in full: the wait ends early both when the socket answers and when the daemon process is gone. It runs in the BACKGROUND, so it never delays the container's boot. |
|
|
470
514
|
| `HARNESS_DOCKER_STATUS_FILE` | `/tmp/harness-docker-status.json` | Where that verdict is recorded. `entrypoint.sh` writes it and the harness reads it, so an override must be set for BOTH (they share one process env). |
|
|
515
|
+
| `HARNESS_DOCKER_PROBE_BINARY` | `/bin/busybox` | The statically linked binary the platform builds its one-layer probe image from, for the check that answers whether this daemon can RUN a container. Only an image variant that ships it elsewhere needs this. Absent is a supported answer, not a failure: the check then reports that it could not be carried out, which is what the native host transport gets on a developer laptop. |
|
|
471
516
|
|
|
472
517
|
## Build / test
|
|
473
518
|
|
package/dist/agent.js
CHANGED
|
@@ -137,8 +137,11 @@ export async function handleAgent(job, opts = {}) {
|
|
|
137
137
|
// This sits on the critical path AHEAD of the clone, which is the cost of having one
|
|
138
138
|
// composition point instead of one per mode (each mode owns its own clone, so there is no
|
|
139
139
|
// single post-clone place to put this). The pass is sized for that: everything in it runs
|
|
140
|
-
// concurrently, every probe
|
|
141
|
-
// deliberate
|
|
140
|
+
// concurrently, and every probe either answers in milliseconds or is bounded. Two of them are
|
|
141
|
+
// deliberate waits rather than instant answers: one short retry for a daemon that is still
|
|
142
|
+
// starting, and, only once a daemon has answered, the container the platform runs to find out
|
|
143
|
+
// whether it can run one at all (`docker-capability.ts`, budgeted and memoised per container
|
|
144
|
+
// for a positive). Both take the job's signal, so an abandoned run stops paying at once.
|
|
142
145
|
const staged = {
|
|
143
146
|
...job,
|
|
144
147
|
systemPrompt: await appendEnvironmentInventory(job.systemPrompt, {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type DockerCommandRunner } from './docker-command.js';
|
|
2
|
+
import { type Logger } from './logger.js';
|
|
3
|
+
/** What one measurement concluded. See the three answers above; nothing collapses them. */
|
|
4
|
+
export type DockerWorkload = {
|
|
5
|
+
status: 'usable';
|
|
6
|
+
} | {
|
|
7
|
+
status: 'unusable';
|
|
8
|
+
detail: string;
|
|
9
|
+
} | {
|
|
10
|
+
status: 'unknown';
|
|
11
|
+
reason: string;
|
|
12
|
+
/**
|
|
13
|
+
* Whether a daemon ANSWERED before the check ran out of things it could do. A weaker fact
|
|
14
|
+
* than the check exists to establish, and the one a stale boot record must be read against.
|
|
15
|
+
*/
|
|
16
|
+
daemonAnswered: boolean;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* A one-slot memo for the assembled archive.
|
|
20
|
+
*
|
|
21
|
+
* The archive is byte-stable for one `(payload path, architecture)` pair by construction
|
|
22
|
+
* (docker-probe-image.ts pins every timestamp for exactly this reason), and neither half changes
|
|
23
|
+
* within a process. Since a NEGATIVE verdict is re-measured on purpose and two sites ask per job,
|
|
24
|
+
* without this the same two megabytes are re-read and re-sha256'd four times per job for a value
|
|
25
|
+
* that cannot differ. One slot rather than a map: there is only ever one key.
|
|
26
|
+
*/
|
|
27
|
+
export interface ProbeArchiveMemo {
|
|
28
|
+
read(key: string): Buffer | undefined;
|
|
29
|
+
write(key: string, archive: Buffer): void;
|
|
30
|
+
}
|
|
31
|
+
/** Build a {@link ProbeArchiveMemo}. Supplied only by {@link realDeps}, so a test memoises nothing. */
|
|
32
|
+
export declare function oneSlotArchiveMemo(): ProbeArchiveMemo;
|
|
33
|
+
/** What a measurement needs from the machine, so a test can supply all of it. */
|
|
34
|
+
export interface DockerWorkloadDeps {
|
|
35
|
+
/** Read the probe payload. Rejecting (ENOENT) is the supported "this machine has none". */
|
|
36
|
+
readPayload: (path: string) => Promise<Buffer>;
|
|
37
|
+
payloadPath: string;
|
|
38
|
+
runDocker: DockerCommandRunner;
|
|
39
|
+
/** This process's architecture, as `process.arch` spells it: the PAYLOAD's, never the daemon's. */
|
|
40
|
+
arch: string;
|
|
41
|
+
logger?: Logger;
|
|
42
|
+
archives?: ProbeArchiveMemo;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Carry out one measurement. Pure of caching, so the suite states every branch directly.
|
|
46
|
+
*
|
|
47
|
+
* TOTAL: it never rejects, whatever happens inside it. The thing it replaced was total by
|
|
48
|
+
* construction (a `try/catch` around one `execFile`), and it is consulted from a stand-up path
|
|
49
|
+
* documented as best-effort, so a throw here would fail a job over a probe whose whole purpose is
|
|
50
|
+
* to make a failure legible. A throw is also, by definition, the platform's own machinery
|
|
51
|
+
* breaking, which is the `unknown` disposition and never the `unusable` one.
|
|
52
|
+
*
|
|
53
|
+
* That asymmetry is the design. Only the RUN produces `unusable`; everything before it produces
|
|
54
|
+
* `unknown`, because everything before it is the platform's own machinery and a bug in it must be
|
|
55
|
+
* able to say "I could not tell" and never "your daemon is broken". The load step in particular is
|
|
56
|
+
* the one this repo wrote itself.
|
|
57
|
+
*/
|
|
58
|
+
export declare function measureDockerWorkload(deps?: DockerWorkloadDeps, signal?: AbortSignal): Promise<DockerWorkload>;
|
|
59
|
+
/**
|
|
60
|
+
* A measurement, plus what the last one concluded without taking another.
|
|
61
|
+
*
|
|
62
|
+
* Callable because every caller wants the verdict; `last()` exists for `GET /health`, which is
|
|
63
|
+
* polled and must not spawn a container per poll to answer a question it does not act on.
|
|
64
|
+
*/
|
|
65
|
+
export interface DockerWorkloadProbe {
|
|
66
|
+
(signal?: AbortSignal): Promise<DockerWorkload>;
|
|
67
|
+
last(): DockerWorkload | undefined;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Build a probe that measures at most once per container for a POSITIVE answer.
|
|
71
|
+
*
|
|
72
|
+
* A daemon that has run a container has proved something that does not stop being true, so that
|
|
73
|
+
* verdict is kept and every later job reads it for free. A negative is NOT kept, for the reason
|
|
74
|
+
* `resolveDockerVerdict` gives about the boot record: a container outlives its boot, a warm pool
|
|
75
|
+
* serves many jobs from one, and a daemon that was not ready for the first job must not latch
|
|
76
|
+
* the whole container into saying so. Re-measuring a negative is cheap; a daemon that cannot
|
|
77
|
+
* mount fails at once.
|
|
78
|
+
*
|
|
79
|
+
* Concurrent callers share one in-flight measurement rather than each starting a container, and
|
|
80
|
+
* the measurement is cancelled when the LAST of them has abandoned it. Neither half is optional:
|
|
81
|
+
* one job's abort may not kill a measurement a sibling job is still waiting on (the local native
|
|
82
|
+
* transport serves every concurrent job from one process), and a measurement nobody is waiting
|
|
83
|
+
* for is a container start no job will read, which is what an abandoned run should stop paying
|
|
84
|
+
* for the moment it is abandoned.
|
|
85
|
+
*/
|
|
86
|
+
export declare function createDockerWorkloadProbe(deps?: DockerWorkloadDeps): DockerWorkloadProbe;
|
|
87
|
+
/** The process-wide probe. One per container, which is what makes the positive memo worth having. */
|
|
88
|
+
export declare const probeDockerWorkload: DockerWorkloadProbe;
|
|
89
|
+
/**
|
|
90
|
+
* What `GET /health` reports about the workload check.
|
|
91
|
+
*
|
|
92
|
+
* `unmeasured` is its own word rather than an omitted key or a `null`: this endpoint is polled
|
|
93
|
+
* from boot, so "nothing has needed the daemon yet" is the normal early answer and it must not
|
|
94
|
+
* read as either a broken daemon or a build that cannot report one.
|
|
95
|
+
*/
|
|
96
|
+
export declare function reportedDockerWorkload(probe?: DockerWorkloadProbe): DockerWorkload | {
|
|
97
|
+
status: 'unmeasured';
|
|
98
|
+
reason: string;
|
|
99
|
+
};
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { spawnDockerCommand, } from './docker-command.js';
|
|
3
|
+
import { buildProbeArchive, payloadArchitecture, PROBE_COMMAND, PROBE_IMAGE_TAG, PROBE_SENTINEL, } from './docker-probe-image.js';
|
|
4
|
+
import { log } from './logger.js';
|
|
5
|
+
import { redactSecrets } from './redact.js';
|
|
6
|
+
/**
|
|
7
|
+
* The statically linked binary the probe image is built from, overridable for an image variant
|
|
8
|
+
* that ships it elsewhere. Absent is a supported answer, not a failure: under
|
|
9
|
+
* `LOCAL_NATIVE_AGENTS` the harness runs on a developer's machine that never saw this image.
|
|
10
|
+
*/
|
|
11
|
+
const PAYLOAD_PATH = process.env.HARNESS_DOCKER_PROBE_BINARY?.trim() || '/bin/busybox';
|
|
12
|
+
/**
|
|
13
|
+
* The ceiling on ONE WHOLE measurement, shared out across the docker commands it makes: each
|
|
14
|
+
* gets what is left of it, down to {@link MIN_COMMAND_MS}.
|
|
15
|
+
*
|
|
16
|
+
* One budget rather than a per-command ceiling, because a per-command one multiplies: three
|
|
17
|
+
* commands at 30s each is a minute and a half of dead time on a wedged daemon, on the critical
|
|
18
|
+
* path ahead of the clone. Sized for a WEDGED daemon and not for a slow one: the payload is a
|
|
19
|
+
* couple of megabytes already on local disk, so a daemon that works answers in about the time it
|
|
20
|
+
* takes to start one container, and a daemon that cannot mount fails immediately.
|
|
21
|
+
*
|
|
22
|
+
* What it does NOT do is bound the cost per JOB, and the comment that once claimed so was wrong.
|
|
23
|
+
* A POSITIVE verdict is memoised for the container's life; a negative is deliberately
|
|
24
|
+
* re-measured (see {@link createDockerWorkloadProbe}), and two independent sites ask per job (the
|
|
25
|
+
* environment inventory and the compose stand-up), so a serving-but-wedged daemon costs this
|
|
26
|
+
* twice per job. That is the price of not latching a warm container into a stale refusal, which
|
|
27
|
+
* is why the budget is the size it is and why an abandoned job stops paying it at once.
|
|
28
|
+
*/
|
|
29
|
+
const WORKLOAD_BUDGET_MS = 20_000;
|
|
30
|
+
/** The floor on one command's share of the budget, so an exhausted budget still gets an answer. */
|
|
31
|
+
const MIN_COMMAND_MS = 1_000;
|
|
32
|
+
/**
|
|
33
|
+
* The ceiling on removing the probe image again.
|
|
34
|
+
*
|
|
35
|
+
* Its own, and deliberately NOT given the caller's abort signal: the image is the platform's, and
|
|
36
|
+
* a cancelled job is the one case where nobody is left to clean up after it. Bounded separately
|
|
37
|
+
* so a wedged daemon cannot turn the cleanup into a second full budget.
|
|
38
|
+
*/
|
|
39
|
+
const CLEANUP_TIMEOUT_MS = 5_000;
|
|
40
|
+
/** How much of a failing command's output is kept. It is quoted into an agent's system prompt. */
|
|
41
|
+
const DETAIL_CHARS = 300;
|
|
42
|
+
/** Build a {@link ProbeArchiveMemo}. Supplied only by {@link realDeps}, so a test memoises nothing. */
|
|
43
|
+
export function oneSlotArchiveMemo() {
|
|
44
|
+
let held;
|
|
45
|
+
return {
|
|
46
|
+
read: (key) => (held?.key === key ? held.archive : undefined),
|
|
47
|
+
write: (key, archive) => {
|
|
48
|
+
held = { key, archive };
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const realDeps = {
|
|
53
|
+
readPayload: (path) => readFile(path),
|
|
54
|
+
payloadPath: PAYLOAD_PATH,
|
|
55
|
+
runDocker: spawnDockerCommand,
|
|
56
|
+
arch: process.arch,
|
|
57
|
+
archives: oneSlotArchiveMemo(),
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Carry out one measurement. Pure of caching, so the suite states every branch directly.
|
|
61
|
+
*
|
|
62
|
+
* TOTAL: it never rejects, whatever happens inside it. The thing it replaced was total by
|
|
63
|
+
* construction (a `try/catch` around one `execFile`), and it is consulted from a stand-up path
|
|
64
|
+
* documented as best-effort, so a throw here would fail a job over a probe whose whole purpose is
|
|
65
|
+
* to make a failure legible. A throw is also, by definition, the platform's own machinery
|
|
66
|
+
* breaking, which is the `unknown` disposition and never the `unusable` one.
|
|
67
|
+
*
|
|
68
|
+
* That asymmetry is the design. Only the RUN produces `unusable`; everything before it produces
|
|
69
|
+
* `unknown`, because everything before it is the platform's own machinery and a bug in it must be
|
|
70
|
+
* able to say "I could not tell" and never "your daemon is broken". The load step in particular is
|
|
71
|
+
* the one this repo wrote itself.
|
|
72
|
+
*/
|
|
73
|
+
export async function measureDockerWorkload(deps = realDeps, signal) {
|
|
74
|
+
const seen = { daemonAnswered: false };
|
|
75
|
+
try {
|
|
76
|
+
return await measure(deps, seen, signal);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
const cause = describeThrown(err);
|
|
80
|
+
(deps.logger ?? log).warn('docker capability: the container check itself fell over', {
|
|
81
|
+
error: cause,
|
|
82
|
+
});
|
|
83
|
+
return undeterminable(`the platform's own container check could not be completed (${cause})`, seen.daemonAnswered);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function measure(deps, seen, signal) {
|
|
87
|
+
const deadline = Date.now() + WORKLOAD_BUDGET_MS;
|
|
88
|
+
const share = () => Math.max(MIN_COMMAND_MS, deadline - Date.now());
|
|
89
|
+
const command = (args, stdin) => deps.runDocker(args, {
|
|
90
|
+
...(stdin ? { stdin } : {}),
|
|
91
|
+
...(signal ? { signal } : {}),
|
|
92
|
+
timeoutMs: share(),
|
|
93
|
+
...(deps.logger ? { logger: deps.logger } : {}),
|
|
94
|
+
});
|
|
95
|
+
// Ask the DAEMON which architecture it runs, rather than assuming it shares this process's.
|
|
96
|
+
// An external `DOCKER_HOST` is a supported path, and an arm64 harness against an amd64 sidecar
|
|
97
|
+
// (or a remote x86_64 daemon reached from an arm64 laptop) shares nothing with it but a socket:
|
|
98
|
+
// declaring the wrong one in the image config gets the run refused, which would report a
|
|
99
|
+
// perfectly good daemon as one that cannot run containers. It is also the cheapest proof that a
|
|
100
|
+
// daemon is answering at all, which is the fact `resolveDockerVerdict` reads back.
|
|
101
|
+
const asked = await command(['version', '--format', '{{.Server.Arch}}']);
|
|
102
|
+
if (asked.outcome !== 'ran' || asked.code !== 0) {
|
|
103
|
+
return undeterminable(`no Docker daemon answered the platform's container check (${describeOutcome(asked)})`, false);
|
|
104
|
+
}
|
|
105
|
+
seen.daemonAnswered = true;
|
|
106
|
+
const daemonArch = asked.stdout.trim();
|
|
107
|
+
if (!/^[a-z0-9_]+$/.test(daemonArch)) {
|
|
108
|
+
return undeterminable(`the Docker daemon did not name an architecture the platform can build an image for (${redactSecrets(daemonArch).slice(0, 40) || 'it answered nothing'})`, true);
|
|
109
|
+
}
|
|
110
|
+
const payloadArch = payloadArchitecture(deps.arch);
|
|
111
|
+
if (!payloadArch) {
|
|
112
|
+
return undeterminable(`the platform has no container check for the ${deps.arch} architecture`, true);
|
|
113
|
+
}
|
|
114
|
+
if (payloadArch !== daemonArch) {
|
|
115
|
+
return undeterminable(`the platform's container check is built for ${payloadArch} and this daemon runs ${daemonArch}`, true);
|
|
116
|
+
}
|
|
117
|
+
const assembled = await assembleArchive(deps, daemonArch);
|
|
118
|
+
if ('reason' in assembled)
|
|
119
|
+
return undeterminable(assembled.reason, true);
|
|
120
|
+
const load = await command(['load'], assembled.archive);
|
|
121
|
+
if (load.outcome !== 'ran' || load.code !== 0) {
|
|
122
|
+
return undeterminable(`the platform could not load its own probe image (${describeOutcome(load)})`, true);
|
|
123
|
+
}
|
|
124
|
+
const run = await command([
|
|
125
|
+
'run',
|
|
126
|
+
'--rm',
|
|
127
|
+
'--pull',
|
|
128
|
+
'never',
|
|
129
|
+
'--network',
|
|
130
|
+
'none',
|
|
131
|
+
PROBE_IMAGE_TAG,
|
|
132
|
+
...PROBE_COMMAND,
|
|
133
|
+
]);
|
|
134
|
+
// Before the verdict, and whatever the verdict is: the probe image is the platform's, and an
|
|
135
|
+
// agent that runs `docker images` should not have to wonder whose it is.
|
|
136
|
+
await removeProbeImage(deps);
|
|
137
|
+
return classifyRun(run);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* What the container RUN proves. The one step allowed to conclude `unusable`, and even here not
|
|
141
|
+
* every non-zero exit is evidence about the daemon.
|
|
142
|
+
*
|
|
143
|
+
* Docker splits its own failures from the container's by exit code: 125 is `docker run` itself
|
|
144
|
+
* failing, 126 is a command that could not be invoked and 127 one that was not found. The last
|
|
145
|
+
* two are facts about THE PAYLOAD, a binary this platform put in an image it built, and the
|
|
146
|
+
* container had to be created and started to produce them. 125 covers both the daemon refusing to
|
|
147
|
+
* create the container (the verdict this whole module exists for) and the tag not being there to
|
|
148
|
+
* run, which is our own load, so that one is split by what docker SAID.
|
|
149
|
+
*/
|
|
150
|
+
function classifyRun(run) {
|
|
151
|
+
if (run.outcome === 'failed') {
|
|
152
|
+
return undeterminable(`the platform's container check did not run (${run.reason})`, true);
|
|
153
|
+
}
|
|
154
|
+
if (run.code === 0) {
|
|
155
|
+
if (run.stdout.includes(PROBE_SENTINEL))
|
|
156
|
+
return { status: 'usable' };
|
|
157
|
+
// Nothing explains this: the container was reported as having run cleanly and produced none
|
|
158
|
+
// of the output it exists to produce. That is a fact about the check, not about the daemon.
|
|
159
|
+
return undeterminable("the platform's probe container exited cleanly without printing its marker", true);
|
|
160
|
+
}
|
|
161
|
+
const ours = platformSideRunFailure(run);
|
|
162
|
+
return ours
|
|
163
|
+
? undeterminable(`${ours} (${describeOutcome(run)})`, true)
|
|
164
|
+
: { status: 'unusable', detail: describeOutcome(run) };
|
|
165
|
+
}
|
|
166
|
+
/** Messages that name the PLATFORM's half of a failed run rather than the daemon's. */
|
|
167
|
+
const PLATFORM_SIDE_RUN_MESSAGES = [
|
|
168
|
+
{
|
|
169
|
+
pattern: /no such image|unable to find image/i,
|
|
170
|
+
cause: "the platform's probe image was not there to be run",
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
pattern: /exec format error/i,
|
|
174
|
+
cause: "the platform's probe binary cannot be executed on this daemon's machine",
|
|
175
|
+
},
|
|
176
|
+
];
|
|
177
|
+
function platformSideRunFailure(run) {
|
|
178
|
+
if (run.code === 126 || run.code === 127) {
|
|
179
|
+
return "the platform's probe binary could not be invoked inside the container";
|
|
180
|
+
}
|
|
181
|
+
const said = `${run.stderr}\n${run.stdout}`;
|
|
182
|
+
return PLATFORM_SIDE_RUN_MESSAGES.find((m) => m.pattern.test(said))?.cause;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Assemble the archive for the daemon's architecture, reusing the last one built.
|
|
186
|
+
*
|
|
187
|
+
* A read that fails is classified rather than asserted away: `HARNESS_DOCKER_PROBE_BINARY`
|
|
188
|
+
* pointing at a directory, at a path this user may not read, or at a failing mount is a
|
|
189
|
+
* misconfiguration an operator can fix, and "this machine does not have it" states the opposite
|
|
190
|
+
* fact. The sentence goes into an agent's system prompt and into `GET /health`, so a discarded
|
|
191
|
+
* cause is a cause nobody ever sees.
|
|
192
|
+
*/
|
|
193
|
+
async function assembleArchive(deps, architecture) {
|
|
194
|
+
const key = `${deps.payloadPath}::${architecture}`;
|
|
195
|
+
const held = deps.archives?.read(key);
|
|
196
|
+
if (held)
|
|
197
|
+
return { archive: held };
|
|
198
|
+
let payload;
|
|
199
|
+
try {
|
|
200
|
+
payload = await deps.readPayload(deps.payloadPath);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
return { reason: describePayloadFailure(err, deps.payloadPath) };
|
|
204
|
+
}
|
|
205
|
+
const archive = buildProbeArchive(payload, architecture);
|
|
206
|
+
deps.archives?.write(key, archive);
|
|
207
|
+
return { archive };
|
|
208
|
+
}
|
|
209
|
+
function describePayloadFailure(err, path) {
|
|
210
|
+
const needs = `the platform's own container check needs ${path}`;
|
|
211
|
+
switch (err.code) {
|
|
212
|
+
case 'ENOENT':
|
|
213
|
+
case 'ENOTDIR':
|
|
214
|
+
return `${needs}, which this machine does not have`;
|
|
215
|
+
case 'EACCES':
|
|
216
|
+
case 'EPERM':
|
|
217
|
+
return `${needs}, which it is not permitted to read`;
|
|
218
|
+
case 'EISDIR':
|
|
219
|
+
return `${needs} to be a file, and it is a directory`;
|
|
220
|
+
default:
|
|
221
|
+
return `${needs}, which could not be read (${describeThrown(err)})`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Remove the probe image, and SAY SO when that did not work.
|
|
226
|
+
*
|
|
227
|
+
* The one line above it promises an agent will never find a `cat-factory-docker-probe` and wonder
|
|
228
|
+
* whose it is, and the daemon has two ordinary ways to refuse: a `--rm` teardown still in flight
|
|
229
|
+
* holds the image ("image is being used by stopped container"), and a wedged daemon does not
|
|
230
|
+
* answer at all. Discarding the outcome left both silent, so the promise was unverifiable in
|
|
231
|
+
* exactly the states that break it.
|
|
232
|
+
*/
|
|
233
|
+
async function removeProbeImage(deps) {
|
|
234
|
+
const removed = await deps.runDocker(['image', 'rm', '-f', PROBE_IMAGE_TAG], {
|
|
235
|
+
timeoutMs: CLEANUP_TIMEOUT_MS,
|
|
236
|
+
...(deps.logger ? { logger: deps.logger } : {}),
|
|
237
|
+
});
|
|
238
|
+
if (removed.outcome === 'ran' && removed.code === 0)
|
|
239
|
+
return;
|
|
240
|
+
(deps.logger ?? log).warn('docker capability: the probe image could not be removed', {
|
|
241
|
+
image: PROBE_IMAGE_TAG,
|
|
242
|
+
error: describeOutcome(removed),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
function undeterminable(reason, daemonAnswered) {
|
|
246
|
+
return { status: 'unknown', reason, daemonAnswered };
|
|
247
|
+
}
|
|
248
|
+
/** A bounded, scrubbed one-line summary of what a command said, for a prompt or a log field. */
|
|
249
|
+
function describeOutcome(outcome) {
|
|
250
|
+
if (outcome.outcome === 'failed')
|
|
251
|
+
return outcome.reason;
|
|
252
|
+
const said = `${outcome.stderr}\n${outcome.stdout}`
|
|
253
|
+
.split('\n')
|
|
254
|
+
.map((line) => line.trim())
|
|
255
|
+
.filter(Boolean)
|
|
256
|
+
.join('; ');
|
|
257
|
+
return bounded(said) || `docker exited ${outcome.code} without saying why`;
|
|
258
|
+
}
|
|
259
|
+
/** The one describer for a thrown value here: scrubbed and bounded, like any other detail. */
|
|
260
|
+
function describeThrown(err) {
|
|
261
|
+
return bounded(err instanceof Error ? err.message : String(err)) || 'it said nothing';
|
|
262
|
+
}
|
|
263
|
+
function bounded(text) {
|
|
264
|
+
const scrubbed = redactSecrets(text);
|
|
265
|
+
return scrubbed.length > DETAIL_CHARS ? `${scrubbed.slice(0, DETAIL_CHARS)}…` : scrubbed;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Build a probe that measures at most once per container for a POSITIVE answer.
|
|
269
|
+
*
|
|
270
|
+
* A daemon that has run a container has proved something that does not stop being true, so that
|
|
271
|
+
* verdict is kept and every later job reads it for free. A negative is NOT kept, for the reason
|
|
272
|
+
* `resolveDockerVerdict` gives about the boot record: a container outlives its boot, a warm pool
|
|
273
|
+
* serves many jobs from one, and a daemon that was not ready for the first job must not latch
|
|
274
|
+
* the whole container into saying so. Re-measuring a negative is cheap; a daemon that cannot
|
|
275
|
+
* mount fails at once.
|
|
276
|
+
*
|
|
277
|
+
* Concurrent callers share one in-flight measurement rather than each starting a container, and
|
|
278
|
+
* the measurement is cancelled when the LAST of them has abandoned it. Neither half is optional:
|
|
279
|
+
* one job's abort may not kill a measurement a sibling job is still waiting on (the local native
|
|
280
|
+
* transport serves every concurrent job from one process), and a measurement nobody is waiting
|
|
281
|
+
* for is a container start no job will read, which is what an abandoned run should stop paying
|
|
282
|
+
* for the moment it is abandoned.
|
|
283
|
+
*/
|
|
284
|
+
export function createDockerWorkloadProbe(deps = realDeps) {
|
|
285
|
+
let latest;
|
|
286
|
+
let inFlight;
|
|
287
|
+
const begin = () => {
|
|
288
|
+
const cancel = new AbortController();
|
|
289
|
+
const measurement = {
|
|
290
|
+
cancel,
|
|
291
|
+
waiters: 0,
|
|
292
|
+
result: measureDockerWorkload(deps, cancel.signal).then((verdict) => {
|
|
293
|
+
latest = verdict;
|
|
294
|
+
if (inFlight === measurement)
|
|
295
|
+
inFlight = undefined;
|
|
296
|
+
return verdict;
|
|
297
|
+
}),
|
|
298
|
+
};
|
|
299
|
+
return measurement;
|
|
300
|
+
};
|
|
301
|
+
const probe = (async (signal) => {
|
|
302
|
+
if (latest?.status === 'usable')
|
|
303
|
+
return latest;
|
|
304
|
+
const measurement = (inFlight ??= begin());
|
|
305
|
+
measurement.waiters += 1;
|
|
306
|
+
const watch = signal ? watchAbandonment(signal) : undefined;
|
|
307
|
+
try {
|
|
308
|
+
return watch
|
|
309
|
+
? await Promise.race([measurement.result, watch.abandoned])
|
|
310
|
+
: await measurement.result;
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
watch?.dispose();
|
|
314
|
+
measurement.waiters -= 1;
|
|
315
|
+
if (measurement.waiters === 0 && inFlight === measurement)
|
|
316
|
+
measurement.cancel.abort();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
probe.last = () => latest;
|
|
320
|
+
return probe;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* A verdict for the caller whose job was cancelled while it waited, and the listener teardown
|
|
324
|
+
* that keeps a long-lived native-transport process from accumulating one per job.
|
|
325
|
+
*/
|
|
326
|
+
function watchAbandonment(signal) {
|
|
327
|
+
let give = () => { };
|
|
328
|
+
const abandoned = new Promise((resolve) => {
|
|
329
|
+
give = () => resolve(undeterminable("the job was cancelled before the platform's container check answered", false));
|
|
330
|
+
if (signal.aborted)
|
|
331
|
+
give();
|
|
332
|
+
else
|
|
333
|
+
signal.addEventListener('abort', give, { once: true });
|
|
334
|
+
});
|
|
335
|
+
return { abandoned, dispose: () => signal.removeEventListener('abort', give) };
|
|
336
|
+
}
|
|
337
|
+
/** The process-wide probe. One per container, which is what makes the positive memo worth having. */
|
|
338
|
+
export const probeDockerWorkload = createDockerWorkloadProbe();
|
|
339
|
+
/**
|
|
340
|
+
* What `GET /health` reports about the workload check.
|
|
341
|
+
*
|
|
342
|
+
* `unmeasured` is its own word rather than an omitted key or a `null`: this endpoint is polled
|
|
343
|
+
* from boot, so "nothing has needed the daemon yet" is the normal early answer and it must not
|
|
344
|
+
* read as either a broken daemon or a build that cannot report one.
|
|
345
|
+
*/
|
|
346
|
+
export function reportedDockerWorkload(probe = probeDockerWorkload) {
|
|
347
|
+
return (probe.last() ?? {
|
|
348
|
+
status: 'unmeasured',
|
|
349
|
+
reason: 'nothing in this container has needed the docker daemon yet',
|
|
350
|
+
});
|
|
351
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type Logger } from './logger.js';
|
|
2
|
+
/** What running one docker command did, kept as raw as the spawn. */
|
|
3
|
+
export type CommandOutcome = {
|
|
4
|
+
outcome: 'ran';
|
|
5
|
+
code: number;
|
|
6
|
+
stdout: string;
|
|
7
|
+
stderr: string;
|
|
8
|
+
} | {
|
|
9
|
+
outcome: 'failed';
|
|
10
|
+
reason: string;
|
|
11
|
+
};
|
|
12
|
+
/** What one docker invocation is given. `timeoutMs` is required: an unbounded one has no caller. */
|
|
13
|
+
export interface DockerCommandOptions {
|
|
14
|
+
/** Piped to the command's stdin and closed. */
|
|
15
|
+
stdin?: Buffer;
|
|
16
|
+
/** The job's signal. An abandoned job's command is killed rather than left running. */
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
timeoutMs: number;
|
|
19
|
+
logger?: Logger;
|
|
20
|
+
}
|
|
21
|
+
/** Run one `docker …` command. Injected so the suite drives every branch with no daemon. */
|
|
22
|
+
export type DockerCommandRunner = (args: string[], opts: DockerCommandOptions) => Promise<CommandOutcome>;
|
|
23
|
+
/**
|
|
24
|
+
* The real runner: spawn docker, feed it `stdin` when there is any, and report what happened.
|
|
25
|
+
*
|
|
26
|
+
* Never rejects. Every way a spawn can go wrong is one of the two outcomes, because the caller
|
|
27
|
+
* classifies them differently and an exception would collapse that distinction into whichever
|
|
28
|
+
* `catch` caught it first.
|
|
29
|
+
*/
|
|
30
|
+
export declare const spawnDockerCommand: DockerCommandRunner;
|