@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/src/docker-capability.ts
CHANGED
|
@@ -5,14 +5,19 @@ import {
|
|
|
5
5
|
spawnDockerCommand,
|
|
6
6
|
} from './docker-command.js'
|
|
7
7
|
import {
|
|
8
|
+
buildEgressCommand,
|
|
8
9
|
buildProbeArchive,
|
|
10
|
+
EGRESS_DNS_MARKER,
|
|
11
|
+
EGRESS_TCP_MARKER,
|
|
12
|
+
type EgressTarget,
|
|
13
|
+
parseEgressTarget,
|
|
9
14
|
payloadArchitecture,
|
|
10
15
|
PROBE_COMMAND,
|
|
11
16
|
PROBE_IMAGE_TAG,
|
|
12
17
|
PROBE_SENTINEL,
|
|
13
18
|
} from './docker-probe-image.js'
|
|
14
19
|
import { log, type Logger } from './logger.js'
|
|
15
|
-
import {
|
|
20
|
+
import { scrubbedExcerpt } from './redact.js'
|
|
16
21
|
|
|
17
22
|
// ---------------------------------------------------------------------------
|
|
18
23
|
// Whether this machine's Docker daemon can RUN A CONTAINER, as opposed to merely answering.
|
|
@@ -46,11 +51,60 @@ import { redactSecrets } from './redact.js'
|
|
|
46
51
|
// load-bearing one level up: `resolveDockerVerdict` needs the cheap fact this check establishes
|
|
47
52
|
// on its way past (a daemon is answering RIGHT NOW) to keep a warm container out of a stale boot
|
|
48
53
|
// record's refusal, and only the check knows whether it ever got that far.
|
|
54
|
+
//
|
|
55
|
+
// `usable` then carries a SECOND fact, because running a container and reaching the network from
|
|
56
|
+
// inside one are different things and only the first of them was ever measured. A local image
|
|
57
|
+
// loads and runs with no network at all, so a daemon whose nested containers are cut off passes
|
|
58
|
+
// this check exactly as a working one does: the published image ran its daemon with
|
|
59
|
+
// `--iptables=false` and every `docker build` that fetched a dependency was guaranteed to fail,
|
|
60
|
+
// while the harness reported `usable` and told each agent that `docker build` works here (issue
|
|
61
|
+
// #2174). It is a separate field rather than a fourth status because it answers a separate
|
|
62
|
+
// question, with its own three outcomes and its own way of being undeterminable, and because the
|
|
63
|
+
// two have different consequences: no egress is a constraint to plan around, where an unusable
|
|
64
|
+
// daemon is a prohibition.
|
|
49
65
|
// ---------------------------------------------------------------------------
|
|
50
66
|
|
|
67
|
+
/**
|
|
68
|
+
* What a NESTED container could reach, measured from inside one.
|
|
69
|
+
*
|
|
70
|
+
* Measured there and nowhere else, which is the whole point. The harness container's own network
|
|
71
|
+
* is fine in both cases: it resolves and fetches normally, and the daemon pulls images
|
|
72
|
+
* successfully, so every check one layer out reports a working network over a daemon whose
|
|
73
|
+
* containers have none.
|
|
74
|
+
*
|
|
75
|
+
* `reachable` needs BOTH halves. A route with no DNS is not a network an agent can use: nothing
|
|
76
|
+
* it installs is fetched by address, so `blocked` is the honest verdict and the detail names DNS
|
|
77
|
+
* as the half to fix. The reverse (a name resolved, the configured address refused) is
|
|
78
|
+
* `undetermined` instead of `blocked`, because a resolved name proves a path out exists and the
|
|
79
|
+
* likeliest cause is that this deployment filters the address the check was pointed at.
|
|
80
|
+
*/
|
|
81
|
+
export type ContainerEgress =
|
|
82
|
+
/** A container opened a TCP connection out AND resolved a public name. */
|
|
83
|
+
| { status: 'reachable' }
|
|
84
|
+
/** It could not get out. `detail` names how far it got, since the two have different fixes. */
|
|
85
|
+
| { status: 'blocked'; detail: string }
|
|
86
|
+
/** The check could not be carried out, or could not be read as evidence about the network. */
|
|
87
|
+
| {
|
|
88
|
+
status: 'undetermined'
|
|
89
|
+
reason: string
|
|
90
|
+
/**
|
|
91
|
+
* Whether asking again could give a different answer.
|
|
92
|
+
*
|
|
93
|
+
* Load-bearing, because the probe re-measures an undetermined egress and re-measuring
|
|
94
|
+
* means two container starts plus an image load, per job, on the critical path ahead of
|
|
95
|
+
* the clone. Most of the ways to land here cannot change while this container lives: a
|
|
96
|
+
* rejected `HARNESS_DOCKER_EGRESS_TARGET`, a payload with no `nc` applet, an address this
|
|
97
|
+
* deployment filters. Latching those is not a stale verdict, it is the same measurement
|
|
98
|
+
* with the same inputs; re-running it forever costs every job about forty seconds for a
|
|
99
|
+
* value that is settled. Only a genuinely transient failure (a timeout, a cancelled job,
|
|
100
|
+
* a daemon that could not attach the bridge yet) is worth asking about again.
|
|
101
|
+
*/
|
|
102
|
+
recheck: boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
51
105
|
/** What one measurement concluded. See the three answers above; nothing collapses them. */
|
|
52
106
|
export type DockerWorkload =
|
|
53
|
-
| { status: 'usable' }
|
|
107
|
+
| { status: 'usable'; egress: ContainerEgress }
|
|
54
108
|
| { status: 'unusable'; detail: string }
|
|
55
109
|
| {
|
|
56
110
|
status: 'unknown'
|
|
@@ -69,6 +123,25 @@ export type DockerWorkload =
|
|
|
69
123
|
*/
|
|
70
124
|
const PAYLOAD_PATH = process.env.HARNESS_DOCKER_PROBE_BINARY?.trim() || '/bin/busybox'
|
|
71
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Where the egress check aims, overridable for a deployment whose network permits something else.
|
|
128
|
+
*
|
|
129
|
+
* A raw IPv4 address rather than a name, so the connect answers a question about ROUTING alone:
|
|
130
|
+
* pointing it at a hostname would make every verdict depend on DNS, which is the other half and
|
|
131
|
+
* is measured separately. `1.1.1.1:443` is an anycast address that answers TLS from everywhere
|
|
132
|
+
* and belongs to no API this repo calls; the name is npm's because npm is what the outage
|
|
133
|
+
* actually broke. Neither is validated here (see `parseEgressTarget`), so a rejected setting is
|
|
134
|
+
* reported rather than replaced.
|
|
135
|
+
*
|
|
136
|
+
* Both defaults aim at the PUBLIC internet, and a deployment that deliberately has none should
|
|
137
|
+
* point these at what it does run (an internal registry mirror and its own DNS zone). Left at
|
|
138
|
+
* the defaults there, the measurement is honest but narrow: it establishes that a container
|
|
139
|
+
* cannot reach these two, which is why the prompt built from a `blocked` verdict says which
|
|
140
|
+
* targets were tried rather than that nothing at all is reachable.
|
|
141
|
+
*/
|
|
142
|
+
const EGRESS_TARGET = process.env.HARNESS_DOCKER_EGRESS_TARGET?.trim() || '1.1.1.1:443'
|
|
143
|
+
const EGRESS_DNS_NAME = process.env.HARNESS_DOCKER_EGRESS_DNS_NAME?.trim() || 'registry.npmjs.org'
|
|
144
|
+
|
|
72
145
|
/**
|
|
73
146
|
* The ceiling on ONE WHOLE measurement, shared out across the docker commands it makes: each
|
|
74
147
|
* gets what is left of it, down to {@link MIN_COMMAND_MS}.
|
|
@@ -91,6 +164,20 @@ const WORKLOAD_BUDGET_MS = 20_000
|
|
|
91
164
|
/** The floor on one command's share of the budget, so an exhausted budget still gets an answer. */
|
|
92
165
|
const MIN_COMMAND_MS = 1_000
|
|
93
166
|
|
|
167
|
+
/**
|
|
168
|
+
* The ceiling on the egress container, which gets its OWN budget rather than a share of the one
|
|
169
|
+
* above.
|
|
170
|
+
*
|
|
171
|
+
* The argument for a single shared budget is that a per-command ceiling multiplies on a WEDGED
|
|
172
|
+
* daemon, and that argument does not reach here: this container is started only after another one
|
|
173
|
+
* has already run to completion, so the daemon is known to work by the time it is spawned. What
|
|
174
|
+
* it does have to allow for is a check that is SUPPOSED to be slow in the failing case, since a
|
|
175
|
+
* blocked route is silent rather than refused and both in-container timeouts have to expire.
|
|
176
|
+
* Taking that out of the workload budget would have starved the step this whole module exists
|
|
177
|
+
* for; leaving it unbounded would hand a wedged network the whole job.
|
|
178
|
+
*/
|
|
179
|
+
const EGRESS_BUDGET_MS = 20_000
|
|
180
|
+
|
|
94
181
|
/**
|
|
95
182
|
* The ceiling on removing the probe image again.
|
|
96
183
|
*
|
|
@@ -136,6 +223,8 @@ export interface DockerWorkloadDeps {
|
|
|
136
223
|
runDocker: DockerCommandRunner
|
|
137
224
|
/** This process's architecture, as `process.arch` spells it: the PAYLOAD's, never the daemon's. */
|
|
138
225
|
arch: string
|
|
226
|
+
/** Where the egress container aims, as configured. Validated at use, never here. */
|
|
227
|
+
egress: { target: string; dnsName: string }
|
|
139
228
|
logger?: Logger
|
|
140
229
|
archives?: ProbeArchiveMemo
|
|
141
230
|
}
|
|
@@ -145,6 +234,7 @@ const realDeps: DockerWorkloadDeps = {
|
|
|
145
234
|
payloadPath: PAYLOAD_PATH,
|
|
146
235
|
runDocker: spawnDockerCommand,
|
|
147
236
|
arch: process.arch,
|
|
237
|
+
egress: { target: EGRESS_TARGET, dnsName: EGRESS_DNS_NAME },
|
|
148
238
|
archives: oneSlotArchiveMemo(),
|
|
149
239
|
}
|
|
150
240
|
|
|
@@ -213,7 +303,7 @@ async function measure(
|
|
|
213
303
|
const daemonArch = asked.stdout.trim()
|
|
214
304
|
if (!/^[a-z0-9_]+$/.test(daemonArch)) {
|
|
215
305
|
return undeterminable(
|
|
216
|
-
`the Docker daemon did not name an architecture the platform can build an image for (${
|
|
306
|
+
`the Docker daemon did not name an architecture the platform can build an image for (${scrubbedExcerpt(daemonArch, 40) || 'it answered nothing'})`,
|
|
217
307
|
true,
|
|
218
308
|
)
|
|
219
309
|
}
|
|
@@ -252,10 +342,147 @@ async function measure(
|
|
|
252
342
|
PROBE_IMAGE_TAG,
|
|
253
343
|
...PROBE_COMMAND,
|
|
254
344
|
])
|
|
255
|
-
//
|
|
256
|
-
//
|
|
345
|
+
// A daemon that ran that container has answered the first question, and only then is there a
|
|
346
|
+
// second one worth asking. An `unusable` daemon cannot run the egress container either, and a
|
|
347
|
+
// check that could not be carried out has nothing to measure egress against.
|
|
348
|
+
const verdict = classifyRun(run)
|
|
349
|
+
const measured: DockerWorkload =
|
|
350
|
+
verdict.status === 'usable'
|
|
351
|
+
? { status: 'usable', egress: await measureEgress(deps, signal) }
|
|
352
|
+
: verdict
|
|
353
|
+
// After both runs, whatever the verdict is: the probe image is the platform's, and an agent
|
|
354
|
+
// that runs `docker images` should not have to wonder whose it is.
|
|
257
355
|
await removeProbeImage(deps)
|
|
258
|
-
return
|
|
356
|
+
return measured
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Run the second container and read what it reached.
|
|
361
|
+
*
|
|
362
|
+
* On the DEFAULT network, deliberately, which is the one thing that separates it from the marker
|
|
363
|
+
* run above (`--network none`). What an agent's own `docker build` and `docker run` get is the
|
|
364
|
+
* bridge, and the bridge is exactly what a daemon started with `--iptables=false` fails to NAT.
|
|
365
|
+
*
|
|
366
|
+
* Never concludes anything about the DAEMON. Every failure here is either evidence about the
|
|
367
|
+
* network or evidence about this check, and the caller has already established that the daemon
|
|
368
|
+
* runs containers.
|
|
369
|
+
*/
|
|
370
|
+
async function measureEgress(
|
|
371
|
+
deps: DockerWorkloadDeps,
|
|
372
|
+
signal?: AbortSignal,
|
|
373
|
+
): Promise<ContainerEgress> {
|
|
374
|
+
const setting = parseEgressTarget(deps.egress.target, deps.egress.dnsName)
|
|
375
|
+
// A rejected setting is read from this container's own environment, so it answers the same way
|
|
376
|
+
// on every job: latched rather than re-measured, which would otherwise spend two container
|
|
377
|
+
// starts per job re-reading one unchanged string.
|
|
378
|
+
if ('invalid' in setting) {
|
|
379
|
+
return { status: 'undetermined', reason: setting.invalid, recheck: false }
|
|
380
|
+
}
|
|
381
|
+
const run = await deps.runDocker(
|
|
382
|
+
['run', '--rm', '--pull', 'never', PROBE_IMAGE_TAG, ...buildEgressCommand(setting.target)],
|
|
383
|
+
{
|
|
384
|
+
...(signal ? { signal } : {}),
|
|
385
|
+
timeoutMs: EGRESS_BUDGET_MS,
|
|
386
|
+
...(deps.logger ? { logger: deps.logger } : {}),
|
|
387
|
+
},
|
|
388
|
+
)
|
|
389
|
+
return classifyEgress(run, setting.target)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* What the egress container's output proves, over the four combinations its two markers can
|
|
394
|
+
* carry.
|
|
395
|
+
*
|
|
396
|
+
* Read off the STATUS each command printed rather than off the run's own exit code, because the
|
|
397
|
+
* two failures that look alike from outside need opposite answers: a refused connection is
|
|
398
|
+
* evidence about the network, and a 126/127 is busybox saying the image has no such applet, which
|
|
399
|
+
* is evidence about the platform's own payload and may never be reported as a network that is not
|
|
400
|
+
* there.
|
|
401
|
+
*/
|
|
402
|
+
function classifyEgress(run: CommandOutcome, target: EgressTarget): ContainerEgress {
|
|
403
|
+
const where = `${target.host}:${target.port}`
|
|
404
|
+
if (run.outcome === 'failed') {
|
|
405
|
+
return {
|
|
406
|
+
status: 'undetermined',
|
|
407
|
+
reason: `the platform's egress check did not run (${run.reason})`,
|
|
408
|
+
// A spawn failure or a timeout is about this attempt and not about the container.
|
|
409
|
+
recheck: true,
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const tcp = readMarker(run.stdout, EGRESS_TCP_MARKER)
|
|
413
|
+
const dns = readMarker(run.stdout, EGRESS_DNS_MARKER)
|
|
414
|
+
if (tcp === undefined || dns === undefined) {
|
|
415
|
+
// Nothing was measured, and the two ways to get here need different words. `docker run`
|
|
416
|
+
// failing at the DAEMON level (125, or its own "no such image") means the container never
|
|
417
|
+
// started, so the network was not the thing that did not answer; a container that started
|
|
418
|
+
// and printed something unrecognisable is the platform's own payload misbehaving. Reporting
|
|
419
|
+
// the first as "printed no verdict" tells an operator to go looking at the check's output
|
|
420
|
+
// for a container that produced none.
|
|
421
|
+
const refused =
|
|
422
|
+
platformSideRunFailure(run) ?? (run.code === 125 ? daemonRefusedEgressRun : undefined)
|
|
423
|
+
return refused
|
|
424
|
+
? {
|
|
425
|
+
status: 'undetermined',
|
|
426
|
+
reason: `the platform's egress container did not start (${refused}: ${describeOutcome(run)})`,
|
|
427
|
+
// The daemon just ran the marker container, so a bridge it could not attach now is
|
|
428
|
+
// the sort of thing that can differ on the next job.
|
|
429
|
+
recheck: true,
|
|
430
|
+
}
|
|
431
|
+
: {
|
|
432
|
+
status: 'undetermined',
|
|
433
|
+
reason: `the platform's egress check printed no verdict (${describeOutcome(run)})`,
|
|
434
|
+
// Same image, same argv, same output: asking again re-reads the same non-answer.
|
|
435
|
+
recheck: false,
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if ([tcp, dns].some((code) => code === 126 || code === 127)) {
|
|
439
|
+
return {
|
|
440
|
+
status: 'undetermined',
|
|
441
|
+
reason:
|
|
442
|
+
"the platform's egress check could not run inside its own probe container (the payload " +
|
|
443
|
+
'has no `nc` or `nslookup` applet)',
|
|
444
|
+
// A fact about the image this repo builds, which does not change under a running container.
|
|
445
|
+
recheck: false,
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (tcp === 0 && dns === 0) return { status: 'reachable' }
|
|
449
|
+
if (tcp === 0) {
|
|
450
|
+
return {
|
|
451
|
+
status: 'blocked',
|
|
452
|
+
detail: `a container reached ${where} but could not resolve ${target.dnsName}: the route out works and DNS does not`,
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (dns === 0) {
|
|
456
|
+
// A resolved name proves a path out of the container exists, so the connect failing is far
|
|
457
|
+
// more likely to be about the ADDRESS than about the network. Saying "blocked" here would
|
|
458
|
+
// condemn a working sandbox over a target it happens to filter.
|
|
459
|
+
return {
|
|
460
|
+
status: 'undetermined',
|
|
461
|
+
reason:
|
|
462
|
+
`a container resolved ${target.dnsName} but could not connect to ${where}, so this ` +
|
|
463
|
+
'deployment probably filters that address; point HARNESS_DOCKER_EGRESS_TARGET at one it permits',
|
|
464
|
+
// A filtered address is a standing property of the network this container sits in.
|
|
465
|
+
recheck: false,
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
status: 'blocked',
|
|
470
|
+
detail: `a container could reach neither ${where} nor ${target.dnsName}, the two the platform is configured to try`,
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** What a 125 from the egress run means, kept beside the other platform-side run messages. */
|
|
475
|
+
const daemonRefusedEgressRun = 'the daemon refused to create or start it'
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The exit status printed after `marker`, or undefined when the container never printed one.
|
|
479
|
+
*
|
|
480
|
+
* The LAST occurrence wins, so a marker that somehow reached the stream twice is read at its
|
|
481
|
+
* final value rather than at whichever came first.
|
|
482
|
+
*/
|
|
483
|
+
function readMarker(stdout: string, marker: string): number | undefined {
|
|
484
|
+
const status = [...stdout.matchAll(new RegExp(`${marker}(\\d{1,3})`, 'g'))].pop()?.[1]
|
|
485
|
+
return status === undefined ? undefined : Number(status)
|
|
259
486
|
}
|
|
260
487
|
|
|
261
488
|
/**
|
|
@@ -268,8 +495,15 @@ async function measure(
|
|
|
268
495
|
* container had to be created and started to produce them. 125 covers both the daemon refusing to
|
|
269
496
|
* create the container (the verdict this whole module exists for) and the tag not being there to
|
|
270
497
|
* run, which is our own load, so that one is split by what docker SAID.
|
|
498
|
+
*
|
|
499
|
+
* Its own return type, and not {@link DockerWorkload}: what the marker run establishes is a
|
|
500
|
+
* daemon that runs containers, which is the first half of a `usable` verdict and not the whole
|
|
501
|
+
* of one. Naming the intermediate is what stops it being returned as a finished answer with the
|
|
502
|
+
* egress half silently absent.
|
|
271
503
|
*/
|
|
272
|
-
|
|
504
|
+
type RunVerdict = { status: 'usable' } | Exclude<DockerWorkload, { status: 'usable' }>
|
|
505
|
+
|
|
506
|
+
function classifyRun(run: CommandOutcome): RunVerdict {
|
|
273
507
|
if (run.outcome === 'failed') {
|
|
274
508
|
return undeterminable(`the platform's container check did not run (${run.reason})`, true)
|
|
275
509
|
}
|
|
@@ -397,8 +631,7 @@ function describeThrown(err: unknown): string {
|
|
|
397
631
|
}
|
|
398
632
|
|
|
399
633
|
function bounded(text: string): string {
|
|
400
|
-
|
|
401
|
-
return scrubbed.length > DETAIL_CHARS ? `${scrubbed.slice(0, DETAIL_CHARS)}…` : scrubbed
|
|
634
|
+
return scrubbedExcerpt(text, DETAIL_CHARS)
|
|
402
635
|
}
|
|
403
636
|
|
|
404
637
|
/**
|
|
@@ -429,6 +662,17 @@ interface Measurement {
|
|
|
429
662
|
* the whole container into saying so. Re-measuring a negative is cheap; a daemon that cannot
|
|
430
663
|
* mount fails at once.
|
|
431
664
|
*
|
|
665
|
+
* A `usable` verdict whose EGRESS could not be determined is re-measured on the same rule and for
|
|
666
|
+
* the same reason. Whether the bridge is NATed is settled once and for the daemon's life, so a
|
|
667
|
+
* measured `reachable` or `blocked` is kept; a check that timed out measured nothing, and latching
|
|
668
|
+
* that would leave the container permanently unable to say which of the two it is.
|
|
669
|
+
*
|
|
670
|
+
* But only where asking again could ANSWER differently, which is what `ContainerEgress.recheck`
|
|
671
|
+
* carries. Most ways to reach `undetermined` are standing facts about this container: a rejected
|
|
672
|
+
* target setting, a payload with no `nc`, an address the deployment filters. Re-running the whole
|
|
673
|
+
* measurement on those never converges, and it is not cheap: it is `docker version`, the archive,
|
|
674
|
+
* `docker load`, two container starts and an `image rm`, per job, ahead of the clone.
|
|
675
|
+
*
|
|
432
676
|
* Concurrent callers share one in-flight measurement rather than each starting a container, and
|
|
433
677
|
* the measurement is cancelled when the LAST of them has abandoned it. Neither half is optional:
|
|
434
678
|
* one job's abort may not kill a measurement a sibling job is still waiting on (the local native
|
|
@@ -455,7 +699,7 @@ export function createDockerWorkloadProbe(
|
|
|
455
699
|
return measurement
|
|
456
700
|
}
|
|
457
701
|
const probe = (async (signal?: AbortSignal): Promise<DockerWorkload> => {
|
|
458
|
-
if (latest?.status === 'usable') return latest
|
|
702
|
+
if (latest?.status === 'usable' && !isWorthReMeasuring(latest.egress)) return latest
|
|
459
703
|
const measurement = (inFlight ??= begin())
|
|
460
704
|
measurement.waiters += 1
|
|
461
705
|
const watch = signal ? watchAbandonment(signal) : undefined
|
|
@@ -473,6 +717,11 @@ export function createDockerWorkloadProbe(
|
|
|
473
717
|
return probe
|
|
474
718
|
}
|
|
475
719
|
|
|
720
|
+
/** Whether a kept verdict's egress half is one that asking again could still settle. */
|
|
721
|
+
function isWorthReMeasuring(egress: ContainerEgress): boolean {
|
|
722
|
+
return egress.status === 'undetermined' && egress.recheck
|
|
723
|
+
}
|
|
724
|
+
|
|
476
725
|
/**
|
|
477
726
|
* A verdict for the caller whose job was cancelled while it waited, and the listener teardown
|
|
478
727
|
* that keeps a long-lived native-transport process from accumulating one per job.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
+
import { scrubbedExcerpt } from './redact.js'
|
|
2
3
|
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
4
5
|
// The one-container image the platform runs to find out whether this machine's Docker daemon
|
|
@@ -45,6 +46,162 @@ export const PROBE_SENTINEL = 'cat-factory-docker-probe-ok'
|
|
|
45
46
|
/** The argv the probe container runs. `busybox` dispatches on its own name, so this is an echo. */
|
|
46
47
|
export const PROBE_COMMAND: readonly string[] = [`/${PROBE_BINARY_PATH}`, 'echo', PROBE_SENTINEL]
|
|
47
48
|
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// The second thing the same image is asked, and the one the marker run above structurally cannot
|
|
51
|
+
// answer: whether a container started on this daemon can reach the NETWORK.
|
|
52
|
+
//
|
|
53
|
+
// Loading and running a local image needs no network at all, so a daemon whose nested containers
|
|
54
|
+
// are cut off passes the marker run exactly as a working one does. That is not hypothetical: the
|
|
55
|
+
// published executor image ran its rootless daemon with `--iptables=false`, which drops the
|
|
56
|
+
// MASQUERADE rule for the bridge, and every nested container on it had no egress whatsoever
|
|
57
|
+
// (issue #2173). The harness reported `dockerDaemon: "usable"` throughout, and each agent
|
|
58
|
+
// discovered otherwise about seven minutes into an `npm ci` inside a `docker build` (issue
|
|
59
|
+
// #2174). An agent TOLD it has no egress can plan around it; an agent told docker works cannot.
|
|
60
|
+
//
|
|
61
|
+
// It runs as its own container rather than as one more command in the marker run, because the
|
|
62
|
+
// marker run is deliberately `--network none`. The two need opposite networking, so they cannot
|
|
63
|
+
// be the same `docker run`, and keeping them apart has a second payoff: a failure of anything
|
|
64
|
+
// below can only ever produce an EGRESS verdict, never a verdict about the daemon.
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What the egress container prints for each observation: the marker, then the exit STATUS of the
|
|
69
|
+
* command that made it.
|
|
70
|
+
*
|
|
71
|
+
* The status rather than a pass/fail marker, because the two failures need different answers.
|
|
72
|
+
* A refused connection is evidence about the network; a 127 is busybox saying it has no such
|
|
73
|
+
* applet, which is evidence about the platform's own probe image and may never be reported as a
|
|
74
|
+
* network that is not there.
|
|
75
|
+
*/
|
|
76
|
+
export const EGRESS_TCP_MARKER = 'cat-factory-egress-tcp='
|
|
77
|
+
export const EGRESS_DNS_MARKER = 'cat-factory-egress-dns='
|
|
78
|
+
|
|
79
|
+
/** How long the in-container connect may take. Short: a blocked route is silent, not slow. */
|
|
80
|
+
const EGRESS_CONNECT_TIMEOUT_SECONDS = 3
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* How long busybox is given to print its own `nc` usage, for the capability check below. Bounded
|
|
84
|
+
* like everything else in that container: an applet that somehow blocks may not take the budget
|
|
85
|
+
* of the measurement it is only a preamble to.
|
|
86
|
+
*/
|
|
87
|
+
const EGRESS_USAGE_TIMEOUT_SECONDS = 2
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* How long the in-container lookup may take. Its own ceiling because busybox's `nslookup` retries
|
|
91
|
+
* on its own schedule, and an unbounded one would spend the whole check's budget on the half that
|
|
92
|
+
* is the diagnostic rather than the verdict.
|
|
93
|
+
*/
|
|
94
|
+
const EGRESS_LOOKUP_TIMEOUT_SECONDS = 6
|
|
95
|
+
|
|
96
|
+
/** Where the egress check aims: a raw address, plus a name to resolve. */
|
|
97
|
+
export interface EgressTarget {
|
|
98
|
+
host: string
|
|
99
|
+
port: number
|
|
100
|
+
dnsName: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* How much of a rejected setting is quoted back. Enough to recognise which value was refused,
|
|
105
|
+
* short of letting a pasted blob be most of an agent's system prompt.
|
|
106
|
+
*/
|
|
107
|
+
const SETTING_CHARS = 60
|
|
108
|
+
|
|
109
|
+
/** An IPv4 literal. Names are refused on purpose: a target that needs DNS cannot TEST DNS. */
|
|
110
|
+
const IPV4 =
|
|
111
|
+
/^(?:(?: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])$/
|
|
112
|
+
|
|
113
|
+
/** A hostname, in the narrow shape a DNS lookup can be aimed at. */
|
|
114
|
+
const HOSTNAME = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read the configured target, or say why it cannot be used.
|
|
118
|
+
*
|
|
119
|
+
* Validated rather than trusted, and strictly, for two reasons that both matter. The host and the
|
|
120
|
+
* name are interpolated into a `sh -c` script INSIDE the probe container, so anything else there
|
|
121
|
+
* would be running whatever a deployment's environment happened to hold; and a target that is
|
|
122
|
+
* quietly wrong produces a confident `blocked` about a daemon that is fine, which is the exact
|
|
123
|
+
* class of lie this whole module exists to remove. A rejected setting is REPORTED as a check that
|
|
124
|
+
* could not be carried out, never silently swapped for the default: an operator who pointed this
|
|
125
|
+
* at an address their network permits is entitled to find out that it was ignored.
|
|
126
|
+
*/
|
|
127
|
+
export function parseEgressTarget(
|
|
128
|
+
target: string,
|
|
129
|
+
dnsName: string,
|
|
130
|
+
): { target: EgressTarget } | { invalid: string } {
|
|
131
|
+
const [host = '', port = '', ...rest] = target.split(':')
|
|
132
|
+
if (rest.length > 0 || !IPV4.test(host)) {
|
|
133
|
+
return {
|
|
134
|
+
invalid: `the platform's egress check is configured with \`${scrubbedExcerpt(target, SETTING_CHARS)}\`, which is not an \`IPv4:port\` address`,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const parsed = Number(port)
|
|
138
|
+
if (!/^[0-9]{1,5}$/.test(port) || parsed < 1 || parsed > 65535) {
|
|
139
|
+
return {
|
|
140
|
+
invalid: `the platform's egress check is configured with \`${scrubbedExcerpt(target, SETTING_CHARS)}\`, whose port is not a number between 1 and 65535`,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!HOSTNAME.test(dnsName)) {
|
|
144
|
+
return {
|
|
145
|
+
invalid: `the platform's egress check is configured to resolve \`${scrubbedExcerpt(dnsName, SETTING_CHARS)}\`, which is not a hostname`,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { target: { host, port: parsed, dnsName } }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The argv the egress container runs: connect, say what that returned, resolve, say the same.
|
|
153
|
+
*
|
|
154
|
+
* Both observations are made and BOTH are reported, because they fail for different reasons and
|
|
155
|
+
* have different fixes. A connect to a raw address needs only a route; a lookup needs the
|
|
156
|
+
* daemon's embedded resolver to be reachable and to forward. Reporting only the first would call
|
|
157
|
+
* a container with a working route and broken DNS "reachable", and nothing an agent fetches by
|
|
158
|
+
* name would work there.
|
|
159
|
+
*
|
|
160
|
+
* Every applet is called by its full path (`/busybox nc`) rather than by name. The image holds
|
|
161
|
+
* one file and no PATH, and busybox's standalone-shell dispatch is a build-time option nothing
|
|
162
|
+
* here may assume.
|
|
163
|
+
*
|
|
164
|
+
* The connect is the half that is easy to get silently wrong, and `nc -w SEC` alone gets it
|
|
165
|
+
* wrong. busybox documents that flag as the timeout for connects AND FINAL NET READS: once stdin
|
|
166
|
+
* hits EOF `nc` half-closes and then waits to be spoken to, so a connect that SUCCEEDED to a peer
|
|
167
|
+
* which expects the client to speak first (every TLS port, the default `1.1.1.1:443` included)
|
|
168
|
+
* hits the alarm and exits non-zero. Read off the exit status alone that is indistinguishable
|
|
169
|
+
* from a refusal, so a working network reports a route that is not there. `-z` means "connect,
|
|
170
|
+
* then stop", which is the question being asked, so it is used wherever the payload's busybox was
|
|
171
|
+
* built with it; where it was not, the connect runs with no `-w`, which is the build whose `nc`
|
|
172
|
+
* exits on its own when stdin closes.
|
|
173
|
+
*
|
|
174
|
+
* Both halves are wrapped in `${busybox} timeout` either way, since a blackholed route is silent
|
|
175
|
+
* rather than refused and the applet's own ceiling is the thing this comment exists because we
|
|
176
|
+
* cannot assume.
|
|
177
|
+
*/
|
|
178
|
+
export function buildEgressCommand(target: EgressTarget): readonly string[] {
|
|
179
|
+
const busybox = `/${PROBE_BINARY_PATH}`
|
|
180
|
+
const bounded = (seconds: number, command: string): string =>
|
|
181
|
+
`${busybox} timeout ${seconds} ${command}`
|
|
182
|
+
const where = `${target.host} ${target.port}`
|
|
183
|
+
const connectSeconds = EGRESS_CONNECT_TIMEOUT_SECONDS + 1
|
|
184
|
+
const connect = [
|
|
185
|
+
'nc_z=no',
|
|
186
|
+
`case "$(${bounded(EGRESS_USAGE_TIMEOUT_SECONDS, `${busybox} nc`)} 2>&1)" in *-z*) nc_z=yes ;; esac`,
|
|
187
|
+
'if [ "$nc_z" = yes ]; then',
|
|
188
|
+
` ${bounded(connectSeconds, `${busybox} nc -w ${EGRESS_CONNECT_TIMEOUT_SECONDS} -z ${where}`)} >/dev/null 2>&1`,
|
|
189
|
+
'else',
|
|
190
|
+
` ${bounded(connectSeconds, `${busybox} nc ${where}`)} </dev/null >/dev/null 2>&1`,
|
|
191
|
+
'fi',
|
|
192
|
+
].join('\n')
|
|
193
|
+
const resolve = bounded(EGRESS_LOOKUP_TIMEOUT_SECONDS, `${busybox} nslookup ${target.dnsName}`)
|
|
194
|
+
return [
|
|
195
|
+
busybox,
|
|
196
|
+
'sh',
|
|
197
|
+
'-c',
|
|
198
|
+
[
|
|
199
|
+
`${connect}\necho "${EGRESS_TCP_MARKER}$?"`,
|
|
200
|
+
`${resolve} >/dev/null 2>&1; echo "${EGRESS_DNS_MARKER}$?"`,
|
|
201
|
+
].join('\n'),
|
|
202
|
+
]
|
|
203
|
+
}
|
|
204
|
+
|
|
48
205
|
/**
|
|
49
206
|
* Node's architecture names mapped onto the docker name for the same machine.
|
|
50
207
|
*
|
package/src/docker-status.ts
CHANGED
|
@@ -59,7 +59,20 @@ export type DockerSource =
|
|
|
59
59
|
export interface DockerStatus {
|
|
60
60
|
available: boolean | undefined
|
|
61
61
|
source: DockerSource
|
|
62
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Why, in the entrypoint's own closed vocabulary (`serving`, `serving-without-nat`,
|
|
64
|
+
* `still-starting`, `failed`, `missing`, `unreachable`, `probing`).
|
|
65
|
+
*
|
|
66
|
+
* Reported and never branched on, which is what lets the entrypoint add a word without anything
|
|
67
|
+
* here having to know it. Two are worth knowing about. `serving-without-nat`: the daemon that
|
|
68
|
+
* manages its own firewall rules exited without serving, so the one that came up runs with
|
|
69
|
+
* `--iptables=false` and its NESTED containers have no egress. That is a CAUSE, and the only
|
|
70
|
+
* place one exists; what MEASURES the consequence is the egress half of the workload check
|
|
71
|
+
* (docker-capability.ts), from inside a container, with no way to learn why. `still-starting`
|
|
72
|
+
* is a daemon that had not answered when the boot budget ran out and is STILL RUNNING, which is
|
|
73
|
+
* the one absence that routinely stops being true: it is exactly what the live re-probe below
|
|
74
|
+
* exists to catch.
|
|
75
|
+
*/
|
|
63
76
|
reason: string
|
|
64
77
|
/** A human detail for the failing cases: the dockerd log tail, or what was unreachable. */
|
|
65
78
|
detail?: string
|