@cat-factory/executor-harness 1.147.0 → 1.151.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 +62 -9
- package/dist/agent.js +6 -3
- package/dist/docker-capability.d.ts +59 -0
- package/dist/docker-capability.js +176 -17
- package/dist/docker-probe-image.d.ts +61 -0
- package/dist/docker-probe-image.js +135 -0
- package/dist/docker-status.d.ts +14 -1
- package/dist/environment-inventory.d.ts +7 -1
- package/dist/environment-inventory.js +79 -4
- package/dist/infra-standup.js +15 -0
- package/dist/job.d.ts +10 -0
- package/dist/redact.d.ts +15 -0
- package/dist/redact.js +18 -0
- package/package.json +6 -6
- package/src/agent.ts +6 -3
- package/src/docker-capability.ts +259 -10
- package/src/docker-probe-image.ts +157 -0
- package/src/docker-status.ts +14 -1
- package/src/environment-inventory.ts +95 -7
- package/src/infra-standup.ts +16 -0
- package/src/job.ts +10 -0
- package/src/redact.ts +19 -0
package/README.md
CHANGED
|
@@ -155,6 +155,21 @@ Three rules bind anything added to it:
|
|
|
155
155
|
payload on this machine, a daemon whose architecture the payload is not built for, `docker load`
|
|
156
156
|
refusing the archive) AND the halves of a failed run that are ours rather than the daemon's,
|
|
157
157
|
which is what docker's exit 126/127, a tag that did not resolve and an unexecutable payload are.
|
|
158
|
+
- **A daemon that runs containers is not a daemon whose containers have a NETWORK**, which is the
|
|
159
|
+
same mistake one level in again. Loading and running a local image needs no network at all, so the
|
|
160
|
+
workload check above passes identically on a daemon whose nested containers are cut off: every
|
|
161
|
+
published image ran its rootless daemon with `--iptables=false`, which drops the MASQUERADE rule
|
|
162
|
+
for its bridge, and the harness reported `usable` while every `docker build` that fetched a
|
|
163
|
+
dependency was guaranteed to fail (issue #2174). So `usable` carries its own egress verdict,
|
|
164
|
+
measured from INSIDE a nested container (a TCP connect to a raw address, and a name to resolve),
|
|
165
|
+
and the rendered line says something different for each. The `blocked` wording is precise about
|
|
166
|
+
which commands break, because "docker has no network" is false and would have an agent skip work
|
|
167
|
+
it could do: the daemon pulls base images and `docker compose up` of pre-built images works, while
|
|
168
|
+
every `RUN` line that fetches anything fails, SLOWLY, since npm reports `EAI_AGAIN` only once its
|
|
169
|
+
retry backoff gives up and the build reads as a hang. `reachable` needs the connect AND the
|
|
170
|
+
lookup, since nothing an agent installs is fetched by address; a resolved name with a refused
|
|
171
|
+
connect is undetermined rather than blocked, because the resolution proves a path out exists and
|
|
172
|
+
the likelier cause is a deployment that filters the address the check was pointed at.
|
|
158
173
|
- **A daemon that is STARTING is not a daemon that is absent.** Because the entrypoint does not
|
|
159
174
|
wait, the backend dispatches seconds before there is a socket, and `docker info` is then refused
|
|
160
175
|
at once rather than slowly. So a refusal is read against `DOCKER_HOST`, which the entrypoint sets
|
|
@@ -389,12 +404,15 @@ Docker socket would hand the container root on the host.
|
|
|
389
404
|
concluded about itself.
|
|
390
405
|
- The compose stand-up REFUSES on a decided negative and says why, instead of running compose
|
|
391
406
|
against nothing and handing the agent a connection error to interpret. The refusal rides back on
|
|
392
|
-
the Tester step as the cause plus the
|
|
393
|
-
`infraSetup.dockerAvailable` (was anything answering)
|
|
394
|
-
container did on it)
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
407
|
+
the Tester step as the cause plus the three facts that decide where a human should look:
|
|
408
|
+
`infraSetup.dockerAvailable` (was anything answering), `infraSetup.dockerWorkload` (what a
|
|
409
|
+
container did on it) and `infraSetup.dockerEgress` (what that container could REACH). Three
|
|
410
|
+
fields rather than one, because the daemon has three ways to stop the work: nothing to talk to, a
|
|
411
|
+
daemon that answers and cannot run a container, and a daemon that runs containers and gives them
|
|
412
|
+
no network. Flattening the second onto `dockerAvailable: false` renders as "no Docker daemon in
|
|
413
|
+
the executor" and sends an operator to restart a daemon that is already up; flattening the third
|
|
414
|
+
onto `dockerWorkload: 'usable'` renders as a sandbox where the stack works, which is the reading
|
|
415
|
+
that let every `docker build` in a `--iptables=false` container fail unexplained for months.
|
|
398
416
|
|
|
399
417
|
**What the entrypoint probes for is a SOCKET, and serving is not usable.** That is the whole of
|
|
400
418
|
what a boot record can know, and it is weaker than what either consumer wants: a rootless daemon in
|
|
@@ -433,6 +451,39 @@ about to run, and the record supplies what only the record holds, the cause and
|
|
|
433
451
|
log tail. `GET /health` deliberately keeps reporting the boot record rather than probing per poll,
|
|
434
452
|
since it is not the surface that acts on the answer.
|
|
435
453
|
|
|
454
|
+
**Which daemon the container ends up with is a choice made on evidence.** `--iptables=false`
|
|
455
|
+
arrived because the daemon could not start at all without it: a sandbox like Cloudflare Containers
|
|
456
|
+
gives it no way to install its firewall rules, and it refuses to start. What went unnoticed is what
|
|
457
|
+
the flag costs once the daemon DOES start. The rule it drops is the MASQUERADE for the bridge, so a
|
|
458
|
+
nested container is never NATed and has no egress whatsoever, no DNS and no raw IP either. The
|
|
459
|
+
daemon's own `docker pull` keeps working, which is most of why it stayed hidden for so long, and the
|
|
460
|
+
cost lands on a `docker build` whose `RUN npm ci` sits in npm's retry backoff for some seven minutes
|
|
461
|
+
before failing (issue #2173). So the entrypoint starts the daemon that manages its own rules first
|
|
462
|
+
and falls back to `--iptables=false` only when that one EXITS without serving.
|
|
463
|
+
|
|
464
|
+
The death, and not a clock, is what decides. A sandbox with no iptables binary and no NAT module
|
|
465
|
+
does not make the daemon slow, it makes `dockerd` exit at once with the reason on its log, so an
|
|
466
|
+
exit is the one observation here that is actually about the flags. A first arm still starting when
|
|
467
|
+
the budget runs out is a cold sandbox as often as a wedged one, and swapping there would take a
|
|
468
|
+
capable daemon away for the container's whole life on a guess; it is recorded as undecided and LEFT
|
|
469
|
+
RUNNING instead, so `resolveDockerVerdict`'s live re-probe can still find it with its NAT intact. A
|
|
470
|
+
sandbox that genuinely cannot do iptables ends up exactly where it was; a privileged Docker or
|
|
471
|
+
Podman host, which is what local mode runs on, gets working nested networking.
|
|
472
|
+
|
|
473
|
+
Each arm gets its OWN rootlesskit state directory, image store and pid file, and the abandon path
|
|
474
|
+
waits for the process to be gone after `kill -9`. SIGKILL is not propagated, so a launcher that had
|
|
475
|
+
already forked the real `dockerd` dies while its child holds an exclusive lock on the data root and
|
|
476
|
+
a live pid file: sharing either would make the fallback fail with `pid file found` for a reason that
|
|
477
|
+
has nothing to do with why it was started, which is the worst outcome available here (both arms
|
|
478
|
+
record `failed` and the container ends up with no daemon at all). The socket is the one thing that
|
|
479
|
+
cannot be per-arm, since `DOCKER_HOST` names it, and dockerd unlinks and rebinds it.
|
|
480
|
+
|
|
481
|
+
The two arms record different `reason` words (`serving` and `serving-without-nat`) with a detail
|
|
482
|
+
that names the CONSEQUENCE, which the workload check measures from inside a nested container without
|
|
483
|
+
ever learning why. What the detail does NOT do is name a cause: "iptables is unavailable here" is
|
|
484
|
+
not something the entrypoint measures, and the real cause is the daemon's own log tail, which rides
|
|
485
|
+
the stderr line announcing the switch.
|
|
486
|
+
|
|
436
487
|
Why it is written down at all: the image shipped for months with `docker-ce-rootless-extras` (the
|
|
437
488
|
wrappers that START a daemon) and no `docker-ce` (the daemon itself), and no `iproute2` for the
|
|
438
489
|
network rootlesskit builds. The entrypoint backgrounded the start in a subshell where its exit
|
|
@@ -483,8 +534,8 @@ verdict is stated.
|
|
|
483
534
|
| `src/agent-shared.ts` | The few helpers every agent MODE shares (effort-report folding, the capability fields forwarded to `runAgentInWorkspace`). |
|
|
484
535
|
| `src/logger.ts` | Structured logging. |
|
|
485
536
|
| `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
|
|
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. |
|
|
537
|
+
| `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"). `usable` then carries what a SECOND container, on the default network, could reach: `reachable` / `blocked` / `undetermined`, on the same asymmetry (a missing busybox applet is the platform's gap, never an absent network). Total: it answers even if it throws. One budget for the whole pass plus a separate one for the egress container, cancelled when the last caller abandons it, memoised per container once SETTLED: any positive, plus every negative whose cause cannot change under a running container (a rejected target, a payload with no `nc`, a filtered address). Only a genuinely transient failure is re-measured, because re-measuring is two container starts and an image load per job on the critical path. |
|
|
538
|
+
| `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. Also builds the egress container's argv, and validates the address it aims at (an IPv4 literal, never a name: a connect that has to resolve first cannot separate a broken route from broken DNS). The connect uses busybox `nc -z` where the payload has it and no `-w` where it does not, both under `busybox timeout`, because `-w` covers the FINAL NET READ: on a TLS port, a connect that succeeded then exits non-zero and reads as a route that is not there. |
|
|
488
539
|
| `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. |
|
|
489
540
|
| `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). |
|
|
490
541
|
|
|
@@ -510,8 +561,10 @@ runner):
|
|
|
510
561
|
| `REPRODUCTION_TOTAL_BUDGET_MS` | `2700000` (45m) | Wall-clock ceiling on the WHOLE proof phase (every attempt, both trees, setup included). Attempts multiply two full tree runs each and the heartbeat above deliberately stops the inactivity watchdog from firing, so this is what bounds the phase. Checked at phase boundaries; exceeding it settles `inconclusive`, never a run failure. |
|
|
511
562
|
| `HARNESS_TRANSCRIPT_TTL_MS` | `259200000` (3d) | How long lifted subscription-CLI session transcripts are kept before the retention sweep prunes them. |
|
|
512
563
|
| `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. |
|
|
513
|
-
| `HARNESS_DOCKER_READY_TIMEOUT_SECONDS` | `60` | How long `entrypoint.sh`
|
|
564
|
+
| `HARNESS_DOCKER_READY_TIMEOUT_SECONDS` | `60` | How long `entrypoint.sh` spends GETTING a Docker daemon to answer, in total, before recording it unavailable. One budget for the whole sequence, not one per arm: the rootless path may start a second daemon after the first exits, and the fallback gets what is left of this (floored at 10s, the only thing that can push the sequence past it, and only when a daemon took the whole window to die). Only a HUNG daemon pays it 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. |
|
|
514
565
|
| `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). |
|
|
566
|
+
| `HARNESS_DOCKER_EGRESS_TARGET` | `1.1.1.1:443` | Where the egress check connects FROM INSIDE a nested container, to find out whether this daemon's containers have a route out. An `IPv4:port`, never a name (a connect that has to resolve first cannot separate a broken route from broken DNS). The default aims at the PUBLIC internet: a deployment that deliberately has none should point this, and the DNS name below, at what it does run, or the verdict is an honest `blocked` about two addresses that were never the ones that matter. A value that is not an address is REPORTED as a check that could not be carried out, never silently replaced. |
|
|
567
|
+
| `HARNESS_DOCKER_EGRESS_DNS_NAME` | `registry.npmjs.org` | The name that same container resolves, which is the other half of egress: nothing an agent installs is fetched by address, so a working route with broken DNS is still reported as blocked, with the detail naming DNS as the half to fix. |
|
|
515
568
|
| `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. |
|
|
516
569
|
|
|
517
570
|
## Build / test
|
package/dist/agent.js
CHANGED
|
@@ -139,9 +139,12 @@ export async function handleAgent(job, opts = {}) {
|
|
|
139
139
|
// single post-clone place to put this). The pass is sized for that: everything in it runs
|
|
140
140
|
// concurrently, and every probe either answers in milliseconds or is bounded. Two of them are
|
|
141
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
|
|
143
|
-
//
|
|
144
|
-
//
|
|
142
|
+
// starting, and, only once a daemon has answered, the CONTAINERS the platform runs to find out
|
|
143
|
+
// what this daemon can do (`docker-capability.ts`: one to prove it runs a container at all,
|
|
144
|
+
// then one on the default network to see what that container reaches). Both are budgeted, and
|
|
145
|
+
// the pair is memoised per container once it has SETTLED, which is any positive plus every
|
|
146
|
+
// negative that cannot change under a running container. Both take the job's signal, so an
|
|
147
|
+
// abandoned run stops paying at once.
|
|
145
148
|
const staged = {
|
|
146
149
|
...job,
|
|
147
150
|
systemPrompt: await appendEnvironmentInventory(job.systemPrompt, {
|
|
@@ -1,8 +1,51 @@
|
|
|
1
1
|
import { type DockerCommandRunner } from './docker-command.js';
|
|
2
2
|
import { type Logger } from './logger.js';
|
|
3
|
+
/**
|
|
4
|
+
* What a NESTED container could reach, measured from inside one.
|
|
5
|
+
*
|
|
6
|
+
* Measured there and nowhere else, which is the whole point. The harness container's own network
|
|
7
|
+
* is fine in both cases: it resolves and fetches normally, and the daemon pulls images
|
|
8
|
+
* successfully, so every check one layer out reports a working network over a daemon whose
|
|
9
|
+
* containers have none.
|
|
10
|
+
*
|
|
11
|
+
* `reachable` needs BOTH halves. A route with no DNS is not a network an agent can use: nothing
|
|
12
|
+
* it installs is fetched by address, so `blocked` is the honest verdict and the detail names DNS
|
|
13
|
+
* as the half to fix. The reverse (a name resolved, the configured address refused) is
|
|
14
|
+
* `undetermined` instead of `blocked`, because a resolved name proves a path out exists and the
|
|
15
|
+
* likeliest cause is that this deployment filters the address the check was pointed at.
|
|
16
|
+
*/
|
|
17
|
+
export type ContainerEgress =
|
|
18
|
+
/** A container opened a TCP connection out AND resolved a public name. */
|
|
19
|
+
{
|
|
20
|
+
status: 'reachable';
|
|
21
|
+
}
|
|
22
|
+
/** It could not get out. `detail` names how far it got, since the two have different fixes. */
|
|
23
|
+
| {
|
|
24
|
+
status: 'blocked';
|
|
25
|
+
detail: string;
|
|
26
|
+
}
|
|
27
|
+
/** The check could not be carried out, or could not be read as evidence about the network. */
|
|
28
|
+
| {
|
|
29
|
+
status: 'undetermined';
|
|
30
|
+
reason: string;
|
|
31
|
+
/**
|
|
32
|
+
* Whether asking again could give a different answer.
|
|
33
|
+
*
|
|
34
|
+
* Load-bearing, because the probe re-measures an undetermined egress and re-measuring
|
|
35
|
+
* means two container starts plus an image load, per job, on the critical path ahead of
|
|
36
|
+
* the clone. Most of the ways to land here cannot change while this container lives: a
|
|
37
|
+
* rejected `HARNESS_DOCKER_EGRESS_TARGET`, a payload with no `nc` applet, an address this
|
|
38
|
+
* deployment filters. Latching those is not a stale verdict, it is the same measurement
|
|
39
|
+
* with the same inputs; re-running it forever costs every job about forty seconds for a
|
|
40
|
+
* value that is settled. Only a genuinely transient failure (a timeout, a cancelled job,
|
|
41
|
+
* a daemon that could not attach the bridge yet) is worth asking about again.
|
|
42
|
+
*/
|
|
43
|
+
recheck: boolean;
|
|
44
|
+
};
|
|
3
45
|
/** What one measurement concluded. See the three answers above; nothing collapses them. */
|
|
4
46
|
export type DockerWorkload = {
|
|
5
47
|
status: 'usable';
|
|
48
|
+
egress: ContainerEgress;
|
|
6
49
|
} | {
|
|
7
50
|
status: 'unusable';
|
|
8
51
|
detail: string;
|
|
@@ -38,6 +81,11 @@ export interface DockerWorkloadDeps {
|
|
|
38
81
|
runDocker: DockerCommandRunner;
|
|
39
82
|
/** This process's architecture, as `process.arch` spells it: the PAYLOAD's, never the daemon's. */
|
|
40
83
|
arch: string;
|
|
84
|
+
/** Where the egress container aims, as configured. Validated at use, never here. */
|
|
85
|
+
egress: {
|
|
86
|
+
target: string;
|
|
87
|
+
dnsName: string;
|
|
88
|
+
};
|
|
41
89
|
logger?: Logger;
|
|
42
90
|
archives?: ProbeArchiveMemo;
|
|
43
91
|
}
|
|
@@ -76,6 +124,17 @@ export interface DockerWorkloadProbe {
|
|
|
76
124
|
* the whole container into saying so. Re-measuring a negative is cheap; a daemon that cannot
|
|
77
125
|
* mount fails at once.
|
|
78
126
|
*
|
|
127
|
+
* A `usable` verdict whose EGRESS could not be determined is re-measured on the same rule and for
|
|
128
|
+
* the same reason. Whether the bridge is NATed is settled once and for the daemon's life, so a
|
|
129
|
+
* measured `reachable` or `blocked` is kept; a check that timed out measured nothing, and latching
|
|
130
|
+
* that would leave the container permanently unable to say which of the two it is.
|
|
131
|
+
*
|
|
132
|
+
* But only where asking again could ANSWER differently, which is what `ContainerEgress.recheck`
|
|
133
|
+
* carries. Most ways to reach `undetermined` are standing facts about this container: a rejected
|
|
134
|
+
* target setting, a payload with no `nc`, an address the deployment filters. Re-running the whole
|
|
135
|
+
* measurement on those never converges, and it is not cheap: it is `docker version`, the archive,
|
|
136
|
+
* `docker load`, two container starts and an `image rm`, per job, ahead of the clone.
|
|
137
|
+
*
|
|
79
138
|
* Concurrent callers share one in-flight measurement rather than each starting a container, and
|
|
80
139
|
* the measurement is cancelled when the LAST of them has abandoned it. Neither half is optional:
|
|
81
140
|
* one job's abort may not kill a measurement a sibling job is still waiting on (the local native
|
|
@@ -1,14 +1,32 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { spawnDockerCommand, } from './docker-command.js';
|
|
3
|
-
import { buildProbeArchive, payloadArchitecture, PROBE_COMMAND, PROBE_IMAGE_TAG, PROBE_SENTINEL, } from './docker-probe-image.js';
|
|
3
|
+
import { buildEgressCommand, buildProbeArchive, EGRESS_DNS_MARKER, EGRESS_TCP_MARKER, parseEgressTarget, payloadArchitecture, PROBE_COMMAND, PROBE_IMAGE_TAG, PROBE_SENTINEL, } from './docker-probe-image.js';
|
|
4
4
|
import { log } from './logger.js';
|
|
5
|
-
import {
|
|
5
|
+
import { scrubbedExcerpt } from './redact.js';
|
|
6
6
|
/**
|
|
7
7
|
* The statically linked binary the probe image is built from, overridable for an image variant
|
|
8
8
|
* that ships it elsewhere. Absent is a supported answer, not a failure: under
|
|
9
9
|
* `LOCAL_NATIVE_AGENTS` the harness runs on a developer's machine that never saw this image.
|
|
10
10
|
*/
|
|
11
11
|
const PAYLOAD_PATH = process.env.HARNESS_DOCKER_PROBE_BINARY?.trim() || '/bin/busybox';
|
|
12
|
+
/**
|
|
13
|
+
* Where the egress check aims, overridable for a deployment whose network permits something else.
|
|
14
|
+
*
|
|
15
|
+
* A raw IPv4 address rather than a name, so the connect answers a question about ROUTING alone:
|
|
16
|
+
* pointing it at a hostname would make every verdict depend on DNS, which is the other half and
|
|
17
|
+
* is measured separately. `1.1.1.1:443` is an anycast address that answers TLS from everywhere
|
|
18
|
+
* and belongs to no API this repo calls; the name is npm's because npm is what the outage
|
|
19
|
+
* actually broke. Neither is validated here (see `parseEgressTarget`), so a rejected setting is
|
|
20
|
+
* reported rather than replaced.
|
|
21
|
+
*
|
|
22
|
+
* Both defaults aim at the PUBLIC internet, and a deployment that deliberately has none should
|
|
23
|
+
* point these at what it does run (an internal registry mirror and its own DNS zone). Left at
|
|
24
|
+
* the defaults there, the measurement is honest but narrow: it establishes that a container
|
|
25
|
+
* cannot reach these two, which is why the prompt built from a `blocked` verdict says which
|
|
26
|
+
* targets were tried rather than that nothing at all is reachable.
|
|
27
|
+
*/
|
|
28
|
+
const EGRESS_TARGET = process.env.HARNESS_DOCKER_EGRESS_TARGET?.trim() || '1.1.1.1:443';
|
|
29
|
+
const EGRESS_DNS_NAME = process.env.HARNESS_DOCKER_EGRESS_DNS_NAME?.trim() || 'registry.npmjs.org';
|
|
12
30
|
/**
|
|
13
31
|
* The ceiling on ONE WHOLE measurement, shared out across the docker commands it makes: each
|
|
14
32
|
* gets what is left of it, down to {@link MIN_COMMAND_MS}.
|
|
@@ -29,6 +47,19 @@ const PAYLOAD_PATH = process.env.HARNESS_DOCKER_PROBE_BINARY?.trim() || '/bin/bu
|
|
|
29
47
|
const WORKLOAD_BUDGET_MS = 20_000;
|
|
30
48
|
/** The floor on one command's share of the budget, so an exhausted budget still gets an answer. */
|
|
31
49
|
const MIN_COMMAND_MS = 1_000;
|
|
50
|
+
/**
|
|
51
|
+
* The ceiling on the egress container, which gets its OWN budget rather than a share of the one
|
|
52
|
+
* above.
|
|
53
|
+
*
|
|
54
|
+
* The argument for a single shared budget is that a per-command ceiling multiplies on a WEDGED
|
|
55
|
+
* daemon, and that argument does not reach here: this container is started only after another one
|
|
56
|
+
* has already run to completion, so the daemon is known to work by the time it is spawned. What
|
|
57
|
+
* it does have to allow for is a check that is SUPPOSED to be slow in the failing case, since a
|
|
58
|
+
* blocked route is silent rather than refused and both in-container timeouts have to expire.
|
|
59
|
+
* Taking that out of the workload budget would have starved the step this whole module exists
|
|
60
|
+
* for; leaving it unbounded would hand a wedged network the whole job.
|
|
61
|
+
*/
|
|
62
|
+
const EGRESS_BUDGET_MS = 20_000;
|
|
32
63
|
/**
|
|
33
64
|
* The ceiling on removing the probe image again.
|
|
34
65
|
*
|
|
@@ -54,6 +85,7 @@ const realDeps = {
|
|
|
54
85
|
payloadPath: PAYLOAD_PATH,
|
|
55
86
|
runDocker: spawnDockerCommand,
|
|
56
87
|
arch: process.arch,
|
|
88
|
+
egress: { target: EGRESS_TARGET, dnsName: EGRESS_DNS_NAME },
|
|
57
89
|
archives: oneSlotArchiveMemo(),
|
|
58
90
|
};
|
|
59
91
|
/**
|
|
@@ -105,7 +137,7 @@ async function measure(deps, seen, signal) {
|
|
|
105
137
|
seen.daemonAnswered = true;
|
|
106
138
|
const daemonArch = asked.stdout.trim();
|
|
107
139
|
if (!/^[a-z0-9_]+$/.test(daemonArch)) {
|
|
108
|
-
return undeterminable(`the Docker daemon did not name an architecture the platform can build an image for (${
|
|
140
|
+
return undeterminable(`the Docker daemon did not name an architecture the platform can build an image for (${scrubbedExcerpt(daemonArch, 40) || 'it answered nothing'})`, true);
|
|
109
141
|
}
|
|
110
142
|
const payloadArch = payloadArchitecture(deps.arch);
|
|
111
143
|
if (!payloadArch) {
|
|
@@ -131,22 +163,135 @@ async function measure(deps, seen, signal) {
|
|
|
131
163
|
PROBE_IMAGE_TAG,
|
|
132
164
|
...PROBE_COMMAND,
|
|
133
165
|
]);
|
|
134
|
-
//
|
|
135
|
-
//
|
|
166
|
+
// A daemon that ran that container has answered the first question, and only then is there a
|
|
167
|
+
// second one worth asking. An `unusable` daemon cannot run the egress container either, and a
|
|
168
|
+
// check that could not be carried out has nothing to measure egress against.
|
|
169
|
+
const verdict = classifyRun(run);
|
|
170
|
+
const measured = verdict.status === 'usable'
|
|
171
|
+
? { status: 'usable', egress: await measureEgress(deps, signal) }
|
|
172
|
+
: verdict;
|
|
173
|
+
// After both runs, whatever the verdict is: the probe image is the platform's, and an agent
|
|
174
|
+
// that runs `docker images` should not have to wonder whose it is.
|
|
136
175
|
await removeProbeImage(deps);
|
|
137
|
-
return
|
|
176
|
+
return measured;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Run the second container and read what it reached.
|
|
180
|
+
*
|
|
181
|
+
* On the DEFAULT network, deliberately, which is the one thing that separates it from the marker
|
|
182
|
+
* run above (`--network none`). What an agent's own `docker build` and `docker run` get is the
|
|
183
|
+
* bridge, and the bridge is exactly what a daemon started with `--iptables=false` fails to NAT.
|
|
184
|
+
*
|
|
185
|
+
* Never concludes anything about the DAEMON. Every failure here is either evidence about the
|
|
186
|
+
* network or evidence about this check, and the caller has already established that the daemon
|
|
187
|
+
* runs containers.
|
|
188
|
+
*/
|
|
189
|
+
async function measureEgress(deps, signal) {
|
|
190
|
+
const setting = parseEgressTarget(deps.egress.target, deps.egress.dnsName);
|
|
191
|
+
// A rejected setting is read from this container's own environment, so it answers the same way
|
|
192
|
+
// on every job: latched rather than re-measured, which would otherwise spend two container
|
|
193
|
+
// starts per job re-reading one unchanged string.
|
|
194
|
+
if ('invalid' in setting) {
|
|
195
|
+
return { status: 'undetermined', reason: setting.invalid, recheck: false };
|
|
196
|
+
}
|
|
197
|
+
const run = await deps.runDocker(['run', '--rm', '--pull', 'never', PROBE_IMAGE_TAG, ...buildEgressCommand(setting.target)], {
|
|
198
|
+
...(signal ? { signal } : {}),
|
|
199
|
+
timeoutMs: EGRESS_BUDGET_MS,
|
|
200
|
+
...(deps.logger ? { logger: deps.logger } : {}),
|
|
201
|
+
});
|
|
202
|
+
return classifyEgress(run, setting.target);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* What the egress container's output proves, over the four combinations its two markers can
|
|
206
|
+
* carry.
|
|
207
|
+
*
|
|
208
|
+
* Read off the STATUS each command printed rather than off the run's own exit code, because the
|
|
209
|
+
* two failures that look alike from outside need opposite answers: a refused connection is
|
|
210
|
+
* evidence about the network, and a 126/127 is busybox saying the image has no such applet, which
|
|
211
|
+
* is evidence about the platform's own payload and may never be reported as a network that is not
|
|
212
|
+
* there.
|
|
213
|
+
*/
|
|
214
|
+
function classifyEgress(run, target) {
|
|
215
|
+
const where = `${target.host}:${target.port}`;
|
|
216
|
+
if (run.outcome === 'failed') {
|
|
217
|
+
return {
|
|
218
|
+
status: 'undetermined',
|
|
219
|
+
reason: `the platform's egress check did not run (${run.reason})`,
|
|
220
|
+
// A spawn failure or a timeout is about this attempt and not about the container.
|
|
221
|
+
recheck: true,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const tcp = readMarker(run.stdout, EGRESS_TCP_MARKER);
|
|
225
|
+
const dns = readMarker(run.stdout, EGRESS_DNS_MARKER);
|
|
226
|
+
if (tcp === undefined || dns === undefined) {
|
|
227
|
+
// Nothing was measured, and the two ways to get here need different words. `docker run`
|
|
228
|
+
// failing at the DAEMON level (125, or its own "no such image") means the container never
|
|
229
|
+
// started, so the network was not the thing that did not answer; a container that started
|
|
230
|
+
// and printed something unrecognisable is the platform's own payload misbehaving. Reporting
|
|
231
|
+
// the first as "printed no verdict" tells an operator to go looking at the check's output
|
|
232
|
+
// for a container that produced none.
|
|
233
|
+
const refused = platformSideRunFailure(run) ?? (run.code === 125 ? daemonRefusedEgressRun : undefined);
|
|
234
|
+
return refused
|
|
235
|
+
? {
|
|
236
|
+
status: 'undetermined',
|
|
237
|
+
reason: `the platform's egress container did not start (${refused}: ${describeOutcome(run)})`,
|
|
238
|
+
// The daemon just ran the marker container, so a bridge it could not attach now is
|
|
239
|
+
// the sort of thing that can differ on the next job.
|
|
240
|
+
recheck: true,
|
|
241
|
+
}
|
|
242
|
+
: {
|
|
243
|
+
status: 'undetermined',
|
|
244
|
+
reason: `the platform's egress check printed no verdict (${describeOutcome(run)})`,
|
|
245
|
+
// Same image, same argv, same output: asking again re-reads the same non-answer.
|
|
246
|
+
recheck: false,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
if ([tcp, dns].some((code) => code === 126 || code === 127)) {
|
|
250
|
+
return {
|
|
251
|
+
status: 'undetermined',
|
|
252
|
+
reason: "the platform's egress check could not run inside its own probe container (the payload " +
|
|
253
|
+
'has no `nc` or `nslookup` applet)',
|
|
254
|
+
// A fact about the image this repo builds, which does not change under a running container.
|
|
255
|
+
recheck: false,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (tcp === 0 && dns === 0)
|
|
259
|
+
return { status: 'reachable' };
|
|
260
|
+
if (tcp === 0) {
|
|
261
|
+
return {
|
|
262
|
+
status: 'blocked',
|
|
263
|
+
detail: `a container reached ${where} but could not resolve ${target.dnsName}: the route out works and DNS does not`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
if (dns === 0) {
|
|
267
|
+
// A resolved name proves a path out of the container exists, so the connect failing is far
|
|
268
|
+
// more likely to be about the ADDRESS than about the network. Saying "blocked" here would
|
|
269
|
+
// condemn a working sandbox over a target it happens to filter.
|
|
270
|
+
return {
|
|
271
|
+
status: 'undetermined',
|
|
272
|
+
reason: `a container resolved ${target.dnsName} but could not connect to ${where}, so this ` +
|
|
273
|
+
'deployment probably filters that address; point HARNESS_DOCKER_EGRESS_TARGET at one it permits',
|
|
274
|
+
// A filtered address is a standing property of the network this container sits in.
|
|
275
|
+
recheck: false,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
status: 'blocked',
|
|
280
|
+
detail: `a container could reach neither ${where} nor ${target.dnsName}, the two the platform is configured to try`,
|
|
281
|
+
};
|
|
138
282
|
}
|
|
283
|
+
/** What a 125 from the egress run means, kept beside the other platform-side run messages. */
|
|
284
|
+
const daemonRefusedEgressRun = 'the daemon refused to create or start it';
|
|
139
285
|
/**
|
|
140
|
-
*
|
|
141
|
-
* every non-zero exit is evidence about the daemon.
|
|
286
|
+
* The exit status printed after `marker`, or undefined when the container never printed one.
|
|
142
287
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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.
|
|
288
|
+
* The LAST occurrence wins, so a marker that somehow reached the stream twice is read at its
|
|
289
|
+
* final value rather than at whichever came first.
|
|
149
290
|
*/
|
|
291
|
+
function readMarker(stdout, marker) {
|
|
292
|
+
const status = [...stdout.matchAll(new RegExp(`${marker}(\\d{1,3})`, 'g'))].pop()?.[1];
|
|
293
|
+
return status === undefined ? undefined : Number(status);
|
|
294
|
+
}
|
|
150
295
|
function classifyRun(run) {
|
|
151
296
|
if (run.outcome === 'failed') {
|
|
152
297
|
return undeterminable(`the platform's container check did not run (${run.reason})`, true);
|
|
@@ -261,8 +406,7 @@ function describeThrown(err) {
|
|
|
261
406
|
return bounded(err instanceof Error ? err.message : String(err)) || 'it said nothing';
|
|
262
407
|
}
|
|
263
408
|
function bounded(text) {
|
|
264
|
-
|
|
265
|
-
return scrubbed.length > DETAIL_CHARS ? `${scrubbed.slice(0, DETAIL_CHARS)}…` : scrubbed;
|
|
409
|
+
return scrubbedExcerpt(text, DETAIL_CHARS);
|
|
266
410
|
}
|
|
267
411
|
/**
|
|
268
412
|
* Build a probe that measures at most once per container for a POSITIVE answer.
|
|
@@ -274,6 +418,17 @@ function bounded(text) {
|
|
|
274
418
|
* the whole container into saying so. Re-measuring a negative is cheap; a daemon that cannot
|
|
275
419
|
* mount fails at once.
|
|
276
420
|
*
|
|
421
|
+
* A `usable` verdict whose EGRESS could not be determined is re-measured on the same rule and for
|
|
422
|
+
* the same reason. Whether the bridge is NATed is settled once and for the daemon's life, so a
|
|
423
|
+
* measured `reachable` or `blocked` is kept; a check that timed out measured nothing, and latching
|
|
424
|
+
* that would leave the container permanently unable to say which of the two it is.
|
|
425
|
+
*
|
|
426
|
+
* But only where asking again could ANSWER differently, which is what `ContainerEgress.recheck`
|
|
427
|
+
* carries. Most ways to reach `undetermined` are standing facts about this container: a rejected
|
|
428
|
+
* target setting, a payload with no `nc`, an address the deployment filters. Re-running the whole
|
|
429
|
+
* measurement on those never converges, and it is not cheap: it is `docker version`, the archive,
|
|
430
|
+
* `docker load`, two container starts and an `image rm`, per job, ahead of the clone.
|
|
431
|
+
*
|
|
277
432
|
* Concurrent callers share one in-flight measurement rather than each starting a container, and
|
|
278
433
|
* the measurement is cancelled when the LAST of them has abandoned it. Neither half is optional:
|
|
279
434
|
* one job's abort may not kill a measurement a sibling job is still waiting on (the local native
|
|
@@ -299,7 +454,7 @@ export function createDockerWorkloadProbe(deps = realDeps) {
|
|
|
299
454
|
return measurement;
|
|
300
455
|
};
|
|
301
456
|
const probe = (async (signal) => {
|
|
302
|
-
if (latest?.status === 'usable')
|
|
457
|
+
if (latest?.status === 'usable' && !isWorthReMeasuring(latest.egress))
|
|
303
458
|
return latest;
|
|
304
459
|
const measurement = (inFlight ??= begin());
|
|
305
460
|
measurement.waiters += 1;
|
|
@@ -319,6 +474,10 @@ export function createDockerWorkloadProbe(deps = realDeps) {
|
|
|
319
474
|
probe.last = () => latest;
|
|
320
475
|
return probe;
|
|
321
476
|
}
|
|
477
|
+
/** Whether a kept verdict's egress half is one that asking again could still settle. */
|
|
478
|
+
function isWorthReMeasuring(egress) {
|
|
479
|
+
return egress.status === 'undetermined' && egress.recheck;
|
|
480
|
+
}
|
|
322
481
|
/**
|
|
323
482
|
* A verdict for the caller whose job was cancelled while it waited, and the listener teardown
|
|
324
483
|
* that keeps a long-lived native-transport process from accumulating one per job.
|
|
@@ -10,6 +10,67 @@ export declare const PROBE_IMAGE_TAG = "cat-factory-docker-probe:1";
|
|
|
10
10
|
export declare const PROBE_SENTINEL = "cat-factory-docker-probe-ok";
|
|
11
11
|
/** The argv the probe container runs. `busybox` dispatches on its own name, so this is an echo. */
|
|
12
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[];
|
|
13
74
|
/** The docker name for the architecture THIS process's payload is built for, when there is one. */
|
|
14
75
|
export declare function payloadArchitecture(arch?: string): string | undefined;
|
|
15
76
|
interface TarEntry {
|