@bakers-build/addon-ddev 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/claims.d.ts +9 -0
- package/dist/src/claims.js +347 -0
- package/dist/src/discovery.d.ts +39 -0
- package/dist/src/discovery.js +247 -0
- package/dist/src/hosts.d.ts +48 -0
- package/dist/src/hosts.js +133 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +8 -0
- package/dist/src/types.d.ts +133 -0
- package/dist/src/types.js +8 -0
- package/package.json +26 -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-ddev
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DDEV-specific service discovery and claiming: HTTP(S)_EXPOSE/VIRTUAL_HOST interpretation over @bakers-build/addon-docker-compose's generic container data, plus effective-host discovery via `ddev utility configyaml`. No CMS-specific policy lives here.
|
|
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,9 @@
|
|
|
1
|
+
import type { ClaimContext, ClaimedService, RawPort } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* DDEV claimer — every exposed HTTP(S) route of this worktree's containers,
|
|
4
|
+
* coalesced (prefers HTTPS), named from service-type/daemon/VIRTUAL_HOST
|
|
5
|
+
* evidence. The primary web-container router port names itself `web`, Host
|
|
6
|
+
* rewritten through Traefik like any other DDEV service — no CMS-specific
|
|
7
|
+
* policy lives here.
|
|
8
|
+
*/
|
|
9
|
+
export declare function claimDdev(raw: readonly RawPort[], remaining: Set<number>, ctx: ClaimContext): ClaimedService[];
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// BB-676 Task 2 (spec v3 split) — claim raw observations into named,
|
|
2
|
+
// tunnel-ready DDEV services. A claimer consumes raw IDENTITIES (array
|
|
3
|
+
// indices into one `RawDiscovery` pass), never a global port number: two
|
|
4
|
+
// containers or two site rules can share a port, so "port already seen"
|
|
5
|
+
// would silently merge them.
|
|
6
|
+
//
|
|
7
|
+
// This package knows nothing CMS-specific: the web container's own router
|
|
8
|
+
// port names itself `web` (or `web-<host>` for an additional alias), exactly
|
|
9
|
+
// like every other DDEV service names from its compose/service-type/daemon
|
|
10
|
+
// evidence — a project's own application owns multisite/host-header policy,
|
|
11
|
+
// not this claimer (BB-681: no CMS-specific addon layered on top any more).
|
|
12
|
+
//
|
|
13
|
+
// Only a `healthy` claim produces `TunnelRule`s: an orphan, dead, stopped,
|
|
14
|
+
// unknown or not-exposed service is still reported — so `list` can show it —
|
|
15
|
+
// but never shareable.
|
|
16
|
+
import { normalizeHost } from './hosts.js';
|
|
17
|
+
/** Name a claimed service from the best evidence its raw row carries.
|
|
18
|
+
* `serviceType` (`com.ddev.service-type`) is the more specific name when a
|
|
19
|
+
* project happens to set it; `composeService` (`com.docker.compose.service`)
|
|
20
|
+
* is the label DDEV/compose actually sets on every container. A daemon name
|
|
21
|
+
* (a webextradaemon inside the shared web container) distinguishes
|
|
22
|
+
* mailpit/phpmyadmin/etc from the web container's own router port. */
|
|
23
|
+
function nameFromEvidence(p, webExtraExposedPortNames) {
|
|
24
|
+
if (p.daemon) {
|
|
25
|
+
const name = p.daemon.includes(':') ? p.daemon.split(':')[1] : p.daemon;
|
|
26
|
+
return name.toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
// B: a dead/live row for a `web_extra_exposed_ports`-declared service
|
|
29
|
+
// (vite, xhgui, …) carries no `daemon` evidence once it stops listening —
|
|
30
|
+
// its `composeService`/`serviceType` is still just `web` (the shared
|
|
31
|
+
// container), which would otherwise collide with the primary web group's
|
|
32
|
+
// own `web` key and get suffixed into an opaque `web-<hash>-<port>` row.
|
|
33
|
+
// The declared name from `.ddev/config.yaml` is more specific evidence
|
|
34
|
+
// than that shared container label, so it is checked first.
|
|
35
|
+
if (p.containerPort !== undefined) {
|
|
36
|
+
const declared = webExtraExposedPortNames?.get(p.containerPort);
|
|
37
|
+
if (declared)
|
|
38
|
+
return declared.toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
if (p.serviceType)
|
|
41
|
+
return p.serviceType.toLowerCase();
|
|
42
|
+
if (p.composeService)
|
|
43
|
+
return p.composeService.toLowerCase();
|
|
44
|
+
if (p.virtualHost) {
|
|
45
|
+
return p.virtualHost.split(',')[0].split('.')[0].toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
return `service-${(p.containerId ?? 'unknown').slice(0, 8)}`;
|
|
48
|
+
}
|
|
49
|
+
/** The Traefik-routed target for a DDEV service: the ROUTER port DDEV's
|
|
50
|
+
* router/Traefik listens on at the host loopback — never the
|
|
51
|
+
* container-internal port, which only the rule's `direct` origin (dialled
|
|
52
|
+
* by a transport that reaches container IPs) uses. */
|
|
53
|
+
function routedServiceOf(p) {
|
|
54
|
+
const port = p.routerPort ?? p.hostBoundPort ?? p.containerPort ?? 443;
|
|
55
|
+
// B: an alias that only exists via HTTP_EXPOSE has no HTTPS listener behind
|
|
56
|
+
// it at all — dialling `https://` there just fails. Default to `https`
|
|
57
|
+
// only when we have no scheme evidence (e.g. a bare listening socket with
|
|
58
|
+
// no expose rule attached).
|
|
59
|
+
const scheme = p.exposeScheme === 'http' ? 'http' : 'https';
|
|
60
|
+
return `${scheme}://127.0.0.1:${port}`;
|
|
61
|
+
}
|
|
62
|
+
function resolveStatus(p) {
|
|
63
|
+
if (p.containerState === 'stopped')
|
|
64
|
+
return 'stopped';
|
|
65
|
+
if (p.daemonStatus === 'STOPPED' || p.daemonStatus === 'EXITED')
|
|
66
|
+
return 'stopped';
|
|
67
|
+
if (p.exposed === 'unknown' || p.listening === 'unknown')
|
|
68
|
+
return 'unknown';
|
|
69
|
+
if (p.exposed === true && p.listening === false)
|
|
70
|
+
return 'dead';
|
|
71
|
+
if (p.exposed === true && p.listening === true)
|
|
72
|
+
return 'healthy';
|
|
73
|
+
if (p.exposed === false && p.listening === true)
|
|
74
|
+
return 'not-exposed';
|
|
75
|
+
return 'unknown';
|
|
76
|
+
}
|
|
77
|
+
/** The sibling row carrying the SAME derived name that IS actually listening —
|
|
78
|
+
* diagnostic evidence for a dead claim (AC4: "vite is dead, 3002 is not"),
|
|
79
|
+
* never asserted as a verified substitute. */
|
|
80
|
+
function listeningSibling(raw, containerId, excludeIdx) {
|
|
81
|
+
if (!containerId)
|
|
82
|
+
return undefined;
|
|
83
|
+
// B: real DDEV topology never repeats a dead group's derived name on the
|
|
84
|
+
// sibling that actually listens (e.g. vite is dead on 5173, the real
|
|
85
|
+
// listener is a daemon like `pynq-members-dev` on 3002) — matching by name
|
|
86
|
+
// never fires. Match by CONTAINER identity instead: any other row on the
|
|
87
|
+
// same container that is genuinely listening is diagnostic evidence worth
|
|
88
|
+
// surfacing.
|
|
89
|
+
//
|
|
90
|
+
// Critical finding 1: a container can have several genuinely-listening
|
|
91
|
+
// rows (the web-container's internal nginx, Docker's own embedded DNS
|
|
92
|
+
// stub, an actual webextradaemon). `127.0.0.11` (Docker's embedded DNS) is
|
|
93
|
+
// already filtered out upstream, but a daemon-attached row is still the
|
|
94
|
+
// most useful diagnostic evidence — name the thing an operator can
|
|
95
|
+
// actually act on, not whichever row happens to come first.
|
|
96
|
+
// A: a daemon that ALSO owns an exposed, healthy route elsewhere on the
|
|
97
|
+
// same container (mailpit on its router-mapped port) is not the
|
|
98
|
+
// diagnostic candidate an operator can "go use instead" — it already has
|
|
99
|
+
// its own working tunnel. Only an unexposed row names the thing that is
|
|
100
|
+
// actually, unexpectedly listening where the dead claim expected to find
|
|
101
|
+
// nothing else. `ss`'s row order must not decide which daemon wins.
|
|
102
|
+
const exposedDaemonNames = new Set(raw
|
|
103
|
+
.filter((p) => p.containerId === containerId &&
|
|
104
|
+
p.exposed === true &&
|
|
105
|
+
p.daemon !== undefined)
|
|
106
|
+
.map((p) => p.daemon));
|
|
107
|
+
const candidates = raw.filter((p, i) => i !== excludeIdx &&
|
|
108
|
+
p.containerId === containerId &&
|
|
109
|
+
p.listening === true &&
|
|
110
|
+
p.exposed === false &&
|
|
111
|
+
p.address !== '127.0.0.11' &&
|
|
112
|
+
!(p.daemon !== undefined && exposedDaemonNames.has(p.daemon)));
|
|
113
|
+
const webExtraDaemon = candidates.find((p) => p.daemon?.startsWith('webextradaemons:'));
|
|
114
|
+
const anyDaemon = candidates.find((p) => p.daemon !== undefined);
|
|
115
|
+
return webExtraDaemon ?? anyDaemon ?? candidates[0];
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* DDEV claimer — every exposed HTTP(S) route of this worktree's containers,
|
|
119
|
+
* coalesced (prefers HTTPS), named from service-type/daemon/VIRTUAL_HOST
|
|
120
|
+
* evidence. The primary web-container router port names itself `web`, Host
|
|
121
|
+
* rewritten through Traefik like any other DDEV service — no CMS-specific
|
|
122
|
+
* policy lives here.
|
|
123
|
+
*/
|
|
124
|
+
export function claimDdev(raw, remaining, ctx) {
|
|
125
|
+
const exposedIdxs = [...remaining].filter((i) => raw[i].containerId !== undefined && raw[i].exposed !== undefined);
|
|
126
|
+
// Group by (containerId, containerPort) — NOT by routerPort, and NOT by
|
|
127
|
+
// VIRTUAL_HOST: a service can be reachable through an HTTP_EXPOSE and an
|
|
128
|
+
// HTTPS_EXPOSE mapping that target the SAME container port (any non-web
|
|
129
|
+
// alias pair — mailpit, vite, … — follows this pattern); keying on router
|
|
130
|
+
// port treats those as two distinct services.
|
|
131
|
+
//
|
|
132
|
+
// Live pynq-43 topology: DDEV's nginx ALSO listens on container port 443
|
|
133
|
+
// internally even though HTTP_EXPOSE/HTTPS_EXPOSE both route to container
|
|
134
|
+
// port 80 (Traefik terminates TLS itself and only ever forwards there) —
|
|
135
|
+
// that stray, genuinely unexposed 443 socket is NOT part of the public web
|
|
136
|
+
// service, so it must never be swept into the same group as the real
|
|
137
|
+
// HTTP/HTTPS alias pair just because 80/443 are both traditionally
|
|
138
|
+
// "the" web ports. The plain containerPort key already does the right
|
|
139
|
+
// thing for both real shapes: the HTTP/HTTPS alias pair (same containerPort,
|
|
140
|
+
// different routerPort) coalesces naturally; the unrelated 443-internal
|
|
141
|
+
// socket gets its own group, surfacing separately as diagnostic evidence
|
|
142
|
+
// (R2: a bare, unexposed sibling socket on the SAME containerPort as an
|
|
143
|
+
// exposed row still joins that row's group — any other containerPort does not).
|
|
144
|
+
const groups = new Map();
|
|
145
|
+
for (const i of exposedIdxs) {
|
|
146
|
+
const p = raw[i];
|
|
147
|
+
// N2: a port-less `unattachedDaemons` row (STOPPED/EXITED, no socket to
|
|
148
|
+
// attach to) carries neither `containerPort` nor `routerPort`, so every
|
|
149
|
+
// such row on the same container used to collapse into one `:unknown`
|
|
150
|
+
// group — a RUNNING, port-less sibling (nginx/php-fpm) then won the
|
|
151
|
+
// group and a STOPPED sibling (xhgui) silently vanished instead of
|
|
152
|
+
// listing. Key port-less rows by daemon name so each keeps its own group.
|
|
153
|
+
const groupKey = (p.containerPort ?? p.routerPort)
|
|
154
|
+
? `${p.containerId}:${p.containerPort ?? p.routerPort}`
|
|
155
|
+
: `${p.containerId}:unknown:${p.daemon ?? 'unknown'}`;
|
|
156
|
+
const entry = groups.get(groupKey) ?? [];
|
|
157
|
+
entry.push(i);
|
|
158
|
+
groups.set(groupKey, entry);
|
|
159
|
+
}
|
|
160
|
+
// B/duplicate-row fix: a group with no `containerPort` at all (an
|
|
161
|
+
// `unattachedDaemons` row — e.g. the web container's internal `nginx`, or a
|
|
162
|
+
// supervisor-listed daemon `ss`/the socket scan could not correlate to its
|
|
163
|
+
// own already-discovered socket) is DIAGNOSTIC-ONLY evidence about a
|
|
164
|
+
// container that already has a real, port-specific claim elsewhere. Once
|
|
165
|
+
// that more specific claim exists, the containerPort-less group is a pure
|
|
166
|
+
// duplicate of the same container and must not surface as its own row. A
|
|
167
|
+
// container with NO other port-bearing group at all (e.g. `db`, which is
|
|
168
|
+
// genuinely unclaimed) keeps its single generic row — this only suppresses
|
|
169
|
+
// the redundant generic twin, never a genuinely unclaimed container.
|
|
170
|
+
const containerIdsWithPort = new Set();
|
|
171
|
+
for (const i of exposedIdxs) {
|
|
172
|
+
const p = raw[i];
|
|
173
|
+
if (p.containerPort !== undefined && p.containerId !== undefined) {
|
|
174
|
+
containerIdsWithPort.add(p.containerId);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// Order groups so that the group whose name assignment MATTERS most (an
|
|
178
|
+
// actually `healthy` route) claims the clean, unsuffixed name — never
|
|
179
|
+
// whichever group happens to iterate first. Two sockets on the SAME
|
|
180
|
+
// container can legitimately derive the identical baseName (phpMyAdmin's
|
|
181
|
+
// extra loopback-only status socket next to its real port-80 listener;
|
|
182
|
+
// OpenSearch's internal transport port 9300 next to its real 9200 API) —
|
|
183
|
+
// a Map's insertion order is otherwise arbitrary, so the bare, operator-
|
|
184
|
+
// facing key (`phpmyadmin`, not `phpmyadmin-<hash>-80`) could land on the
|
|
185
|
+
// not-exposed diagnostic row instead of the one `ports up phpmyadmin`
|
|
186
|
+
// actually needs to resolve to.
|
|
187
|
+
const statusPriority = {
|
|
188
|
+
healthy: 0,
|
|
189
|
+
dead: 1,
|
|
190
|
+
stopped: 2,
|
|
191
|
+
unknown: 3,
|
|
192
|
+
'not-exposed': 4,
|
|
193
|
+
};
|
|
194
|
+
const orderedGroups = [...groups.values()]
|
|
195
|
+
.map((idxs, order) => {
|
|
196
|
+
const sorted = [...idxs].sort((a, b) => {
|
|
197
|
+
const score = (i) => (raw[i].exposeScheme === 'http' ? 1 : 0);
|
|
198
|
+
return score(a) - score(b);
|
|
199
|
+
});
|
|
200
|
+
const best = raw[sorted[0]];
|
|
201
|
+
return { idxs: sorted, best, status: resolveStatus(best), order };
|
|
202
|
+
})
|
|
203
|
+
.sort((a, b) => {
|
|
204
|
+
const byStatus = statusPriority[a.status] - statusPriority[b.status];
|
|
205
|
+
return byStatus !== 0 ? byStatus : a.order - b.order;
|
|
206
|
+
});
|
|
207
|
+
const out = [];
|
|
208
|
+
const seenKeys = new Set();
|
|
209
|
+
for (const { idxs, best, status } of orderedGroups) {
|
|
210
|
+
for (const i of idxs)
|
|
211
|
+
remaining.delete(i);
|
|
212
|
+
if (best.containerPort === undefined &&
|
|
213
|
+
best.containerId !== undefined &&
|
|
214
|
+
containerIdsWithPort.has(best.containerId) &&
|
|
215
|
+
// Finding 5: only suppress a RUNNING twin (nginx's own internal
|
|
216
|
+
// socket, or a daemon the socket scan already attached to a
|
|
217
|
+
// port-bearing group elsewhere on this container) — a STOPPED/EXITED
|
|
218
|
+
// `webextradaemons` entry (e.g. `xhgui`) has NO other representation
|
|
219
|
+
// anywhere else and must still list, or it silently vanishes from
|
|
220
|
+
// `list` instead of showing as `[stopped]`.
|
|
221
|
+
(best.daemon === undefined || best.daemonStatus === 'RUNNING')) {
|
|
222
|
+
// Diagnostic-only twin of a container that already has a real claim
|
|
223
|
+
// (nginx inside the web container, a daemon the socket scan already
|
|
224
|
+
// matched elsewhere on this same container) — drop it, never render it
|
|
225
|
+
// as its own row.
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const baseName = nameFromEvidence(best, ctx.webExtraExposedPortNames);
|
|
229
|
+
// B: group identity is (containerId, containerPort) — never collapsed
|
|
230
|
+
// across distinct groups just because two unrelated containers happen to
|
|
231
|
+
// derive the same name. Only suffix when this exact key was already
|
|
232
|
+
// produced by an EARLIER distinct group.
|
|
233
|
+
const key = seenKeys.has(baseName)
|
|
234
|
+
? `${baseName}-${(best.containerId ?? '').slice(0, 8)}-${best.containerPort ?? 'na'}`
|
|
235
|
+
: baseName;
|
|
236
|
+
seenKeys.add(baseName);
|
|
237
|
+
const rawPorts = idxs.map((i) => raw[i]);
|
|
238
|
+
let limitation;
|
|
239
|
+
const rules = [];
|
|
240
|
+
let healthyService;
|
|
241
|
+
if (status === 'dead') {
|
|
242
|
+
const sibling = listeningSibling(raw, best.containerId, idxs[0]);
|
|
243
|
+
limitation = sibling
|
|
244
|
+
? `configured port ${best.routerPort ?? best.containerPort} is dead; ` +
|
|
245
|
+
`${nameFromEvidence(sibling, ctx.webExtraExposedPortNames)} is listening on ` +
|
|
246
|
+
`${sibling.containerPort} instead`
|
|
247
|
+
: `configured port ${best.routerPort ?? best.containerPort} is dead`;
|
|
248
|
+
}
|
|
249
|
+
else if (status === 'healthy') {
|
|
250
|
+
const service = routedServiceOf(best);
|
|
251
|
+
healthyService = service;
|
|
252
|
+
rules.push({
|
|
253
|
+
key,
|
|
254
|
+
service,
|
|
255
|
+
...(best.virtualHost
|
|
256
|
+
? { hostHeader: best.virtualHost.split(',')[0] }
|
|
257
|
+
: {}),
|
|
258
|
+
// N7: Traefik presents every DDEV-routed destination over HTTPS
|
|
259
|
+
// with its own (mkcert/self-signed) certificate — `insecureTls`
|
|
260
|
+
// belongs to the resolved TARGET's scheme, not to "is the router
|
|
261
|
+
// port literally 443". `routedServiceOf` always resolves to
|
|
262
|
+
// `https://`, so Mailpit's router 8026 needs this exactly as much
|
|
263
|
+
// as Drupal's router 443 did; the old `routerPort === 443` check
|
|
264
|
+
// silently skipped it for every other router port.
|
|
265
|
+
...(service.startsWith('https://') ? { insecureTls: true } : {}),
|
|
266
|
+
// Transports that reach container IPs skip Traefik and keep the
|
|
267
|
+
// public Host; the `web-<host>` aliases below need Traefik's
|
|
268
|
+
// host-based routing, so they never get this.
|
|
269
|
+
...(best.containerIp && best.containerPort
|
|
270
|
+
? { direct: `http://${best.containerIp}:${best.containerPort}` }
|
|
271
|
+
: {}),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
// Finding 6: name the actual router port an operator would dial —
|
|
275
|
+
// `https://<vhost>` alone silently implies 443, which is wrong for every
|
|
276
|
+
// non-default router port (Mailpit 8026, phpMyAdmin 8037, …).
|
|
277
|
+
const localScheme = best.exposeScheme === 'http' ? 'http' : 'https';
|
|
278
|
+
const localPort = best.routerPort;
|
|
279
|
+
const localDefaultPort = localScheme === 'http' ? 80 : 443;
|
|
280
|
+
const localSuffix = localPort && localPort !== localDefaultPort ? `:${localPort}` : '';
|
|
281
|
+
out.push({
|
|
282
|
+
key,
|
|
283
|
+
claim: 'ddev',
|
|
284
|
+
status,
|
|
285
|
+
local: best.virtualHost
|
|
286
|
+
? `${localScheme}://${best.virtualHost.split(',')[0]}${localSuffix}`
|
|
287
|
+
: `container:${best.containerId}`,
|
|
288
|
+
...(limitation ? { limitation } : {}),
|
|
289
|
+
rawPorts,
|
|
290
|
+
rules,
|
|
291
|
+
});
|
|
292
|
+
// BB-681 Task 1 — split the PRIMARY web group (the bare `web` key —
|
|
293
|
+
// never a duplicate-suffixed or ancillary-service group) into one
|
|
294
|
+
// additional claim per configured host, each named `web-<host-with-
|
|
295
|
+
// hyphens-for-dots>`. An alias is only eligible when it is BOTH
|
|
296
|
+
// configured (`ctx.hosts.additional`) AND observed in this group's own
|
|
297
|
+
// VIRTUAL_HOST evidence — a configured-but-unobserved alias never
|
|
298
|
+
// produces a confident claim.
|
|
299
|
+
if (key === 'web' && status === 'healthy' && ctx.hosts && healthyService) {
|
|
300
|
+
const observed = new Set((best.virtualHost ?? '')
|
|
301
|
+
.split(',')
|
|
302
|
+
.map((h) => normalizeHost(h))
|
|
303
|
+
.filter(Boolean));
|
|
304
|
+
const eligibleAliases = ctx.hosts.additional.filter((a) => observed.has(a));
|
|
305
|
+
// BB-681 review M4: two DIFFERENT hosts can produce the SAME
|
|
306
|
+
// `web-<key>` after replacing dots with hyphens (`admin.ddev-site` and
|
|
307
|
+
// `admin-ddev.site` both become `web-admin-ddev-site`). Spec: "reject
|
|
308
|
+
// collisions instead of selecting by list order" — detect every
|
|
309
|
+
// colliding aliasKey across this one call BEFORE emitting any of
|
|
310
|
+
// them, so neither alias is silently favored by iteration order.
|
|
311
|
+
const keyCounts = new Map();
|
|
312
|
+
for (const alias of eligibleAliases) {
|
|
313
|
+
const aliasKey = `web-${alias.replace(/\./g, '-')}`;
|
|
314
|
+
keyCounts.set(aliasKey, (keyCounts.get(aliasKey) ?? 0) + 1);
|
|
315
|
+
}
|
|
316
|
+
for (const alias of eligibleAliases) {
|
|
317
|
+
const aliasKey = `web-${alias.replace(/\./g, '-')}`;
|
|
318
|
+
const colliding = (keyCounts.get(aliasKey) ?? 0) > 1;
|
|
319
|
+
out.push({
|
|
320
|
+
key: aliasKey,
|
|
321
|
+
claim: 'ddev',
|
|
322
|
+
status: 'healthy',
|
|
323
|
+
local: `https://${alias}`,
|
|
324
|
+
rawPorts,
|
|
325
|
+
...(colliding
|
|
326
|
+
? {
|
|
327
|
+
limitation: `collides with another alias's normalized key '${aliasKey}' — neither is shareable`,
|
|
328
|
+
rules: [],
|
|
329
|
+
}
|
|
330
|
+
: {
|
|
331
|
+
rules: [
|
|
332
|
+
{
|
|
333
|
+
key: aliasKey,
|
|
334
|
+
service: healthyService,
|
|
335
|
+
hostHeader: alias,
|
|
336
|
+
...(healthyService.startsWith('https://')
|
|
337
|
+
? { insecureTls: true }
|
|
338
|
+
: {}),
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
}),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type DockerComposeDeps } from '@bakers-build/addon-docker-compose';
|
|
2
|
+
import type { RawDiscovery } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* DDEV's `HTTP_EXPOSE`/`HTTPS_EXPOSE` format: comma-separated
|
|
5
|
+
* `routerPort:containerPort` pairs (a bare port means router===container).
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseExposeMapping(value: string): Array<{
|
|
8
|
+
routerPort: number;
|
|
9
|
+
containerPort: number;
|
|
10
|
+
}>;
|
|
11
|
+
/** `.ddev/config.yaml`'s `web_extra_exposed_ports:` — a block sequence of
|
|
12
|
+
* `{name, container_port, http_port, https_port}` entries (real shape,
|
|
13
|
+
* captured live from a pynq-43 worktree):
|
|
14
|
+
* ```
|
|
15
|
+
* web_extra_exposed_ports:
|
|
16
|
+
* -
|
|
17
|
+
* name: vite
|
|
18
|
+
* container_port: 5173
|
|
19
|
+
* http_port: 5172
|
|
20
|
+
* https_port: 5173
|
|
21
|
+
* ```
|
|
22
|
+
* A targeted line-parser is enough — no need for a YAML dependency for one
|
|
23
|
+
* array. Keyed by `container_port` since that is what a claimed row's `containerPort`
|
|
24
|
+
* matches against (B: name a non-primary-web group from this before falling
|
|
25
|
+
* back to the web container's generic compose-service label).
|
|
26
|
+
*
|
|
27
|
+
* DDEV accepts both block-sequence shapes — a lone `-` starting the entry
|
|
28
|
+
* (above) AND the inline form `- name: vite` with the first field on the
|
|
29
|
+
* same line as the dash (DDEV's own documented style). A line starting with
|
|
30
|
+
* `-` always begins a new entry either way; its remainder (if any) is the
|
|
31
|
+
* first field of that entry, not a lone separator to discard. */
|
|
32
|
+
export declare function parseWebExtraExposedPorts(configYaml: string | null): ReadonlyMap<number, string>;
|
|
33
|
+
/**
|
|
34
|
+
* Discover this worktree's DDEV-exposed ports: every container the
|
|
35
|
+
* `com.ddev.approot` label (unioned with the generic compose working-dir
|
|
36
|
+
* label, `@bakers-build/addon-docker-compose`'s own filter) matches, interpreted
|
|
37
|
+
* through `HTTP(S)_EXPOSE`/`VIRTUAL_HOST`.
|
|
38
|
+
*/
|
|
39
|
+
export declare function discoverDdevPorts(worktree: string, deps: DockerComposeDeps): Promise<RawDiscovery>;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// BB-676 spec v2 — DDEV-specific interpretation of generic Docker Compose
|
|
2
|
+
// observations: `HTTP_EXPOSE`/`HTTPS_EXPOSE` router-port parsing and the
|
|
3
|
+
// `com.ddev.approot` worktree match. This is the only place in the ports
|
|
4
|
+
// pipeline that knows those conventions exist.
|
|
5
|
+
import { discoverCompose, } from '@bakers-build/addon-docker-compose';
|
|
6
|
+
/**
|
|
7
|
+
* DDEV's `HTTP_EXPOSE`/`HTTPS_EXPOSE` format: comma-separated
|
|
8
|
+
* `routerPort:containerPort` pairs (a bare port means router===container).
|
|
9
|
+
*/
|
|
10
|
+
export function parseExposeMapping(value) {
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const raw of value.split(',')) {
|
|
13
|
+
const part = raw.trim();
|
|
14
|
+
if (!part)
|
|
15
|
+
continue;
|
|
16
|
+
const [a, b] = part.split(':');
|
|
17
|
+
const routerPort = Number(a);
|
|
18
|
+
const containerPort = Number(b ?? a);
|
|
19
|
+
if (!Number.isInteger(routerPort) || !Number.isInteger(containerPort)) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
out.push({ routerPort, containerPort });
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
/** Tag each parsed mapping with which `*_EXPOSE` var produced it, so later
|
|
27
|
+
* stages know the real scheme instead of guessing from the router port. */
|
|
28
|
+
function parseExposeMappingWithScheme(value, scheme) {
|
|
29
|
+
return parseExposeMapping(value).map((m) => ({ ...m, scheme }));
|
|
30
|
+
}
|
|
31
|
+
/** `.ddev/config.yaml`'s `web_extra_exposed_ports:` — a block sequence of
|
|
32
|
+
* `{name, container_port, http_port, https_port}` entries (real shape,
|
|
33
|
+
* captured live from a pynq-43 worktree):
|
|
34
|
+
* ```
|
|
35
|
+
* web_extra_exposed_ports:
|
|
36
|
+
* -
|
|
37
|
+
* name: vite
|
|
38
|
+
* container_port: 5173
|
|
39
|
+
* http_port: 5172
|
|
40
|
+
* https_port: 5173
|
|
41
|
+
* ```
|
|
42
|
+
* A targeted line-parser is enough — no need for a YAML dependency for one
|
|
43
|
+
* array. Keyed by `container_port` since that is what a claimed row's `containerPort`
|
|
44
|
+
* matches against (B: name a non-primary-web group from this before falling
|
|
45
|
+
* back to the web container's generic compose-service label).
|
|
46
|
+
*
|
|
47
|
+
* DDEV accepts both block-sequence shapes — a lone `-` starting the entry
|
|
48
|
+
* (above) AND the inline form `- name: vite` with the first field on the
|
|
49
|
+
* same line as the dash (DDEV's own documented style). A line starting with
|
|
50
|
+
* `-` always begins a new entry either way; its remainder (if any) is the
|
|
51
|
+
* first field of that entry, not a lone separator to discard. */
|
|
52
|
+
export function parseWebExtraExposedPorts(configYaml) {
|
|
53
|
+
const out = new Map();
|
|
54
|
+
if (!configYaml)
|
|
55
|
+
return out;
|
|
56
|
+
const listMatch = /^web_extra_exposed_ports:\s*$/m.exec(configYaml);
|
|
57
|
+
if (!listMatch)
|
|
58
|
+
return out;
|
|
59
|
+
const rest = configYaml.slice(listMatch.index + listMatch[0].length);
|
|
60
|
+
const lines = rest.split('\n');
|
|
61
|
+
let name;
|
|
62
|
+
let containerPort;
|
|
63
|
+
const flush = () => {
|
|
64
|
+
if (name !== undefined && containerPort !== undefined) {
|
|
65
|
+
out.set(containerPort, name);
|
|
66
|
+
}
|
|
67
|
+
name = undefined;
|
|
68
|
+
containerPort = undefined;
|
|
69
|
+
};
|
|
70
|
+
const applyField = (field) => {
|
|
71
|
+
const nameM = /^\s*name:\s*['"]?([^'"#\r\n]*?)['"]?\s*(?:#.*)?$/m.exec(field);
|
|
72
|
+
if (nameM?.[1]) {
|
|
73
|
+
name = nameM[1].trim();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const portM = /^\s*container_port:\s*(\d+)\s*$/m.exec(field);
|
|
77
|
+
if (portM?.[1]) {
|
|
78
|
+
containerPort = Number(portM[1]);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
if (line.trim() === '')
|
|
83
|
+
continue;
|
|
84
|
+
// A line with no leading indentation ends this top-level key's block.
|
|
85
|
+
if (!/^\s/.test(line))
|
|
86
|
+
break;
|
|
87
|
+
const dashM = /^(\s*)-(\s*)(.*)$/.exec(line);
|
|
88
|
+
if (dashM) {
|
|
89
|
+
flush();
|
|
90
|
+
const remainder = dashM[3] ?? '';
|
|
91
|
+
if (remainder.trim() !== '')
|
|
92
|
+
applyField(remainder);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
applyField(line);
|
|
96
|
+
}
|
|
97
|
+
flush();
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
function rawPortsForContainer(worktree, container) {
|
|
101
|
+
const containerState = container.running
|
|
102
|
+
? 'running'
|
|
103
|
+
: 'stopped';
|
|
104
|
+
const virtualHost = container.env.VIRTUAL_HOST;
|
|
105
|
+
const serviceType = container.labels['com.ddev.service-type'];
|
|
106
|
+
const composeService = container.labels['com.docker.compose.service'];
|
|
107
|
+
// N8: DDEV allows `HTTP_EXPOSE` and `HTTPS_EXPOSE` on the SAME container at
|
|
108
|
+
// once — a project can expose one router port over plain HTTP and a
|
|
109
|
+
// different one over HTTPS. `??` read only one var, so the other's router
|
|
110
|
+
// mappings were silently dropped whenever both were set; merge both sets
|
|
111
|
+
// instead. A router port that appears in both wins by insertion order
|
|
112
|
+
// (HTTPS mapping taken first, matching the single-source scheme's old
|
|
113
|
+
// "HTTPS preferred" intent) rather than overwriting silently either way.
|
|
114
|
+
const mappings = [
|
|
115
|
+
...(container.env.HTTPS_EXPOSE
|
|
116
|
+
? parseExposeMappingWithScheme(container.env.HTTPS_EXPOSE, 'https')
|
|
117
|
+
: []),
|
|
118
|
+
...(container.env.HTTP_EXPOSE
|
|
119
|
+
? parseExposeMappingWithScheme(container.env.HTTP_EXPOSE, 'http')
|
|
120
|
+
: []),
|
|
121
|
+
];
|
|
122
|
+
// N8: a `Map<containerPort, RawPort>` can only ever hold ONE row per
|
|
123
|
+
// container port, but with both expose vars set it is legitimate for the
|
|
124
|
+
// SAME container port to be reachable through two different router ports
|
|
125
|
+
// (e.g. `HTTP_EXPOSE=80:443` plus `HTTPS_EXPOSE=443:443`) — an array per
|
|
126
|
+
// port preserves both instead of the second mapping silently clobbering
|
|
127
|
+
// the first.
|
|
128
|
+
const byPort = new Map();
|
|
129
|
+
for (const mapping of mappings) {
|
|
130
|
+
const arr = byPort.get(mapping.containerPort) ?? [];
|
|
131
|
+
arr.push({
|
|
132
|
+
worktree,
|
|
133
|
+
containerId: container.id,
|
|
134
|
+
containerPort: mapping.containerPort,
|
|
135
|
+
routerPort: mapping.routerPort,
|
|
136
|
+
exposeScheme: mapping.scheme,
|
|
137
|
+
...(virtualHost ? { virtualHost } : {}),
|
|
138
|
+
...(serviceType ? { serviceType } : {}),
|
|
139
|
+
...(composeService ? { composeService } : {}),
|
|
140
|
+
...(container.boundPorts.has(mapping.containerPort)
|
|
141
|
+
? { hostBoundPort: container.boundPorts.get(mapping.containerPort) }
|
|
142
|
+
: {}),
|
|
143
|
+
containerState,
|
|
144
|
+
exposed: true,
|
|
145
|
+
// Not yet probed: stopped containers cannot listen; running ones are
|
|
146
|
+
// resolved below, once (or left `unknown` if the probe itself failed
|
|
147
|
+
// upstream in `@bakers-build/addon-docker-compose`).
|
|
148
|
+
listening: containerState === 'running' ? 'unknown' : false,
|
|
149
|
+
});
|
|
150
|
+
byPort.set(mapping.containerPort, arr);
|
|
151
|
+
}
|
|
152
|
+
const ports = [];
|
|
153
|
+
const matchedPorts = new Set();
|
|
154
|
+
for (const socket of container.sockets) {
|
|
155
|
+
matchedPorts.add(socket.containerPort);
|
|
156
|
+
const existing = byPort.get(socket.containerPort);
|
|
157
|
+
if (existing && existing.length > 0) {
|
|
158
|
+
byPort.set(socket.containerPort, existing.map((row) => ({
|
|
159
|
+
...row,
|
|
160
|
+
listening: true,
|
|
161
|
+
...(socket.pid !== undefined ? { pid: socket.pid } : {}),
|
|
162
|
+
...(socket.address !== undefined ? { address: socket.address } : {}),
|
|
163
|
+
...(socket.daemon !== undefined
|
|
164
|
+
? { daemon: socket.daemon, daemonStatus: socket.daemonStatus }
|
|
165
|
+
: {}),
|
|
166
|
+
})));
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
// Listening but no exposure rule claims it — e.g. a daemon bound on
|
|
170
|
+
// the wrong port, or an internal-only service (AC1). This is also
|
|
171
|
+
// where the web container's own `nginx` 80/443 socket shows up when
|
|
172
|
+
// `ss` cannot resolve its (root-owned) pid — attach its bound host
|
|
173
|
+
// port here too (R2), so the claimer can still find a BB-500
|
|
174
|
+
// passthrough target for it.
|
|
175
|
+
ports.push({
|
|
176
|
+
worktree,
|
|
177
|
+
containerId: container.id,
|
|
178
|
+
containerPort: socket.containerPort,
|
|
179
|
+
...(socket.pid !== undefined ? { pid: socket.pid } : {}),
|
|
180
|
+
...(socket.address !== undefined ? { address: socket.address } : {}),
|
|
181
|
+
...(serviceType ? { serviceType } : {}),
|
|
182
|
+
...(composeService ? { composeService } : {}),
|
|
183
|
+
...(socket.daemon !== undefined
|
|
184
|
+
? { daemon: socket.daemon, daemonStatus: socket.daemonStatus }
|
|
185
|
+
: {}),
|
|
186
|
+
...(container.boundPorts.has(socket.containerPort)
|
|
187
|
+
? { hostBoundPort: container.boundPorts.get(socket.containerPort) }
|
|
188
|
+
: {}),
|
|
189
|
+
containerState,
|
|
190
|
+
exposed: false,
|
|
191
|
+
listening: true,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Exposure rows the socket scan did NOT confirm are genuinely dead — but
|
|
196
|
+
// only when the probe itself succeeded; a failed probe means "unknown",
|
|
197
|
+
// never "dead", so a missing probe binary cannot revoke a live route.
|
|
198
|
+
//
|
|
199
|
+
// C: `@bakers-build/addon-docker-compose`'s `discoverCompose` now falls back to
|
|
200
|
+
// reading `/proc/net/tcp`(6) directly whenever `ss` is missing/fails
|
|
201
|
+
// outright (images like phpMyAdmin/OpenSearch ship neither `ss` nor
|
|
202
|
+
// `netstat`), so `container.socketsProbed` is already `true` whenever
|
|
203
|
+
// EITHER probe succeeded, and matched ports from that fallback already
|
|
204
|
+
// flow through `matchedPorts` above. A row that reaches here with
|
|
205
|
+
// `socketsProbed` still `false` means both probes genuinely failed — that
|
|
206
|
+
// is `unknown`, never a guess from a published host port binding (Docker
|
|
207
|
+
// binds a host port whether or not anything inside actually listens, so
|
|
208
|
+
// trusting it as proof of health reads a dead port as healthy just as
|
|
209
|
+
// often as it rescues a truly-unprobable one).
|
|
210
|
+
for (const [port, rows] of byPort) {
|
|
211
|
+
byPort.set(port, rows.map((row) => matchedPorts.has(port)
|
|
212
|
+
? row
|
|
213
|
+
: container.socketsProbed
|
|
214
|
+
? { ...row, listening: false }
|
|
215
|
+
: row));
|
|
216
|
+
}
|
|
217
|
+
for (const rows of byPort.values())
|
|
218
|
+
ports.push(...rows);
|
|
219
|
+
for (const daemon of container.unattachedDaemons) {
|
|
220
|
+
ports.push({
|
|
221
|
+
worktree,
|
|
222
|
+
containerId: container.id,
|
|
223
|
+
daemon: daemon.daemon,
|
|
224
|
+
daemonStatus: daemon.status,
|
|
225
|
+
containerState,
|
|
226
|
+
exposed: 'unknown',
|
|
227
|
+
listening: false,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const containerIp = container.networks.get('ddev_default');
|
|
231
|
+
return containerIp ? ports.map((row) => ({ ...row, containerIp })) : ports;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Discover this worktree's DDEV-exposed ports: every container the
|
|
235
|
+
* `com.ddev.approot` label (unioned with the generic compose working-dir
|
|
236
|
+
* label, `@bakers-build/addon-docker-compose`'s own filter) matches, interpreted
|
|
237
|
+
* through `HTTP(S)_EXPOSE`/`VIRTUAL_HOST`.
|
|
238
|
+
*/
|
|
239
|
+
export async function discoverDdevPorts(worktree, deps) {
|
|
240
|
+
const { containers, diagnostics: composeDiagnostics } = await discoverCompose(worktree, deps, { extraLabelFilters: ['com.ddev.approot'] });
|
|
241
|
+
const diagnostics = [...composeDiagnostics];
|
|
242
|
+
const ports = [];
|
|
243
|
+
for (const container of containers) {
|
|
244
|
+
ports.push(...rawPortsForContainer(worktree, container));
|
|
245
|
+
}
|
|
246
|
+
return { ports, diagnostics };
|
|
247
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface DdevHosts {
|
|
2
|
+
readonly primary: string;
|
|
3
|
+
readonly additional: readonly string[];
|
|
4
|
+
}
|
|
5
|
+
/** Thrown by `parseDdevHosts` on any config shape it cannot safely derive
|
|
6
|
+
* hosts from — a caller converts this to an `unknown` discovery result
|
|
7
|
+
* rather than ever inventing a concrete host. */
|
|
8
|
+
export declare class DdevHostsError extends Error {
|
|
9
|
+
constructor(message: string);
|
|
10
|
+
}
|
|
11
|
+
/** Lowercase/trailing-dot normalization, shared with anything that compares
|
|
12
|
+
* an observed `VIRTUAL_HOST` entry against a configured host (`claims.ts`'s
|
|
13
|
+
* alias-eligibility check). */
|
|
14
|
+
export declare function normalizeHost(host: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Derive `{ primary, additional }` from DDEV's effective config (the parsed
|
|
17
|
+
* `ddev utility configyaml` object, or a pre-parsed equivalent). Throws
|
|
18
|
+
* `DdevHostsError` on missing/invalid required fields, a wildcard host, or a
|
|
19
|
+
* collision between two inputs that normalize to the same host — never
|
|
20
|
+
* silently drops or invents one.
|
|
21
|
+
*/
|
|
22
|
+
export declare function parseDdevHosts(config: unknown): DdevHosts;
|
|
23
|
+
/** The injected exec seam — the same bounded-exec pattern
|
|
24
|
+
* `@bakers-build/addon-docker-compose`'s `discoverCompose` uses (a `run(cmd,
|
|
25
|
+
* args, cwd?)` returning `{ stdout, ok, exitCode? }`), reused rather than
|
|
26
|
+
* inventing a second one. */
|
|
27
|
+
export interface DdevHostsDeps {
|
|
28
|
+
run(cmd: string, args: string[], cwd?: string): Promise<{
|
|
29
|
+
readonly stdout: string;
|
|
30
|
+
readonly ok: boolean;
|
|
31
|
+
}>;
|
|
32
|
+
}
|
|
33
|
+
export type DdevHostsResult = {
|
|
34
|
+
readonly ok: true;
|
|
35
|
+
readonly hosts: DdevHosts;
|
|
36
|
+
} | {
|
|
37
|
+
readonly ok: false;
|
|
38
|
+
readonly reason: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Discover this worktree's effective DDEV hosts via `ddev utility configyaml`
|
|
42
|
+
* (DDEV's own merged config, YAML on stdout) — parsed with the project's
|
|
43
|
+
* existing `yaml` dependency, never a hand-written parser. A failed command,
|
|
44
|
+
* unparseable YAML, or any `parseDdevHosts` rejection all become `{ ok:
|
|
45
|
+
* false }` — never a thrown exception, so a caller converts this directly to
|
|
46
|
+
* an `unknown` claim instead of crashing discovery.
|
|
47
|
+
*/
|
|
48
|
+
export declare function discoverDdevHosts(worktree: string, deps: DdevHostsDeps): Promise<DdevHostsResult>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// BB-681 Task 1 — the project's effective DDEV hosts, read once from
|
|
2
|
+
// `ddev utility configyaml` (DDEV's own merged/expanded config, not the raw
|
|
3
|
+
// `.ddev/config.yaml` file, which can omit defaults DDEV fills in at
|
|
4
|
+
// runtime). DDEV alone owns calling/parsing this — no other addon reads
|
|
5
|
+
// `.ddev/config.yaml` for hostnames.
|
|
6
|
+
import { parse as parseYaml } from 'yaml';
|
|
7
|
+
/** Thrown by `parseDdevHosts` on any config shape it cannot safely derive
|
|
8
|
+
* hosts from — a caller converts this to an `unknown` discovery result
|
|
9
|
+
* rather than ever inventing a concrete host. */
|
|
10
|
+
export class DdevHostsError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'DdevHostsError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Lowercase/trailing-dot normalization, shared with anything that compares
|
|
17
|
+
* an observed `VIRTUAL_HOST` entry against a configured host (`claims.ts`'s
|
|
18
|
+
* alias-eligibility check). */
|
|
19
|
+
export function normalizeHost(host) {
|
|
20
|
+
return host.trim().toLowerCase().replace(/\.$/, '');
|
|
21
|
+
}
|
|
22
|
+
/** A bare `*` or a `*.`-prefixed entry is a wildcard — DDEV accepts these in
|
|
23
|
+
* `additional_hostnames`/`additional_fqdns`, but a wildcard is unsupported
|
|
24
|
+
* for individual host sharing: there is no single concrete hostname to
|
|
25
|
+
* derive from it, and inventing one would be a guess this module must never
|
|
26
|
+
* make. */
|
|
27
|
+
function isWildcard(host) {
|
|
28
|
+
return host.includes('*');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Derive `{ primary, additional }` from DDEV's effective config (the parsed
|
|
32
|
+
* `ddev utility configyaml` object, or a pre-parsed equivalent). Throws
|
|
33
|
+
* `DdevHostsError` on missing/invalid required fields, a wildcard host, or a
|
|
34
|
+
* collision between two inputs that normalize to the same host — never
|
|
35
|
+
* silently drops or invents one.
|
|
36
|
+
*/
|
|
37
|
+
export function parseDdevHosts(config) {
|
|
38
|
+
if (typeof config !== 'object' || config === null) {
|
|
39
|
+
throw new DdevHostsError('ddev config is not an object');
|
|
40
|
+
}
|
|
41
|
+
const c = config;
|
|
42
|
+
const name = c.name;
|
|
43
|
+
const projectTld = c.project_tld;
|
|
44
|
+
if (typeof name !== 'string' || !name) {
|
|
45
|
+
throw new DdevHostsError('ddev config is missing `name`');
|
|
46
|
+
}
|
|
47
|
+
if (typeof projectTld !== 'string' || !projectTld) {
|
|
48
|
+
throw new DdevHostsError('ddev config is missing `project_tld`');
|
|
49
|
+
}
|
|
50
|
+
const additionalHostnames = Array.isArray(c.additional_hostnames)
|
|
51
|
+
? c.additional_hostnames.filter((h) => typeof h === 'string' && h.length > 0)
|
|
52
|
+
: [];
|
|
53
|
+
const additionalFqdns = Array.isArray(c.additional_fqdns)
|
|
54
|
+
? c.additional_fqdns.filter((h) => typeof h === 'string' && h.length > 0)
|
|
55
|
+
: [];
|
|
56
|
+
const primaryRaw = `${name}.${projectTld}`;
|
|
57
|
+
if (isWildcard(primaryRaw)) {
|
|
58
|
+
throw new DdevHostsError('the primary host resolves to a wildcard — unsupported for individual host sharing');
|
|
59
|
+
}
|
|
60
|
+
const primary = normalizeHost(primaryRaw);
|
|
61
|
+
// Order of derivation: additional_hostnames first (expanded with
|
|
62
|
+
// project_tld), then additional_fqdns (literal) — a stable, input-order-
|
|
63
|
+
// independent derivation (reordering either source list must not change
|
|
64
|
+
// the resulting set), checked below by normalized-key collision, not by
|
|
65
|
+
// list position.
|
|
66
|
+
const rawAdditional = [
|
|
67
|
+
...additionalHostnames.map((h) => `${h}.${projectTld}`),
|
|
68
|
+
...additionalFqdns,
|
|
69
|
+
];
|
|
70
|
+
for (const host of rawAdditional) {
|
|
71
|
+
if (isWildcard(host)) {
|
|
72
|
+
throw new DdevHostsError(`additional host '${host}' is a wildcard — unsupported for individual host sharing`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const seen = new Map();
|
|
76
|
+
const additional = [];
|
|
77
|
+
for (const raw of rawAdditional) {
|
|
78
|
+
const normalized = normalizeHost(raw);
|
|
79
|
+
if (normalized === primary)
|
|
80
|
+
continue; // already covered by the primary claim
|
|
81
|
+
const prior = seen.get(normalized);
|
|
82
|
+
if (prior !== undefined && prior !== raw) {
|
|
83
|
+
throw new DdevHostsError(`two hosts normalize to the same value '${normalized}': '${prior}' and '${raw}'`);
|
|
84
|
+
}
|
|
85
|
+
if (prior === undefined) {
|
|
86
|
+
seen.set(normalized, raw);
|
|
87
|
+
additional.push(normalized);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { primary, additional };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Discover this worktree's effective DDEV hosts via `ddev utility configyaml`
|
|
94
|
+
* (DDEV's own merged config, YAML on stdout) — parsed with the project's
|
|
95
|
+
* existing `yaml` dependency, never a hand-written parser. A failed command,
|
|
96
|
+
* unparseable YAML, or any `parseDdevHosts` rejection all become `{ ok:
|
|
97
|
+
* false }` — never a thrown exception, so a caller converts this directly to
|
|
98
|
+
* an `unknown` claim instead of crashing discovery.
|
|
99
|
+
*/
|
|
100
|
+
export async function discoverDdevHosts(worktree, deps) {
|
|
101
|
+
let result;
|
|
102
|
+
try {
|
|
103
|
+
result = await deps.run('ddev', ['utility', 'configyaml'], worktree);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
reason: `ddev utility configyaml failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (!result.ok) {
|
|
112
|
+
return { ok: false, reason: 'ddev utility configyaml failed' };
|
|
113
|
+
}
|
|
114
|
+
let parsed;
|
|
115
|
+
try {
|
|
116
|
+
parsed = parseYaml(result.stdout);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
reason: `ddev utility configyaml output is not valid YAML: ${err instanceof Error ? err.message : String(err)}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
return { ok: true, hosts: parseDdevHosts(parsed) };
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { parseSocketListeners, type SocketListener, } from '@bakers-build/addon-docker-compose';
|
|
2
|
+
export { claimDdev } from './claims.js';
|
|
3
|
+
export { discoverDdevPorts, parseExposeMapping, parseWebExtraExposedPorts, } from './discovery.js';
|
|
4
|
+
export type { DdevHosts, DdevHostsDeps, DdevHostsResult } from './hosts.js';
|
|
5
|
+
export { DdevHostsError, discoverDdevHosts, normalizeHost, parseDdevHosts, } from './hosts.js';
|
|
6
|
+
export type { ClaimContext, ClaimedDiscovery, ClaimedService, ClaimStatus, HttpProbe, RawDiagnostic, RawDiscovery, RawPort, Tri, TunnelRule, } from './types.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// BB-676 pass-6 review cleanup — `parseSocketListeners`/`SocketListener`
|
|
2
|
+
// live in `@bakers-build/addon-docker-compose` (the lower-level package); routed
|
|
3
|
+
// through here because `@bakers-build/addon-ports` may not edge to
|
|
4
|
+
// `@bakers-build/addon-docker-compose` directly (see `scripts/assert-acyclic.mjs`).
|
|
5
|
+
export { parseSocketListeners, } from '@bakers-build/addon-docker-compose';
|
|
6
|
+
export { claimDdev } from './claims.js';
|
|
7
|
+
export { discoverDdevPorts, parseExposeMapping, parseWebExtraExposedPorts, } from './discovery.js';
|
|
8
|
+
export { DdevHostsError, discoverDdevHosts, normalizeHost, parseDdevHosts, } from './hosts.js';
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { DdevHosts } from './hosts.js';
|
|
2
|
+
export type { DdevHosts } from './hosts.js';
|
|
3
|
+
/** One OBSERVATION, not yet a claimed service. A port alone is not an
|
|
4
|
+
* identity (two containers, two site rules can share one). `RawPort`
|
|
5
|
+
* carries every piece of evidence a claimer needs, and a missing probe is
|
|
6
|
+
* `unknown` — never collapsed into `false` — so a permission-denied Docker
|
|
7
|
+
* query cannot read as "this is dead" and revoke a route that is actually
|
|
8
|
+
* still live. */
|
|
9
|
+
export type Tri = boolean | 'unknown';
|
|
10
|
+
export interface RawPort {
|
|
11
|
+
readonly worktree: string;
|
|
12
|
+
readonly containerId?: string;
|
|
13
|
+
readonly containerPort?: number;
|
|
14
|
+
readonly protocol?: 'tcp' | 'udp';
|
|
15
|
+
readonly hostAddress?: string;
|
|
16
|
+
readonly hostPort?: number;
|
|
17
|
+
/** The in-container bind address a listening socket reported, when known —
|
|
18
|
+
* used to tell Docker's embedded DNS resolver (`127.0.0.11`) apart from a
|
|
19
|
+
* real service when picking a dead claim's "what IS listening" sibling. */
|
|
20
|
+
readonly address?: string;
|
|
21
|
+
readonly pid?: number;
|
|
22
|
+
readonly cmdline?: string;
|
|
23
|
+
readonly cwd?: string;
|
|
24
|
+
readonly cwdDeleted?: boolean;
|
|
25
|
+
/** The container's IP on the `ddev_default` network, when it has one. */
|
|
26
|
+
readonly containerIp?: string;
|
|
27
|
+
/** The router-side port of an `HTTP_EXPOSE`/`HTTPS_EXPOSE` mapping. */
|
|
28
|
+
readonly routerPort?: number;
|
|
29
|
+
/** Which `*_EXPOSE` var produced this row's router mapping — the only
|
|
30
|
+
* reliable signal for the target scheme (B): a port that only appears in
|
|
31
|
+
* `HTTP_EXPOSE` must be dialled over plain HTTP, never HTTPS. */
|
|
32
|
+
readonly exposeScheme?: 'http' | 'https';
|
|
33
|
+
readonly virtualHost?: string;
|
|
34
|
+
/** The `com.ddev.service-type` label, when a project sets one. Real DDEV
|
|
35
|
+
* containers do not carry this by default — `composeService` below is the
|
|
36
|
+
* label that is actually present — but a project may still add it, so it
|
|
37
|
+
* is kept as the more specific naming source when it exists. */
|
|
38
|
+
readonly serviceType?: string;
|
|
39
|
+
/** The `com.docker.compose.service` label (`web`, `mailpit`, `phpmyadmin`,
|
|
40
|
+
* `opensearch`, …) — the naming evidence DDEV/compose actually sets on
|
|
41
|
+
* every service container; naming evidence for the claimer. */
|
|
42
|
+
readonly composeService?: string;
|
|
43
|
+
/** `supervisorctl`'s `group:name`, e.g. `webextradaemons:xhgui`. */
|
|
44
|
+
readonly daemon?: string;
|
|
45
|
+
readonly daemonStatus?: 'RUNNING' | 'STOPPED' | 'EXITED' | 'UNKNOWN';
|
|
46
|
+
readonly containerState?: 'running' | 'stopped' | 'unknown';
|
|
47
|
+
/** A real socket was observed bound to this port. */
|
|
48
|
+
readonly listening: Tri;
|
|
49
|
+
/** A router/DDEV rule claims this port, regardless of whether it listens. */
|
|
50
|
+
readonly exposed: Tri;
|
|
51
|
+
/** The host-bound port `docker inspect`'s `NetworkSettings.Ports` maps this
|
|
52
|
+
* `containerPort` to (BB-500 passthrough target), when published. */
|
|
53
|
+
readonly hostBoundPort?: number;
|
|
54
|
+
}
|
|
55
|
+
export interface RawDiagnostic {
|
|
56
|
+
readonly source: 'host' | 'docker' | 'web-sockets' | 'supervisor' | 'ddev-hosts';
|
|
57
|
+
readonly message: string;
|
|
58
|
+
/** D: the container this diagnostic is about, when it is about one in
|
|
59
|
+
* particular — see `ComposeDiagnostic.containerId` (`@bakers-build/addon-docker-compose`)
|
|
60
|
+
* for why this narrows reap's blast radius instead of widening it. */
|
|
61
|
+
readonly containerId?: string;
|
|
62
|
+
/** C: true when this `source: 'host'` diagnostic means the WHOLE host
|
|
63
|
+
* probe (`ss -ltnpH`) failed, blinding every host-backed service at once
|
|
64
|
+
* — the host-side equivalent of a `containerId`-less Docker diagnostic.
|
|
65
|
+
* A per-listener host diagnostic (e.g. "no visible owner") is about one
|
|
66
|
+
* socket, not the whole probe, and must not carry this flag. */
|
|
67
|
+
readonly wholePass?: boolean;
|
|
68
|
+
}
|
|
69
|
+
export interface RawDiscovery {
|
|
70
|
+
readonly ports: readonly RawPort[];
|
|
71
|
+
readonly diagnostics: readonly RawDiagnostic[];
|
|
72
|
+
}
|
|
73
|
+
export interface TunnelRule {
|
|
74
|
+
readonly key: string;
|
|
75
|
+
readonly service: string;
|
|
76
|
+
readonly hostHeader?: string;
|
|
77
|
+
readonly insecureTls?: boolean;
|
|
78
|
+
/** Plain-HTTP origin of the service container itself
|
|
79
|
+
* (`http://<ddev_default IP>:<containerPort>`), reachable with the public
|
|
80
|
+
* Host kept; only transports that can dial container IPs (frp) use it. */
|
|
81
|
+
readonly direct?: string;
|
|
82
|
+
}
|
|
83
|
+
export type ClaimStatus = 'healthy' | 'dead' | 'stopped' | 'not-exposed' | 'unknown';
|
|
84
|
+
export interface ClaimedService {
|
|
85
|
+
readonly key: string;
|
|
86
|
+
/** The claimer that owns this service — `'process'` (a configured ports
|
|
87
|
+
* rule), `'node'` (the all-Node rule) or `'ddev'`. */
|
|
88
|
+
readonly claim: string;
|
|
89
|
+
readonly status: ClaimStatus;
|
|
90
|
+
/** A local address worth opening on this machine. */
|
|
91
|
+
readonly local: string;
|
|
92
|
+
/** Set when the service is shareable but with a known gap (e.g. no login). */
|
|
93
|
+
readonly limitation?: string;
|
|
94
|
+
/** The raw evidence this claim rests on, for `list` and diagnostics. */
|
|
95
|
+
readonly rawPorts: readonly RawPort[];
|
|
96
|
+
/** Empty unless `status === 'healthy'`. */
|
|
97
|
+
readonly rules: readonly TunnelRule[];
|
|
98
|
+
}
|
|
99
|
+
export interface ClaimedDiscovery {
|
|
100
|
+
readonly services: readonly ClaimedService[];
|
|
101
|
+
/** Surfaced raw-discovery diagnostics — a caller (reap) must be able to
|
|
102
|
+
* tell "nothing is here" from "the probe itself could not see", so a
|
|
103
|
+
* denied Docker query cannot read as every service having vanished. */
|
|
104
|
+
readonly diagnostics: readonly RawDiagnostic[];
|
|
105
|
+
/** Every raw identity no claimer picked up — `list` shows these dimmed,
|
|
106
|
+
* literally "no addon" (spec): visible, but never shareable. */
|
|
107
|
+
readonly unclaimed: readonly RawPort[];
|
|
108
|
+
}
|
|
109
|
+
/** A single bounded, non-redirecting HTTP probe — injected, never a real fetch here. */
|
|
110
|
+
export type HttpProbe = (url: string, opts: {
|
|
111
|
+
readonly timeoutMs: number;
|
|
112
|
+
}) => Promise<{
|
|
113
|
+
readonly ok: boolean;
|
|
114
|
+
readonly status: number;
|
|
115
|
+
readonly body: string;
|
|
116
|
+
} | null>;
|
|
117
|
+
export interface ClaimContext {
|
|
118
|
+
readonly worktree: string;
|
|
119
|
+
readonly ticket: string;
|
|
120
|
+
readonly zone: string;
|
|
121
|
+
readonly probe: HttpProbe;
|
|
122
|
+
/** `.ddev/config.yaml`'s `web_extra_exposed_ports[].name`, keyed by
|
|
123
|
+
* `container_port` — the declared name for a non-primary-web service
|
|
124
|
+
* (vite, xhgui, …) sharing the web container, so a dead/live claim on that
|
|
125
|
+
* port surfaces under its configured name instead of the web container's
|
|
126
|
+
* generic `web` compose-service label. */
|
|
127
|
+
readonly webExtraExposedPortNames?: ReadonlyMap<number, string>;
|
|
128
|
+
/** BB-681 Task 1 — the project's effective DDEV hosts (primary +
|
|
129
|
+
* additional), from `ddev utility configyaml`. Absent/unparseable config
|
|
130
|
+
* means no individual-host splitting is attempted — the primary `web`
|
|
131
|
+
* claim alone still works, never invented from a guess. */
|
|
132
|
+
readonly hosts?: DdevHosts;
|
|
133
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// BB-676 spec v2 — the claim contract, owned here since DDEV is the shape's
|
|
2
|
+
// primary producer (routerPort/virtualHost/daemon/daemonStatus/containerState
|
|
3
|
+
// all come from DDEV conventions). `@bakers-build/addon-ports` imports these types
|
|
4
|
+
// (its Storybook claimer also produces `RawPort`-shaped values, for host
|
|
5
|
+
// processes) rather than redeclaring them, so the two packages share one
|
|
6
|
+
// contract; the dependency edge runs addon-ports -> addon-ddev only (the
|
|
7
|
+
// acyclic package graph permits no edge back).
|
|
8
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakers-build/addon-ddev",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-next.42",
|
|
4
|
+
"description": "DDEV-specific service discovery and claiming: HTTP(S)_EXPOSE/VIRTUAL_HOST interpretation over @bakers-build/addon-docker-compose's generic container data, plus effective-host discovery via `ddev utility configyaml`. No CMS-specific policy lives here.",
|
|
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/ddev"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@bakers-build/addon-docker-compose": "^0.1.0-next.42",
|
|
23
|
+
"yaml": "^2.7.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@bakers-build/core": "^0.1.0-next.42"
|
|
27
|
+
}
|
|
28
|
+
}
|