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