@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.
@@ -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 { redactSecrets } from './redact.js'
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 (${redactSecrets(daemonArch).slice(0, 40) || 'it answered nothing'})`,
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
- // Before the verdict, and whatever the verdict is: the probe image is the platform's, and an
256
- // agent that runs `docker images` should not have to wonder whose it is.
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 classifyRun(run)
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
- function classifyRun(run: CommandOutcome): DockerWorkload {
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
- const scrubbed = redactSecrets(text)
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
  *
@@ -59,7 +59,20 @@ export type DockerSource =
59
59
  export interface DockerStatus {
60
60
  available: boolean | undefined
61
61
  source: DockerSource
62
- /** Why, in the entrypoint's own closed vocabulary (`serving`/`failed`/`missing`/…). */
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