@bakers-build/addon-docker-compose 0.0.0-stage → 0.1.0-next.42

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 keytec GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @bakers-build/addon-docker-compose
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Generic Docker Compose discovery: containers, bound ports, in-container sockets and supervisor status for a worktree — no DDEV knowledge.
4
+
5
+ Part of the bakers.build CLI. Install the meta package `@bakers-build/bakers` to get the `bakers` CLI with all addons. Source: https://git.key-tec.de/bakers-build/bakers-build (bakers-cli/).
@@ -0,0 +1,130 @@
1
+ /** The injected seam — only `run`, since this package never reads `/proc`
2
+ * itself (that is `addon-ports`' own host-listener discovery). `stderr` is
3
+ * optional and only consulted to split an exit-126 `supervisorctl` failure
4
+ * (BB-681 Task 1): "command not found" (missing executable — no diagnostic,
5
+ * same as exit 127) versus "permission denied" (a real failure, still
6
+ * diagnostic) — the general exec behavior is otherwise unchanged. */
7
+ export interface DockerComposeDeps {
8
+ run(cmd: string, args: string[], cwd?: string): Promise<{
9
+ stdout: string;
10
+ ok: boolean;
11
+ exitCode?: number;
12
+ stderr?: string;
13
+ }>;
14
+ }
15
+ export interface ComposeDiagnostic {
16
+ readonly source: 'docker' | 'web-sockets' | 'supervisor';
17
+ readonly message: string;
18
+ /** D: which container this diagnostic is about, when it is about one in
19
+ * particular (a per-container probe failure) — absent for a whole-pass
20
+ * failure (`docker ps`/`docker inspect` itself failing) that genuinely
21
+ * blinds every container at once. A reap must only withhold a record
22
+ * whose OWN container produced unresolved evidence, not every record in
23
+ * the worktree just because one unrelated container had a probe gap. */
24
+ readonly containerId?: string;
25
+ }
26
+ /** A confirmed-listening socket inside a container, with its supervisor
27
+ * daemon attached when the pid correlation below found one. */
28
+ export interface ComposeSocket {
29
+ readonly containerPort: number;
30
+ /** The bind address `ss`/`/proc/net/tcp` reported — `127.0.0.11` is
31
+ * Docker's own embedded DNS resolver stub inside every container's
32
+ * network namespace, never an operator-addressable service; a consumer
33
+ * (`listeningSibling`, `@bakers-build/addon-ddev`) excludes it when picking a
34
+ * diagnostic "what IS listening" candidate for a dead claim. */
35
+ readonly address?: string;
36
+ readonly pid?: number;
37
+ readonly daemon?: string;
38
+ readonly daemonStatus?: 'RUNNING' | 'STOPPED' | 'EXITED' | 'UNKNOWN';
39
+ }
40
+ /** A supervisor-managed daemon with no live socket to attach to (typically
41
+ * STOPPED/EXITED) — distinct from "exposed but nothing is listening". */
42
+ export interface ComposeUnattachedDaemon {
43
+ readonly daemon: string;
44
+ readonly status: 'RUNNING' | 'STOPPED' | 'EXITED' | 'UNKNOWN';
45
+ }
46
+ export interface ComposeContainer {
47
+ readonly id: string;
48
+ readonly running: boolean;
49
+ readonly labels: Readonly<Record<string, string>>;
50
+ readonly env: Readonly<Record<string, string>>;
51
+ /** `NetworkSettings.Ports`' tcp entries, container port → bound host port. */
52
+ readonly boundPorts: ReadonlyMap<number, number>;
53
+ /** `NetworkSettings.Networks`, network name → non-empty `IPAddress`. */
54
+ readonly networks: ReadonlyMap<string, string>;
55
+ /** `false` when the in-container socket probe itself failed (e.g. no `ss`
56
+ * in a minimal image) — `sockets` is then NOT proof of "nothing is
57
+ * listening", only that nothing could be confirmed listening; a consumer
58
+ * must not read an empty `sockets` as "dead" when this is `false`. */
59
+ readonly socketsProbed: boolean;
60
+ readonly sockets: readonly ComposeSocket[];
61
+ readonly unattachedDaemons: readonly ComposeUnattachedDaemon[];
62
+ }
63
+ export interface ComposeDiscovery {
64
+ readonly containers: readonly ComposeContainer[];
65
+ readonly diagnostics: readonly ComposeDiagnostic[];
66
+ }
67
+ /** `docker ps -a --format '{{.ID}}'` — one id per line. */
68
+ export declare function parseDockerPsIds(text: string): string[];
69
+ interface ContainerInspect {
70
+ readonly id: string;
71
+ readonly running: boolean;
72
+ readonly labels: Readonly<Record<string, string>>;
73
+ readonly env: Readonly<Record<string, string>>;
74
+ readonly boundPorts: ReadonlyMap<number, number>;
75
+ readonly networks: ReadonlyMap<string, string>;
76
+ }
77
+ /**
78
+ * `docker inspect <ids…>` — a JSON array, one entry per container. Malformed
79
+ * input (or an entry missing `Id`) is dropped rather than thrown, so one bad
80
+ * record does not blank the whole batch.
81
+ */
82
+ export declare function parseDockerInspect(text: string): ContainerInspect[];
83
+ /** One listening socket `ss -ltnp` reported. `pid`/`command` are absent for a
84
+ * row with no process column (no permission to see the owner, or a root-
85
+ * owned listener an unprivileged `docker exec` cannot resolve) — still real
86
+ * listening evidence, just with an unknown owner. */
87
+ export interface SocketListener {
88
+ readonly address: string;
89
+ readonly port: number;
90
+ readonly command?: string;
91
+ readonly pid?: number;
92
+ }
93
+ /**
94
+ * Parse `ss -ltnp` output into listeners.
95
+ *
96
+ * The line's shape is `LISTEN q q <addr>:<port> <peer> users:(("cmd",pid=N,fd=…))`.
97
+ * The address may be `127.0.0.1`, `0.0.0.0`, `*`, or an IPv6 form in brackets, so
98
+ * the port is read as the last `:`-separated field of the local-address column
99
+ * and everything before it is the bind address. A row with no process column
100
+ * (no permission, or a kernel socket) is KEPT — it is still listening
101
+ * evidence, just with no pid to correlate a supervisor daemon against.
102
+ */
103
+ export declare function parseSocketListeners(text: string): SocketListener[];
104
+ export declare function parseProcNetTcp(text: string): SocketListener[];
105
+ /** `ps -eo pid,ppid --no-headers` → child pid to parent pid, for walking a
106
+ * socket's process up to the supervisor-managed ancestor that owns it: a
107
+ * supervisor daemon's reported pid is its own forked process, not
108
+ * necessarily the child that actually binds the socket. */
109
+ export declare function parsePidParents(text: string): Map<number, number>;
110
+ interface SupervisorEntry {
111
+ readonly daemon: string;
112
+ readonly status: 'RUNNING' | 'STOPPED' | 'EXITED' | 'UNKNOWN';
113
+ readonly pid?: number;
114
+ }
115
+ /** `supervisorctl status` — `group:name STATUS [pid N, uptime …]`. */
116
+ export declare function parseSupervisorStatus(text: string): SupervisorEntry[];
117
+ /**
118
+ * Discover this worktree's Docker Compose containers: `docker ps -a` (label
119
+ * filters supplied by the caller — `com.docker.compose.project.working_dir`
120
+ * is always included, since that is the label Compose itself sets; a
121
+ * DDEV-flavored caller passes `extraLabelFilters: ['com.ddev.approot']` to
122
+ * union DDEV's own worktree label, without this package knowing what that
123
+ * label means), `docker inspect`, and one bounded per-running-container
124
+ * `docker exec` (socket `ss -ltnp` + `supervisorctl status`, run concurrently
125
+ * across containers, pid-correlated).
126
+ */
127
+ export declare function discoverCompose(worktree: string, deps: DockerComposeDeps, opts?: {
128
+ readonly extraLabelFilters?: readonly string[];
129
+ }): Promise<ComposeDiscovery>;
130
+ export {};
@@ -0,0 +1,440 @@
1
+ // BB-676 spec v2 — generic Docker Compose discovery, no DDEV knowledge.
2
+ //
3
+ // Split out of `@bakers-build/addon-ports` (operator decision 2026-10-15: "ddev
4
+ // darf nicht im ports dabei sein, zumindest wenn es direkt ddev calls
5
+ // abfeuert") so firing `docker` is not that package's job. This package knows
6
+ // nothing about `HTTP(S)_EXPOSE`, `VIRTUAL_HOST` or any other DDEV
7
+ // convention — it reports containers, their labels/env/bound ports, and the
8
+ // confirmed in-container sockets + supervisor daemons, correlated by pid.
9
+ // Interpreting what those mean (a DDEV router mapping, a Drupal site, …) is a
10
+ // consumer's job — see `@bakers-build/addon-ddev`.
11
+ /** Exit 126 is POSIX "found but not executable" OR (many shells/`docker
12
+ * exec`) "command not found" — both reuse 126 instead of 127 in practice.
13
+ * Only a stderr phrase that clearly says "permission denied" counts as the
14
+ * real failure; anything else (including no stderr at all) is treated like
15
+ * a missing binary, so an engine that returns 126 for an absent
16
+ * `supervisorctl` never blocks a no-claim reap. */
17
+ function isPermissionDenied126(stderr) {
18
+ if (!stderr)
19
+ return false;
20
+ return /permission denied/i.test(stderr);
21
+ }
22
+ /** `docker ps -a --format '{{.ID}}'` — one id per line. */
23
+ export function parseDockerPsIds(text) {
24
+ return text
25
+ .split('\n')
26
+ .map((line) => line.trim())
27
+ .filter(Boolean);
28
+ }
29
+ /**
30
+ * `docker inspect <ids…>` — a JSON array, one entry per container. Malformed
31
+ * input (or an entry missing `Id`) is dropped rather than thrown, so one bad
32
+ * record does not blank the whole batch.
33
+ */
34
+ export function parseDockerInspect(text) {
35
+ let parsed;
36
+ try {
37
+ parsed = JSON.parse(text);
38
+ }
39
+ catch {
40
+ return [];
41
+ }
42
+ if (!Array.isArray(parsed))
43
+ return [];
44
+ const out = [];
45
+ for (const entry of parsed) {
46
+ const e = entry;
47
+ const id = e.Id;
48
+ if (typeof id !== 'string')
49
+ continue;
50
+ const state = e.State;
51
+ const config = e.Config;
52
+ const labels = config?.Labels ?? {};
53
+ const envList = Array.isArray(config?.Env) ? config.Env : [];
54
+ const env = {};
55
+ for (const line of envList) {
56
+ const eq = line.indexOf('=');
57
+ if (eq === -1)
58
+ continue;
59
+ env[line.slice(0, eq)] = line.slice(eq + 1);
60
+ }
61
+ const boundPorts = new Map();
62
+ const networkSettings = e.NetworkSettings;
63
+ const portsMap = networkSettings?.Ports;
64
+ for (const [key, bindings] of Object.entries(portsMap ?? {})) {
65
+ const [portStr, proto] = key.split('/');
66
+ if (proto !== 'tcp' || !bindings || bindings.length === 0)
67
+ continue;
68
+ const containerPort = Number(portStr);
69
+ const hostPort = Number(bindings[0]?.HostPort);
70
+ if (Number.isInteger(containerPort) && Number.isInteger(hostPort)) {
71
+ boundPorts.set(containerPort, hostPort);
72
+ }
73
+ }
74
+ const networks = new Map();
75
+ const networksMap = networkSettings?.Networks;
76
+ for (const [name, network] of Object.entries(networksMap ?? {})) {
77
+ const ip = network?.IPAddress;
78
+ if (typeof ip === 'string' && ip !== '')
79
+ networks.set(name, ip);
80
+ }
81
+ out.push({
82
+ id,
83
+ running: state?.Running === true,
84
+ labels,
85
+ env,
86
+ boundPorts,
87
+ networks,
88
+ });
89
+ }
90
+ return out;
91
+ }
92
+ /**
93
+ * Parse `ss -ltnp` output into listeners.
94
+ *
95
+ * The line's shape is `LISTEN q q <addr>:<port> <peer> users:(("cmd",pid=N,fd=…))`.
96
+ * The address may be `127.0.0.1`, `0.0.0.0`, `*`, or an IPv6 form in brackets, so
97
+ * the port is read as the last `:`-separated field of the local-address column
98
+ * and everything before it is the bind address. A row with no process column
99
+ * (no permission, or a kernel socket) is KEPT — it is still listening
100
+ * evidence, just with no pid to correlate a supervisor daemon against.
101
+ */
102
+ export function parseSocketListeners(text) {
103
+ const listeners = [];
104
+ for (const line of text.split('\n')) {
105
+ if (!line.startsWith('LISTEN'))
106
+ continue;
107
+ const columns = line.trim().split(/\s+/);
108
+ const local = columns[3] ?? '';
109
+ const sep = local.lastIndexOf(':');
110
+ if (sep === -1)
111
+ continue;
112
+ const port = Number(local.slice(sep + 1));
113
+ if (!Number.isInteger(port))
114
+ continue;
115
+ const address = local.slice(0, sep).replace(/^\[|\]$/g, '');
116
+ const process = /\(\("([^"]+)",pid=(\d+)/.exec(line);
117
+ listeners.push({
118
+ address,
119
+ port,
120
+ ...(process
121
+ ? { command: process[1] ?? '', pid: Number(process[2]) }
122
+ : {}),
123
+ });
124
+ }
125
+ return listeners;
126
+ }
127
+ /** C: fallback when `ss` is missing/fails entirely inside the container
128
+ * (exit 127, not "ran, found nothing") — images like phpMyAdmin/OpenSearch
129
+ * ship neither `ss` nor `netstat`. Parse `/proc/net/tcp`(6) directly: each
130
+ * data line's 2nd column is `local_address:port_hex`, the 4th column is
131
+ * connection state, `0A` hex = LISTEN. This reads the kernel's own listener
132
+ * table — strictly real listening evidence, unlike trusting a published
133
+ * host port binding (Docker binds a host port whether or not anything
134
+ * inside listens). */
135
+ /** `/proc/net/tcp`'s IPv4 address column is 8 hex chars, little-endian byte
136
+ * order — `0B00007F` is `127.0.0.11`, not `11.0.0.127`. A 32-char IPv6 form
137
+ * is returned as-is (comparisons against it are not needed here). */
138
+ function decodeProcNetAddress(hex) {
139
+ if (hex.length !== 8)
140
+ return hex;
141
+ const bytes = [0, 2, 4, 6].map((i) => Number.parseInt(hex.slice(i, i + 2), 16));
142
+ return bytes.reverse().join('.');
143
+ }
144
+ export function parseProcNetTcp(text) {
145
+ const listeners = [];
146
+ const lines = text.split('\n').slice(1); // header row
147
+ for (const line of lines) {
148
+ const cols = line.trim().split(/\s+/);
149
+ const localAddress = cols[1];
150
+ const state = cols[3];
151
+ if (!localAddress || state !== '0A')
152
+ continue;
153
+ const sep = localAddress.lastIndexOf(':');
154
+ if (sep === -1)
155
+ continue;
156
+ const port = Number.parseInt(localAddress.slice(sep + 1), 16);
157
+ if (!Number.isInteger(port))
158
+ continue;
159
+ listeners.push({
160
+ address: decodeProcNetAddress(localAddress.slice(0, sep)),
161
+ port,
162
+ });
163
+ }
164
+ return listeners;
165
+ }
166
+ /** `ps -eo pid,ppid --no-headers` → child pid to parent pid, for walking a
167
+ * socket's process up to the supervisor-managed ancestor that owns it: a
168
+ * supervisor daemon's reported pid is its own forked process, not
169
+ * necessarily the child that actually binds the socket. */
170
+ export function parsePidParents(text) {
171
+ const out = new Map();
172
+ for (const line of text.split('\n')) {
173
+ const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
174
+ if (!match)
175
+ continue;
176
+ out.set(Number(match[1]), Number(match[2]));
177
+ }
178
+ return out;
179
+ }
180
+ /** Is `ancestorPid` the supervisor pid, or an ancestor of it, up to a
181
+ * bounded depth? Protects against a cyclic/malformed parent map. */
182
+ function isAncestor(parents, pid, ancestorPid) {
183
+ let current = pid;
184
+ for (let hop = 0; hop < 16 && current !== undefined; hop += 1) {
185
+ if (current === ancestorPid)
186
+ return true;
187
+ current = parents.get(current);
188
+ }
189
+ return false;
190
+ }
191
+ /** `supervisorctl status` — `group:name STATUS [pid N, uptime …]`. */
192
+ export function parseSupervisorStatus(text) {
193
+ const out = [];
194
+ for (const line of text.split('\n')) {
195
+ const trimmed = line.trim();
196
+ const match = /^(\S+)\s+(RUNNING|STOPPED|EXITED|STARTING|BACKOFF|FATAL)\b(.*)$/.exec(trimmed);
197
+ if (!match)
198
+ continue;
199
+ const status = match[2] === 'RUNNING' || match[2] === 'STOPPED' || match[2] === 'EXITED'
200
+ ? match[2]
201
+ : 'UNKNOWN';
202
+ const pid = /pid (\d+)/.exec(match[3] ?? '');
203
+ out.push({
204
+ daemon: match[1] ?? '',
205
+ status,
206
+ ...(pid ? { pid: Number(pid[1]) } : {}),
207
+ });
208
+ }
209
+ return out;
210
+ }
211
+ /**
212
+ * Discover this worktree's Docker Compose containers: `docker ps -a` (label
213
+ * filters supplied by the caller — `com.docker.compose.project.working_dir`
214
+ * is always included, since that is the label Compose itself sets; a
215
+ * DDEV-flavored caller passes `extraLabelFilters: ['com.ddev.approot']` to
216
+ * union DDEV's own worktree label, without this package knowing what that
217
+ * label means), `docker inspect`, and one bounded per-running-container
218
+ * `docker exec` (socket `ss -ltnp` + `supervisorctl status`, run concurrently
219
+ * across containers, pid-correlated).
220
+ */
221
+ export async function discoverCompose(worktree, deps, opts) {
222
+ const diagnostics = [];
223
+ const labels = [
224
+ 'com.docker.compose.project.working_dir',
225
+ ...(opts?.extraLabelFilters ?? []),
226
+ ];
227
+ const psResults = await Promise.all(labels.map((label) => deps.run('docker', [
228
+ 'ps',
229
+ '-a',
230
+ '--filter',
231
+ `label=${label}=${worktree}`,
232
+ '--format',
233
+ '{{.ID}}',
234
+ ])));
235
+ if (psResults.every((r) => !r.ok)) {
236
+ diagnostics.push({
237
+ source: 'docker',
238
+ message: '`docker ps` failed — container discovery is unknown, not empty',
239
+ });
240
+ return { containers: [], diagnostics };
241
+ }
242
+ const ids = [
243
+ ...new Set(psResults.flatMap((r) => (r.ok ? parseDockerPsIds(r.stdout) : []))),
244
+ ];
245
+ if (ids.length === 0)
246
+ return { containers: [], diagnostics };
247
+ const inspect = await deps.run('docker', ['inspect', ...ids]);
248
+ if (!inspect.ok) {
249
+ diagnostics.push({ source: 'docker', message: '`docker inspect` failed' });
250
+ return { containers: [], diagnostics };
251
+ }
252
+ const inspected = parseDockerInspect(inspect.stdout);
253
+ const perContainer = await Promise.all(inspected.map(async (c) => {
254
+ const containerDiagnostics = [];
255
+ if (!c.running) {
256
+ return {
257
+ container: {
258
+ ...c,
259
+ socketsProbed: true,
260
+ sockets: [],
261
+ unattachedDaemons: [],
262
+ },
263
+ diagnostics: containerDiagnostics,
264
+ };
265
+ }
266
+ const [socketsRes, pidTreeRes, supervisorRes] = await Promise.all([
267
+ deps.run('docker', ['exec', c.id, 'ss', '-ltnp']),
268
+ deps.run('docker', [
269
+ 'exec',
270
+ c.id,
271
+ 'ps',
272
+ '-eo',
273
+ 'pid,ppid',
274
+ '--no-headers',
275
+ ]),
276
+ deps.run('docker', ['exec', c.id, 'supervisorctl', 'status']),
277
+ ]);
278
+ // B: `supervisorctl status` exits 3 (LSB NOT_RUNNING) whenever ANY
279
+ // managed program is STOPPED/EXITED/FATAL — not just when the whole
280
+ // call failed. Its stdout still lists every program's status in that
281
+ // case, so parse it the same as exit 0; only a genuine failure
282
+ // (binary missing, timeout, unreachable container) withholds evidence.
283
+ const supervisorHasDaemons = supervisorRes.ok || supervisorRes.exitCode === 3;
284
+ const parents = pidTreeRes.ok
285
+ ? parsePidParents(pidTreeRes.stdout)
286
+ : new Map();
287
+ const sockets = [];
288
+ // R6: a daemon's reported pid can own more than one listening socket
289
+ // (and `ss`'s own row order is not guaranteed) — a single-port map
290
+ // made which port actually got the daemon attached depend on which
291
+ // line `ss` printed first. Keep every port a pid owns.
292
+ const pidToPort = new Map();
293
+ let socketsProbed = socketsRes.ok;
294
+ if (socketsRes.ok) {
295
+ for (const listener of parseSocketListeners(socketsRes.stdout)) {
296
+ // Docker's embedded DNS resolver (127.0.0.11) listens inside
297
+ // every container's netns on an ephemeral port — never a service
298
+ // an operator or a dead-port diagnostic should ever name.
299
+ if (listener.address === '127.0.0.11')
300
+ continue;
301
+ // A real in-container `ss -ltnpH` run as an unprivileged uid often
302
+ // cannot see the PID of a root-owned listener (nginx on 80/443) —
303
+ // the socket line itself still proves it is listening.
304
+ sockets.push({
305
+ containerPort: listener.port,
306
+ address: listener.address,
307
+ });
308
+ if (listener.pid !== undefined) {
309
+ const ports = pidToPort.get(listener.pid) ?? [];
310
+ ports.push(listener.port);
311
+ pidToPort.set(listener.pid, ports);
312
+ }
313
+ }
314
+ }
315
+ else {
316
+ // C: `ss` may simply not be installed at all (exit 127) — images
317
+ // like phpMyAdmin/OpenSearch ship neither `ss` nor `netstat` AND
318
+ // have no published host binding (Traefik-only routing), so they
319
+ // would otherwise stay `unknown` forever. Read the kernel's own
320
+ // listener table directly instead of trusting any host binding
321
+ // (Docker binds a host port whether or not anything listens).
322
+ const [tcp, tcp6] = await Promise.all([
323
+ deps.run('docker', ['exec', c.id, 'cat', '/proc/net/tcp']),
324
+ deps.run('docker', ['exec', c.id, 'cat', '/proc/net/tcp6']),
325
+ ]);
326
+ if (tcp.ok || tcp6.ok) {
327
+ socketsProbed = true;
328
+ const seen = new Set();
329
+ for (const out of [tcp, tcp6]) {
330
+ if (!out.ok)
331
+ continue;
332
+ for (const listener of parseProcNetTcp(out.stdout)) {
333
+ if (listener.address === '127.0.0.11')
334
+ continue;
335
+ if (seen.has(listener.port))
336
+ continue;
337
+ seen.add(listener.port);
338
+ sockets.push({
339
+ containerPort: listener.port,
340
+ address: listener.address,
341
+ });
342
+ }
343
+ }
344
+ // `/proc/net/tcp` carries no pid column, so no daemon can be
345
+ // attached to any of these sockets. Only worth flagging when
346
+ // this container actually has supervisor-managed daemons to
347
+ // lose (N1): phpMyAdmin/OpenSearch/`db` have none (exit 127),
348
+ // and a diagnostic there would wrongly protect their stale
349
+ // reap records forever.
350
+ if (supervisorHasDaemons) {
351
+ containerDiagnostics.push({
352
+ source: 'web-sockets',
353
+ message: `in-container socket probe for ${c.id} used the /proc/net/tcp fallback — no daemon names available`,
354
+ containerId: c.id,
355
+ });
356
+ }
357
+ }
358
+ else {
359
+ // Both probes failed outright — genuinely unknown, never dead,
360
+ // so a missing probe binary cannot revoke a live route.
361
+ containerDiagnostics.push({
362
+ source: 'web-sockets',
363
+ message: `in-container socket probe failed for ${c.id}`,
364
+ containerId: c.id,
365
+ });
366
+ }
367
+ }
368
+ const unattachedDaemons = [];
369
+ if (supervisorHasDaemons) {
370
+ for (const entry of parseSupervisorStatus(supervisorRes.stdout)) {
371
+ // Prefer an exact socket-pid match; a supervisor-managed daemon's
372
+ // own reported pid is sometimes a forked child's parent rather
373
+ // than the pid holding the socket, so fall back to walking the
374
+ // process tree from each socket pid up to this supervisor pid.
375
+ // R6: a pid can own more than one port — attach the daemon to
376
+ // every one of them, not just whichever happened to match first.
377
+ let ports = entry.pid ? pidToPort.get(entry.pid) : undefined;
378
+ if (ports === undefined && entry.pid !== undefined) {
379
+ for (const [socketPid, candidatePorts] of pidToPort) {
380
+ if (isAncestor(parents, socketPid, entry.pid)) {
381
+ ports = candidatePorts;
382
+ break;
383
+ }
384
+ }
385
+ }
386
+ if (ports !== undefined && ports.length > 0) {
387
+ for (const port of ports) {
388
+ const idx = sockets.findIndex((s) => s.containerPort === port);
389
+ if (idx >= 0) {
390
+ sockets[idx] = {
391
+ ...sockets[idx],
392
+ daemon: entry.daemon,
393
+ daemonStatus: entry.status,
394
+ };
395
+ }
396
+ }
397
+ continue;
398
+ }
399
+ // No live socket to attach to (typically STOPPED/EXITED).
400
+ unattachedDaemons.push({
401
+ daemon: entry.daemon,
402
+ status: entry.status,
403
+ });
404
+ }
405
+ }
406
+ else if (supervisorRes.exitCode !== 127 &&
407
+ !(supervisorRes.exitCode === 126 &&
408
+ !isPermissionDenied126(supervisorRes.stderr))) {
409
+ // D/4: exit 127 means `supervisorctl` is not installed in this
410
+ // image at all (phpMyAdmin, OpenSearch, OpenSearch Dashboards,
411
+ // `db`, …) — an absent tool is not a probe failure, just "this
412
+ // container has no supervisor" (true of most non-DDEV-web
413
+ // containers), and must never withhold an unrelated reap record.
414
+ // Exit 126 without a "permission denied" stderr signal is treated
415
+ // the same way — some engines report a missing binary as 126
416
+ // rather than 127, and this must not block a no-claim reap either.
417
+ // Only a genuine execution failure (the binary exists but errored,
418
+ // a real permission-denied 126, timed out, or the container could
419
+ // not be reached at all) is diagnostic evidence.
420
+ containerDiagnostics.push({
421
+ source: 'supervisor',
422
+ message: `supervisorctl status failed for ${c.id}`,
423
+ containerId: c.id,
424
+ });
425
+ }
426
+ return {
427
+ container: {
428
+ ...c,
429
+ socketsProbed,
430
+ sockets,
431
+ unattachedDaemons,
432
+ },
433
+ diagnostics: containerDiagnostics,
434
+ };
435
+ }));
436
+ const containers = perContainer.map((r) => r.container);
437
+ for (const r of perContainer)
438
+ diagnostics.push(...r.diagnostics);
439
+ return { containers, diagnostics };
440
+ }
@@ -0,0 +1 @@
1
+ export { type ComposeContainer, type ComposeDiagnostic, type ComposeDiscovery, type ComposeSocket, type ComposeUnattachedDaemon, type DockerComposeDeps, discoverCompose, parseDockerInspect, parseDockerPsIds, parsePidParents, parseSocketListeners, parseSupervisorStatus, type SocketListener, } from './discovery.js';
@@ -0,0 +1 @@
1
+ export { discoverCompose, parseDockerInspect, parseDockerPsIds, parsePidParents, parseSocketListeners, parseSupervisorStatus, } from './discovery.js';
package/package.json CHANGED
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "name": "@bakers-build/addon-docker-compose",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-next.42",
4
+ "description": "Generic Docker Compose discovery: containers, bound ports, in-container sockets and supervisor status for a worktree — no DDEV knowledge.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./dist/src/index.js"
9
+ },
10
+ "files": [
11
+ "dist/src"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://git.key-tec.de/bakers-build/bakers-build.git",
19
+ "directory": "bakers-cli/addons/docker-compose"
20
+ },
21
+ "peerDependencies": {
22
+ "@bakers-build/core": "^0.1.0-next.42"
23
+ }
24
+ }