@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 +21 -0
- package/README.md +4 -2
- package/dist/src/discovery.d.ts +130 -0
- package/dist/src/discovery.js +440 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/package.json +22 -4
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
|
-
#
|
|
1
|
+
# @bakers-build/addon-docker-compose
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|