@bakers-build/addon-ports 0.0.0-stage → 0.1.0-next.38
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/claim-share.d.ts +33 -0
- package/dist/src/claim-share.js +38 -0
- package/dist/src/claims.d.ts +39 -0
- package/dist/src/claims.js +286 -0
- package/dist/src/config.d.ts +40 -0
- package/dist/src/config.js +158 -0
- package/dist/src/core.d.ts +10 -0
- package/dist/src/core.js +15 -0
- package/dist/src/deps-node.d.ts +91 -0
- package/dist/src/deps-node.js +234 -0
- package/dist/src/discovery.d.ts +28 -0
- package/dist/src/discovery.js +100 -0
- package/dist/src/inbox.d.ts +35 -0
- package/dist/src/inbox.js +91 -0
- package/dist/src/index.d.ts +92 -0
- package/dist/src/index.js +525 -0
- package/dist/src/lock.d.ts +21 -0
- package/dist/src/lock.js +148 -0
- package/dist/src/preset.d.ts +6 -0
- package/dist/src/preset.js +30 -0
- package/dist/src/service-config.d.ts +27 -0
- package/dist/src/service-config.js +111 -0
- package/dist/src/state.d.ts +15 -0
- package/dist/src/state.js +39 -0
- package/dist/src/transport-contract.d.ts +2 -0
- package/dist/src/transport-contract.js +9 -0
- package/dist/src/transport.d.ts +46 -0
- package/dist/src/transport.js +37 -0
- package/dist/src/tunnel-ops.d.ts +76 -0
- package/dist/src/tunnel-ops.js +302 -0
- package/dist/src/tunnel-result.d.ts +6 -0
- package/dist/src/tunnel-result.js +41 -0
- package/dist/src/tunnel-state.d.ts +94 -0
- package/dist/src/tunnel-state.js +218 -0
- package/dist/src/types.d.ts +18 -0
- package/dist/src/types.js +11 -0
- package/package.json +31 -4
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { HttpProbe } from './claims.js';
|
|
2
|
+
import type { HostDiscoveryDeps } from './discovery.js';
|
|
3
|
+
import { type StateDeps } from './state.js';
|
|
4
|
+
/** The filesystem seams for the state file. */
|
|
5
|
+
export declare const nodeStateDeps: StateDeps;
|
|
6
|
+
/** The discovery seams: `ss` and `/proc/<pid>`. */
|
|
7
|
+
export declare const nodeDiscoveryDeps: HostDiscoveryDeps;
|
|
8
|
+
/**
|
|
9
|
+
* AC6 — `/proc/<pid>/stat`'s 22nd field (start time, in clock ticks since
|
|
10
|
+
* boot): a stronger PID-reuse guard than cmdline/deleted-cwd alone, which
|
|
11
|
+
* could coincidentally match a reused pid running a similar-looking process.
|
|
12
|
+
* Pure and exported so the field-counting (the 2nd field is `(comm)`, which
|
|
13
|
+
* may itself contain spaces/parens) is directly testable without `/proc`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseProcStatStartTime(stat: string): number | null;
|
|
16
|
+
/** `null` when the process is gone or the field cannot be parsed — no
|
|
17
|
+
* process to compare a start time against either way. */
|
|
18
|
+
export declare function processStartTime(pid: number): Promise<number | null>;
|
|
19
|
+
/**
|
|
20
|
+
* BB-681 Task 4 — a process whose cmdline carries ANY pluggable-transport
|
|
21
|
+
* package's own ownership marker: `addon-ports-cloudflare-quick`'s private
|
|
22
|
+
* per-route `--config <BB_HOME>/ports/quick/<hash>/gen-<n>/config.yaml`,
|
|
23
|
+
* or a Named Cloudflare connector's `tunnel run` invocation. This generic
|
|
24
|
+
* legacy-reap scan predates per-route managed processes (BB-676) and its
|
|
25
|
+
* only signal for "legacy" is `cmdline.includes('--url')` (see
|
|
26
|
+
* `index.ts`'s reap handler) — which the new Quick package's OWN managed
|
|
27
|
+
* processes also match, since they use the same `--url` flag. Without this
|
|
28
|
+
* guard, generic reap would treat every quick-tunnel package process as an
|
|
29
|
+
* orphan it's free to kill; the quick package owns its OWN revalidated
|
|
30
|
+
* reap (`reconcileQuick`'s `desired: 'absent'` path) and this generic scan
|
|
31
|
+
* must never duplicate or race it.
|
|
32
|
+
*/
|
|
33
|
+
export declare function isPluggableTransportManaged(cmdline: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* BB-676 Task 4 — scans the process table for `cloudflared`, keeping
|
|
36
|
+
* `cmdline` so a caller can tell a legacy `tunnel --url …` quick-tunnel
|
|
37
|
+
* (reap's cleanup target) apart from the machine's named `tunnel run`
|
|
38
|
+
* connector, which reap must never touch (plan review focus 4). BB-681
|
|
39
|
+
* Task 4: also excludes anything `isPluggableTransportManaged` recognizes —
|
|
40
|
+
* a pluggable transport's own managed process, never generic reap's to kill.
|
|
41
|
+
*/
|
|
42
|
+
export declare function listCloudflaredProcesses(): Promise<{
|
|
43
|
+
pid: number;
|
|
44
|
+
cwd: string;
|
|
45
|
+
deleted: boolean;
|
|
46
|
+
cmdline: string;
|
|
47
|
+
startTime: number | null;
|
|
48
|
+
}[]>;
|
|
49
|
+
/**
|
|
50
|
+
* AC6 — read a worktree's OLD pre-BB-676 per-port quick-tunnel state
|
|
51
|
+
* (`.bakers/ports-tunnels.json`, written by the `transport.ts`/`state.ts` path
|
|
52
|
+
* this feature replaced) purely as migration evidence: a legacy-process
|
|
53
|
+
* candidate whose pid appears here is additional corroboration reap may use,
|
|
54
|
+
* never a replacement for the existing argv/owner/cwd checks. Never written
|
|
55
|
+
* or removed by this addon. Absent or malformed reads as "no evidence", not
|
|
56
|
+
* an error — a half-written or already-cleaned-up legacy file must not block
|
|
57
|
+
* reap.
|
|
58
|
+
*/
|
|
59
|
+
export declare function readLegacyTunnelPids(worktree: string): Promise<ReadonlySet<number>>;
|
|
60
|
+
/**
|
|
61
|
+
* Is `pid` owned by this process's own user? Legacy reap must never kill
|
|
62
|
+
* another user's process (plan review focus 4) — even an own-user match is
|
|
63
|
+
* re-validated by the caller against fresh `processInfo` immediately before
|
|
64
|
+
* `SIGTERM`, to close the PID-reuse race between listing and killing.
|
|
65
|
+
*/
|
|
66
|
+
export declare function isOwnedByCurrentUser(pid: number): Promise<boolean>;
|
|
67
|
+
/** BB-676 Task 4 — a bounded, non-redirecting Storybook probe over real `fetch`. */
|
|
68
|
+
export declare const nodeHttpProbe: HttpProbe;
|
|
69
|
+
/** Everything a command needs, bundled. */
|
|
70
|
+
export declare const nodeDeps: {
|
|
71
|
+
run(cmd: string, args: string[], cwd?: string): Promise<{
|
|
72
|
+
stdout: string;
|
|
73
|
+
ok: boolean;
|
|
74
|
+
}>;
|
|
75
|
+
processInfo(pid: number): Promise<{
|
|
76
|
+
path: string;
|
|
77
|
+
deleted: boolean;
|
|
78
|
+
cmdline: string;
|
|
79
|
+
} | null>;
|
|
80
|
+
readFile(path: string): Promise<string | null>;
|
|
81
|
+
};
|
|
82
|
+
/** BB-681 Task 2 — everything `bringUpTunnel`/`takeDownTunnel`/`reapTunnels`
|
|
83
|
+
* need: state + lock I/O only. Provider I/O (e.g. Cloudflare's REST API)
|
|
84
|
+
* lives inside the configured transport package instead. */
|
|
85
|
+
export declare const nodeTunnelOpsDeps: {
|
|
86
|
+
spawn: typeof import("child_process").spawn;
|
|
87
|
+
readFile(path: string): Promise<string>;
|
|
88
|
+
writeFile(path: string, data: string, mode: number): Promise<void>;
|
|
89
|
+
rename(from: string, to: string): Promise<void>;
|
|
90
|
+
mkdir(path: string): Promise<void>;
|
|
91
|
+
};
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// BB-475 — the real seams behind discovery and transport, for the CLI.
|
|
2
|
+
//
|
|
3
|
+
// The I/O edge of the package: the `defaultSpawn` half of the conductor-host
|
|
4
|
+
// pattern. Everything here is substituted in tests; nothing here is unit-tested,
|
|
5
|
+
// exactly as `createConductorProcessHost`'s defaults are not — the seams exist so
|
|
6
|
+
// the LOGIC above them can be. `reap` and `discoverHostPorts` above carry the
|
|
7
|
+
// behaviour; this file only wires them to `child_process` and `/proc`.
|
|
8
|
+
import { execFile as execFileCallback } from 'node:child_process';
|
|
9
|
+
import { readFile, readlink, stat } from 'node:fs/promises';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
import { nodeLockDeps } from './lock.js';
|
|
12
|
+
import { readTunnelState } from './state.js';
|
|
13
|
+
import { nodeTunnelStateDeps } from './tunnel-state.js';
|
|
14
|
+
// `execFile`, never `exec`: no shell, so a value that ends up in an argument —
|
|
15
|
+
// notably a tunnel `target` derived from a DISCOVERED service address — cannot
|
|
16
|
+
// be interpreted as a shell metacharacter. The argv array is the whole point.
|
|
17
|
+
const execFile = promisify(execFileCallback);
|
|
18
|
+
// G: a paused/hung container must not hang `list`/`reap` (which holds the
|
|
19
|
+
// machine lock) forever — `docker exec`/`inspect`/`ps` all get a bounded
|
|
20
|
+
// deadline; a timeout is reported as a failed probe (surfaced as a
|
|
21
|
+
// diagnostic upstream), never left to hang.
|
|
22
|
+
const EXEC_TIMEOUT_MS = 5_000;
|
|
23
|
+
/** The filesystem seams for the state file. */
|
|
24
|
+
export const nodeStateDeps = {
|
|
25
|
+
readFile: async (path) => {
|
|
26
|
+
try {
|
|
27
|
+
return await readFile(path, 'utf8');
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
/** The discovery seams: `ss` and `/proc/<pid>`. */
|
|
35
|
+
export const nodeDiscoveryDeps = {
|
|
36
|
+
run: async (cmd, args, cwd) => {
|
|
37
|
+
try {
|
|
38
|
+
const { stdout } = await execFile(cmd, args, {
|
|
39
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
40
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
41
|
+
...(cwd ? { cwd } : {}),
|
|
42
|
+
});
|
|
43
|
+
return { stdout, ok: true };
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
// A non-zero exit still carries stdout for a command like `ddev describe`
|
|
47
|
+
// outside a project; hand it back so the parser can decide. `code` is
|
|
48
|
+
// the numeric exit code when the process actually ran (e.g. 127 =
|
|
49
|
+
// command not found inside the container); a spawn-level failure
|
|
50
|
+
// carries a string errno instead, so only a number is surfaced.
|
|
51
|
+
// `stderr` is also threaded through (BB-681 review I3): without it,
|
|
52
|
+
// `isPermissionDenied126` can never fire against a REAL `docker exec`,
|
|
53
|
+
// so every exit 126 reads as a missing binary even when the container
|
|
54
|
+
// actually refused for a permission reason.
|
|
55
|
+
const withOut = err;
|
|
56
|
+
const exitCode = typeof withOut.code === 'number' ? withOut.code : undefined;
|
|
57
|
+
return {
|
|
58
|
+
stdout: withOut.stdout ?? '',
|
|
59
|
+
ok: false,
|
|
60
|
+
...(exitCode !== undefined ? { exitCode } : {}),
|
|
61
|
+
...(withOut.stderr ? { stderr: withOut.stderr } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
processInfo: async (pid) => {
|
|
66
|
+
try {
|
|
67
|
+
const link = await readlink(`/proc/${pid}/cwd`);
|
|
68
|
+
// The kernel appends ` (deleted)` to the symlink TARGET when the working
|
|
69
|
+
// directory has been removed — the orphan signal (spec C3). `stat` on the
|
|
70
|
+
// magic link resolves the CURRENT directory, so it throws ENOENT for a
|
|
71
|
+
// reaped worktree; the two agree, and the stat is the reliable one.
|
|
72
|
+
const cmdlineRaw = await readFile(`/proc/${pid}/cmdline`, 'utf8').catch(() => '');
|
|
73
|
+
const cmdline = cmdlineRaw.replace(/\0/g, ' ').trim();
|
|
74
|
+
const deleted = link.endsWith(' (deleted)') ||
|
|
75
|
+
!(await stat(`/proc/${pid}/cwd`)
|
|
76
|
+
.then(() => true)
|
|
77
|
+
.catch(() => false));
|
|
78
|
+
return {
|
|
79
|
+
path: link.replace(/ \(deleted\)$/, ''),
|
|
80
|
+
deleted,
|
|
81
|
+
cmdline,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* AC6 — `/proc/<pid>/stat`'s 22nd field (start time, in clock ticks since
|
|
91
|
+
* boot): a stronger PID-reuse guard than cmdline/deleted-cwd alone, which
|
|
92
|
+
* could coincidentally match a reused pid running a similar-looking process.
|
|
93
|
+
* Pure and exported so the field-counting (the 2nd field is `(comm)`, which
|
|
94
|
+
* may itself contain spaces/parens) is directly testable without `/proc`.
|
|
95
|
+
*/
|
|
96
|
+
export function parseProcStatStartTime(stat) {
|
|
97
|
+
const afterComm = stat.slice(stat.lastIndexOf(')') + 2);
|
|
98
|
+
const fields = afterComm.split(' ');
|
|
99
|
+
// Field 3 is state (already consumed above as field 2's successor is index
|
|
100
|
+
// 0 here); starttime is the 22nd field overall, i.e. index 19 of this
|
|
101
|
+
// remainder (22 - 3 = 19, since fields 1-2 are `pid`/`comm`).
|
|
102
|
+
const startTime = Number(fields[19]);
|
|
103
|
+
return Number.isFinite(startTime) ? startTime : null;
|
|
104
|
+
}
|
|
105
|
+
/** `null` when the process is gone or the field cannot be parsed — no
|
|
106
|
+
* process to compare a start time against either way. */
|
|
107
|
+
export async function processStartTime(pid) {
|
|
108
|
+
try {
|
|
109
|
+
return parseProcStatStartTime(await readFile(`/proc/${pid}/stat`, 'utf8'));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* BB-681 Task 4 — a process whose cmdline carries ANY pluggable-transport
|
|
117
|
+
* package's own ownership marker: `addon-ports-cloudflare-quick`'s private
|
|
118
|
+
* per-route `--config <BB_HOME>/ports/quick/<hash>/gen-<n>/config.yaml`,
|
|
119
|
+
* or a Named Cloudflare connector's `tunnel run` invocation. This generic
|
|
120
|
+
* legacy-reap scan predates per-route managed processes (BB-676) and its
|
|
121
|
+
* only signal for "legacy" is `cmdline.includes('--url')` (see
|
|
122
|
+
* `index.ts`'s reap handler) — which the new Quick package's OWN managed
|
|
123
|
+
* processes also match, since they use the same `--url` flag. Without this
|
|
124
|
+
* guard, generic reap would treat every quick-tunnel package process as an
|
|
125
|
+
* orphan it's free to kill; the quick package owns its OWN revalidated
|
|
126
|
+
* reap (`reconcileQuick`'s `desired: 'absent'` path) and this generic scan
|
|
127
|
+
* must never duplicate or race it.
|
|
128
|
+
*/
|
|
129
|
+
export function isPluggableTransportManaged(cmdline) {
|
|
130
|
+
if (/\/ports\/quick\//.test(cmdline))
|
|
131
|
+
return true; // addon-ports-cloudflare-quick
|
|
132
|
+
if (/\btunnel\s+run\b/.test(cmdline))
|
|
133
|
+
return true; // Named Cloudflare connector
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* BB-676 Task 4 — scans the process table for `cloudflared`, keeping
|
|
138
|
+
* `cmdline` so a caller can tell a legacy `tunnel --url …` quick-tunnel
|
|
139
|
+
* (reap's cleanup target) apart from the machine's named `tunnel run`
|
|
140
|
+
* connector, which reap must never touch (plan review focus 4). BB-681
|
|
141
|
+
* Task 4: also excludes anything `isPluggableTransportManaged` recognizes —
|
|
142
|
+
* a pluggable transport's own managed process, never generic reap's to kill.
|
|
143
|
+
*/
|
|
144
|
+
export async function listCloudflaredProcesses() {
|
|
145
|
+
const { stdout } = await execFile('ps', ['-eo', 'pid,comm'], {
|
|
146
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
147
|
+
}).catch(() => ({
|
|
148
|
+
stdout: '',
|
|
149
|
+
}));
|
|
150
|
+
const processes = [];
|
|
151
|
+
for (const line of stdout.split('\n')) {
|
|
152
|
+
if (!/cloudflared/.test(line))
|
|
153
|
+
continue;
|
|
154
|
+
const pid = Number(line.trim().split(/\s+/)[0]);
|
|
155
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
156
|
+
continue;
|
|
157
|
+
const info = await nodeDiscoveryDeps.processInfo(pid);
|
|
158
|
+
if (info && !isPluggableTransportManaged(info.cmdline))
|
|
159
|
+
processes.push({
|
|
160
|
+
pid,
|
|
161
|
+
cwd: info.path,
|
|
162
|
+
deleted: info.deleted,
|
|
163
|
+
cmdline: info.cmdline,
|
|
164
|
+
startTime: await processStartTime(pid),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return processes;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* AC6 — read a worktree's OLD pre-BB-676 per-port quick-tunnel state
|
|
171
|
+
* (`.bakers/ports-tunnels.json`, written by the `transport.ts`/`state.ts` path
|
|
172
|
+
* this feature replaced) purely as migration evidence: a legacy-process
|
|
173
|
+
* candidate whose pid appears here is additional corroboration reap may use,
|
|
174
|
+
* never a replacement for the existing argv/owner/cwd checks. Never written
|
|
175
|
+
* or removed by this addon. Absent or malformed reads as "no evidence", not
|
|
176
|
+
* an error — a half-written or already-cleaned-up legacy file must not block
|
|
177
|
+
* reap.
|
|
178
|
+
*/
|
|
179
|
+
export async function readLegacyTunnelPids(worktree) {
|
|
180
|
+
const state = await readTunnelState(worktree, nodeStateDeps);
|
|
181
|
+
return new Set(state.tunnels.map((t) => t.pid));
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Is `pid` owned by this process's own user? Legacy reap must never kill
|
|
185
|
+
* another user's process (plan review focus 4) — even an own-user match is
|
|
186
|
+
* re-validated by the caller against fresh `processInfo` immediately before
|
|
187
|
+
* `SIGTERM`, to close the PID-reuse race between listing and killing.
|
|
188
|
+
*/
|
|
189
|
+
export async function isOwnedByCurrentUser(pid) {
|
|
190
|
+
try {
|
|
191
|
+
const status = await readFile(`/proc/${pid}/status`, 'utf8');
|
|
192
|
+
const match = /^Uid:\s+(\d+)/m.exec(status);
|
|
193
|
+
return (match !== undefined &&
|
|
194
|
+
match !== null &&
|
|
195
|
+
Number(match[1]) === process.getuid?.());
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** BB-676 Task 4 — a bounded, non-redirecting Storybook probe over real `fetch`. */
|
|
202
|
+
export const nodeHttpProbe = async (url, opts) => {
|
|
203
|
+
const controller = new AbortController();
|
|
204
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
|
|
205
|
+
try {
|
|
206
|
+
const res = await fetch(url, {
|
|
207
|
+
signal: controller.signal,
|
|
208
|
+
redirect: 'manual',
|
|
209
|
+
});
|
|
210
|
+
if (res.type === 'opaqueredirect' ||
|
|
211
|
+
(res.status >= 300 && res.status < 400))
|
|
212
|
+
return null;
|
|
213
|
+
const body = await res.text();
|
|
214
|
+
return { ok: res.ok, status: res.status, body };
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
/** Everything a command needs, bundled. */
|
|
224
|
+
export const nodeDeps = {
|
|
225
|
+
...nodeStateDeps,
|
|
226
|
+
...nodeDiscoveryDeps,
|
|
227
|
+
};
|
|
228
|
+
/** BB-681 Task 2 — everything `bringUpTunnel`/`takeDownTunnel`/`reapTunnels`
|
|
229
|
+
* need: state + lock I/O only. Provider I/O (e.g. Cloudflare's REST API)
|
|
230
|
+
* lives inside the configured transport package instead. */
|
|
231
|
+
export const nodeTunnelOpsDeps = {
|
|
232
|
+
...nodeTunnelStateDeps,
|
|
233
|
+
...nodeLockDeps,
|
|
234
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { parseSocketListeners, type RawDiscovery, type SocketListener } from '@bakers-build/addon-ddev';
|
|
2
|
+
export { parseSocketListeners, type SocketListener };
|
|
3
|
+
/** The injected seams for host-listener discovery. */
|
|
4
|
+
export interface HostDiscoveryDeps {
|
|
5
|
+
/** Run a command, capturing stdout; `ok` is false when it could not run. */
|
|
6
|
+
run(cmd: string, args: string[], cwd?: string): Promise<{
|
|
7
|
+
stdout: string;
|
|
8
|
+
ok: boolean;
|
|
9
|
+
}>;
|
|
10
|
+
/**
|
|
11
|
+
* A process's working directory and command line — `/proc/<pid>/cwd` and
|
|
12
|
+
* `/proc/<pid>/cmdline`. `deleted` is the `(deleted)` suffix `readlink`
|
|
13
|
+
* reports when the directory is gone (the orphan signal), and `cmdline` is
|
|
14
|
+
* what names the service: the socket table gives only `node`, and `node` is
|
|
15
|
+
* Storybook, Vite and a dozen other things.
|
|
16
|
+
*/
|
|
17
|
+
processInfo(pid: number): Promise<{
|
|
18
|
+
path: string;
|
|
19
|
+
deleted: boolean;
|
|
20
|
+
cmdline: string;
|
|
21
|
+
} | null>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Discover this worktree's host listeners (AC1): `ss -ltnpH`, dedupe PIDs
|
|
25
|
+
* before reading `/proc` (one read per pid), match owner against the
|
|
26
|
+
* worktree. `list` calls only this — no Docker, no Cloudflare, no mutation.
|
|
27
|
+
*/
|
|
28
|
+
export declare function discoverHostPorts(worktree: string, deps: HostDiscoveryDeps): Promise<RawDiscovery>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// BB-676 spec v2 — host-listener discovery only. Docker/DDEV discovery
|
|
2
|
+
// moved to `@bakers-build/addon-docker-compose`/`@bakers-build/addon-ddev` (operator
|
|
3
|
+
// decision 2026-10-15: `bakers ports` must not fire `docker` itself). This file
|
|
4
|
+
// now does exactly one thing: `ss -ltnpH` plus `/proc/<pid>` — no external
|
|
5
|
+
// binary beyond `ss`, no root.
|
|
6
|
+
//
|
|
7
|
+
// The parsers are pure and exported so a test drives them with captured
|
|
8
|
+
// output; the orchestrator takes an injected `run`/`processInfo` so no real
|
|
9
|
+
// socket is ever read in a test.
|
|
10
|
+
import { parseSocketListeners, } from '@bakers-build/addon-ddev';
|
|
11
|
+
// BB-676 pass-6 review cleanup — `parseSocketListeners`/`SocketListener`
|
|
12
|
+
// used to be reimplemented here byte-for-byte; `@bakers-build/addon-docker-compose`
|
|
13
|
+
// is the one home now (the lower-level package), re-exported through
|
|
14
|
+
// `@bakers-build/addon-ddev` because this addon may not edge to
|
|
15
|
+
// `@bakers-build/addon-docker-compose` directly (`scripts/assert-acyclic.mjs`).
|
|
16
|
+
export { parseSocketListeners };
|
|
17
|
+
/** Is `path` inside `worktree` (or the worktree itself)? */
|
|
18
|
+
function underWorktree(path, worktree) {
|
|
19
|
+
const root = worktree.replace(/\/+$/, '');
|
|
20
|
+
return path === root || path.startsWith(`${root}/`);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Discover this worktree's host listeners (AC1): `ss -ltnpH`, dedupe PIDs
|
|
24
|
+
* before reading `/proc` (one read per pid), match owner against the
|
|
25
|
+
* worktree. `list` calls only this — no Docker, no Cloudflare, no mutation.
|
|
26
|
+
*/
|
|
27
|
+
export async function discoverHostPorts(worktree, deps) {
|
|
28
|
+
const diagnostics = [];
|
|
29
|
+
const ports = [];
|
|
30
|
+
// C3: a listener is only THIS worktree's observation when its owning
|
|
31
|
+
// process's cwd resolves under it. A foreign worktree's listener (another
|
|
32
|
+
// ticket, the main checkout) is not included at all — not even as an
|
|
33
|
+
// orphan, because it was never ours. A pid-less row (no permission to see
|
|
34
|
+
// the owner) cannot be attributed to any worktree, so it is also excluded
|
|
35
|
+
// here, with a diagnostic recording that evidence was incomplete.
|
|
36
|
+
// macOS compatibility: `ss`/`/proc` are Linux-only (ticket scope: "Linux
|
|
37
|
+
// discovery") — there is no BSD/macOS equivalent attempted here (lsof/
|
|
38
|
+
// netstat parity is out of scope). Rather than let a raw `ENOENT` surface
|
|
39
|
+
// as "ss -ltnpH failed" with no further context, name the real cause when
|
|
40
|
+
// we know it, so this reads as an honest platform limitation rather than a
|
|
41
|
+
// mysterious probe failure. Docker/DDEV-routed services are unaffected —
|
|
42
|
+
// the Linux VM behind Docker Desktop still answers these probes fine
|
|
43
|
+
// in-container; only THIS host-process scan is skipped.
|
|
44
|
+
const host = await deps.run('ss', ['-ltnpH']);
|
|
45
|
+
if (!host.ok) {
|
|
46
|
+
diagnostics.push({
|
|
47
|
+
source: 'host',
|
|
48
|
+
wholePass: true,
|
|
49
|
+
message: process.platform !== 'linux'
|
|
50
|
+
? `host-process discovery requires Linux (\`ss\`/\`/proc\`), not available on ${process.platform}; container-routed services are still discoverable`
|
|
51
|
+
: '`ss -ltnpH` failed',
|
|
52
|
+
});
|
|
53
|
+
return { ports, diagnostics };
|
|
54
|
+
}
|
|
55
|
+
const listeners = parseSocketListeners(host.stdout);
|
|
56
|
+
const pids = [
|
|
57
|
+
...new Set(listeners
|
|
58
|
+
.map((l) => l.pid)
|
|
59
|
+
.filter((pid) => pid !== undefined)),
|
|
60
|
+
];
|
|
61
|
+
const infoByPid = new Map();
|
|
62
|
+
await Promise.all(pids.map(async (pid) => infoByPid.set(pid, await deps.processInfo(pid))));
|
|
63
|
+
for (const listener of listeners) {
|
|
64
|
+
if (listener.pid === undefined) {
|
|
65
|
+
diagnostics.push({
|
|
66
|
+
source: 'host',
|
|
67
|
+
message: `listening port ${listener.port} has no visible owner (permission?); evidence incomplete`,
|
|
68
|
+
});
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const info = infoByPid.get(listener.pid) ?? null;
|
|
72
|
+
// cloudflared is the transport, never a service to tunnel (BB-500).
|
|
73
|
+
if (info?.cmdline.toLowerCase().includes('cloudflared'))
|
|
74
|
+
continue;
|
|
75
|
+
if (!info) {
|
|
76
|
+
diagnostics.push({
|
|
77
|
+
source: 'host',
|
|
78
|
+
message: `pid ${listener.pid} on port ${listener.port} vanished before its cwd could be read`,
|
|
79
|
+
});
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// A deleted cwd under this worktree is a genuine orphan (kept, no rule);
|
|
83
|
+
// a deleted or live cwd under a DIFFERENT worktree is simply not this
|
|
84
|
+
// worktree's listener.
|
|
85
|
+
if (!underWorktree(info.path, worktree))
|
|
86
|
+
continue;
|
|
87
|
+
ports.push({
|
|
88
|
+
worktree,
|
|
89
|
+
hostAddress: listener.address,
|
|
90
|
+
hostPort: listener.port,
|
|
91
|
+
pid: listener.pid,
|
|
92
|
+
cmdline: info.cmdline,
|
|
93
|
+
cwd: info.path,
|
|
94
|
+
cwdDeleted: info.deleted,
|
|
95
|
+
listening: true,
|
|
96
|
+
exposed: 'unknown',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return { ports, diagnostics };
|
|
100
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { InboxMessageHandler } from '@bakers-build/conductor/contract';
|
|
2
|
+
import type { ClaimedDiscovery, ClaimedService } from './claims.js';
|
|
3
|
+
import { type OperationContext, type PortsTransport, type TunnelDesiredRecord } from './core.js';
|
|
4
|
+
/** The I/O the handler needs, injected so a test drives it with fixtures.
|
|
5
|
+
* `loadTransport` is the SAME shared loader the CLI uses (`config.ts`'s
|
|
6
|
+
* `loadPortsTransport`) — one code path for both, so settings validation
|
|
7
|
+
* cannot be bypassed by going through the inbox instead of the CLI. A
|
|
8
|
+
* missing/invalid/unresolvable transport resolves to `null`: the message
|
|
9
|
+
* answers a stable "no ports configuration" line rather than throwing a
|
|
10
|
+
* raw stack trace into the ticket's addon card. */
|
|
11
|
+
export interface TunnelHandlerDeps {
|
|
12
|
+
discover(worktree: string, ticket: string, zone: string): Promise<ClaimedDiscovery>;
|
|
13
|
+
readRecords(worktree: string): Promise<readonly TunnelDesiredRecord[]>;
|
|
14
|
+
bringUp(service: ClaimedService, ctx: OperationContext): Promise<{
|
|
15
|
+
ok: boolean;
|
|
16
|
+
message: string;
|
|
17
|
+
}>;
|
|
18
|
+
/** Same shared loader the CLI uses, surfaced with its own redacted
|
|
19
|
+
* diagnostic on failure — BB-681 review I5: a missing config and an
|
|
20
|
+
* unresolvable/invalid transport package are different problems and must
|
|
21
|
+
* not collapse onto one generic "no configuration" line here while the
|
|
22
|
+
* CLI reports the real one. */
|
|
23
|
+
loadTransport(): Promise<{
|
|
24
|
+
readonly ok: true;
|
|
25
|
+
readonly transport: PortsTransport;
|
|
26
|
+
} | {
|
|
27
|
+
readonly ok: false;
|
|
28
|
+
readonly message: string;
|
|
29
|
+
}>;
|
|
30
|
+
home(): string;
|
|
31
|
+
}
|
|
32
|
+
export declare function tunnelHandlers(deps: TunnelHandlerDeps): Record<string, InboxMessageHandler>;
|
|
33
|
+
/** The live handlers for the conductor's effective ports entry `with`
|
|
34
|
+
* (`options`, machine entry wins) — the conductor passes it per dispatch. */
|
|
35
|
+
export declare function handlersFor(options: unknown): Record<string, InboxMessageHandler>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// BB-676 Task 4 — the ports addon's contract-2 handler (BB-661): a single
|
|
2
|
+
// `tunnel` message claims every eligible service of the worktree and shares
|
|
3
|
+
// each one not yet applied, then answers with one Markdown list.
|
|
4
|
+
//
|
|
5
|
+
// Only a claim with `rules.length > 0` is ever shared — an unknown, dead,
|
|
6
|
+
// stopped or not-exposed service never reaches `bringUp` (spec "Addon
|
|
7
|
+
// claims": "No unclaimed tunnels"). Liveness is read from the machine's
|
|
8
|
+
// APPLIED state, never a per-route PID — a route is up because its record
|
|
9
|
+
// says so, not because some process on this host answers to a signal.
|
|
10
|
+
import { realpathSync } from 'node:fs';
|
|
11
|
+
import { homeBbDir } from '@bakers-build/core';
|
|
12
|
+
import { servicesToShare, shareAll } from './claim-share.js';
|
|
13
|
+
import { discoverClaimedServices } from './claims.js';
|
|
14
|
+
import { assertNoMachinePorts, bringUpTunnel, loadPortsTransport, readMachineTunnelRecords, withProblems, } from './core.js';
|
|
15
|
+
import { nodeDeps, nodeHttpProbe, nodeTunnelOpsDeps } from './deps-node.js';
|
|
16
|
+
import { serviceRules } from './service-config.js';
|
|
17
|
+
import { tunnelMarkdown } from './tunnel-result.js';
|
|
18
|
+
/** The worktree as the CLI sees it (`process.cwd()` is already real): DDEV
|
|
19
|
+
* labels containers with the real path and discovery matches it exactly, so a
|
|
20
|
+
* symlinked path from the server would find nothing (BB-693). */
|
|
21
|
+
function canonicalPath(path) {
|
|
22
|
+
try {
|
|
23
|
+
return realpathSync(path);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return path;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function tunnelHandlers(deps) {
|
|
30
|
+
return {
|
|
31
|
+
tunnel: async (request) => {
|
|
32
|
+
const loaded = await deps.loadTransport();
|
|
33
|
+
if (!loaded.ok) {
|
|
34
|
+
return { markdown: loaded.message };
|
|
35
|
+
}
|
|
36
|
+
const transport = loaded.transport;
|
|
37
|
+
const ticket = request.ticket.identifier;
|
|
38
|
+
const worktree = canonicalPath(request.worktreePath);
|
|
39
|
+
const discovery = await deps.discover(worktree, ticket, transport.zone ?? '');
|
|
40
|
+
const ctx = {
|
|
41
|
+
worktree,
|
|
42
|
+
ticket,
|
|
43
|
+
home: deps.home(),
|
|
44
|
+
transport,
|
|
45
|
+
signal: request.signal,
|
|
46
|
+
};
|
|
47
|
+
const outcomes = await shareAll(servicesToShare(discovery), (s) => deps.bringUp(s, ctx));
|
|
48
|
+
const failures = outcomes
|
|
49
|
+
.filter(({ result }) => !result.ok)
|
|
50
|
+
.map(({ service, result }) => ({ service, message: result.message }));
|
|
51
|
+
const after = await deps.readRecords(worktree);
|
|
52
|
+
return { markdown: tunnelMarkdown(discovery, after, failures) };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** The live handlers for the conductor's effective ports entry `with`
|
|
57
|
+
* (`options`, machine entry wins) — the conductor passes it per dispatch. */
|
|
58
|
+
export function handlersFor(options) {
|
|
59
|
+
return tunnelHandlers({
|
|
60
|
+
discover: (worktree, ticket, zone) => discoverClaimedServices(worktree, nodeDeps, {
|
|
61
|
+
ticket,
|
|
62
|
+
zone,
|
|
63
|
+
probe: nodeHttpProbe,
|
|
64
|
+
rules: serviceRules(options),
|
|
65
|
+
}),
|
|
66
|
+
readRecords: (worktree) => readMachineTunnelRecords(worktree, homeBbDir(), nodeTunnelOpsDeps),
|
|
67
|
+
bringUp: (service, ctx) => bringUpTunnel(service.rules[0], ctx, nodeTunnelOpsDeps),
|
|
68
|
+
loadTransport: async () => {
|
|
69
|
+
// The conductor hands the merged `with`; a bad setting answers here
|
|
70
|
+
// instead of a silently empty claim list.
|
|
71
|
+
const [problem] = withProblems(options);
|
|
72
|
+
if (problem)
|
|
73
|
+
return { ok: false, message: problem };
|
|
74
|
+
try {
|
|
75
|
+
await assertNoMachinePorts();
|
|
76
|
+
const transport = await loadPortsTransport(options, homeBbDir());
|
|
77
|
+
return { ok: true, transport };
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
// The loader already produces a bounded, redacted diagnostic (missing
|
|
81
|
+
// entry, unresolvable package, rejected settings) — the SAME message
|
|
82
|
+
// the CLI prints, never a raw stack trace into the ticket's addon card.
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
message: err instanceof Error ? err.message : String(err),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
home: () => homeBbDir(),
|
|
90
|
+
});
|
|
91
|
+
}
|