@catheadowl/ukp 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -5
- package/docs/remote-deployment.md +94 -16
- package/package.json +1 -1
- package/src/capabilities/door-health.ts +480 -0
- package/src/capabilities/tls-identity.ts +3 -2
- package/src/cli.ts +6 -0
- package/src/commands/diagnose.ts +80 -2
- package/src/commands/guide-content/remote.txt +1 -1
- package/src/commands/init.ts +27 -0
- package/src/commands/serve-task.ts +334 -44
- package/src/commands/serve.ts +101 -19
- package/src/server.ts +76 -43
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/** Windows resident-door health model (O-020 ruling B2): the read-only
|
|
2
|
+
* checks behind `ukp diagnose --door`. A "door" here is what
|
|
3
|
+
* `ukp serve --print-task` sets up: a scheduled task whose action runs
|
|
4
|
+
* wscript.exe on a hidden launcher (start-*-hidden.vbs) under
|
|
5
|
+
* %USERPROFILE%\.ukp, which runs the start script (start-*.cmd, carrying
|
|
6
|
+
* the serve line and the token) and owns door.log. Multi-door hosts rename
|
|
7
|
+
* the trio per door (field shape: ukp-door / ukp-door-<name>), so
|
|
8
|
+
* discovery matches the task ACTION (wscript + a .vbs under .ukp), never a
|
|
9
|
+
* fixed task name.
|
|
10
|
+
*
|
|
11
|
+
* Read-only by construction: the system facts arrive as one PowerShell
|
|
12
|
+
* snapshot (task roster, process table, LISTEN sockets) - no process is
|
|
13
|
+
* killed, no file written, no task registered or unregistered, and the
|
|
14
|
+
* "port bind dry-run" is a listener-table lookup, not a real bind (a real
|
|
15
|
+
* listen could pop a firewall prompt and race the launcher's crash retry).
|
|
16
|
+
* The start script is read but only the serve line's --endpoint/--select/
|
|
17
|
+
* --host/--port values are ever echoed; the UKP_SERVE_TOKEN line stays in the
|
|
18
|
+
* file (secret-free output, D-090 convention). Repair knowledge is
|
|
19
|
+
* pointers only: `ukp serve --print-task` section 5 and the deployment
|
|
20
|
+
* handbook - diagnose says what is wrong, never fixes it. */
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// System snapshot (injectable in full for tests)
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export interface DoorTaskRow {
|
|
30
|
+
name: string;
|
|
31
|
+
state: string;
|
|
32
|
+
execute: string;
|
|
33
|
+
arguments: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface DoorProcessRow {
|
|
37
|
+
pid: number;
|
|
38
|
+
ppid: number;
|
|
39
|
+
name: string;
|
|
40
|
+
commandLine: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DoorListenerRow {
|
|
44
|
+
address: string;
|
|
45
|
+
port: number;
|
|
46
|
+
pid: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DoorSystemSnapshot {
|
|
50
|
+
tasks: DoorTaskRow[];
|
|
51
|
+
processes: DoorProcessRow[];
|
|
52
|
+
listeners: DoorListenerRow[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type DoorSystemProbe = () => DoorSystemSnapshot;
|
|
56
|
+
|
|
57
|
+
/** Default file reader for the operator-authored door files. */
|
|
58
|
+
export function defaultReadDoorFile(path: string): string {
|
|
59
|
+
return readFileSync(path, "utf8");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The one-shot PowerShell collector. Everything diagnose needs about
|
|
63
|
+
* tasks, processes, and listeners rides in a single spawn (each direct
|
|
64
|
+
* bun->tool edge is Defender-taxed on win32; powershell as the carrier is
|
|
65
|
+
* the measured-fast shape, see spawn-relay.ts) and comes back as JSON.
|
|
66
|
+
* `[Console]::OutputEncoding` is set so non-ASCII task names survive the
|
|
67
|
+
* pipe as UTF-8. */
|
|
68
|
+
export function defaultDoorSystemProbe(): DoorSystemSnapshot {
|
|
69
|
+
const script = [
|
|
70
|
+
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
|
|
71
|
+
"$tasks = @(Get-ScheduledTask -ErrorAction SilentlyContinue | ForEach-Object {",
|
|
72
|
+
" $a = @($_.Actions)[0]",
|
|
73
|
+
' [pscustomobject]@{ name = $_.TaskName; state = [string]$_.State; execute = [string]$a.Execute; arguments = [string]$a.Arguments }',
|
|
74
|
+
"})",
|
|
75
|
+
"$processes = @(Get-CimInstance Win32_Process | ForEach-Object {",
|
|
76
|
+
' [pscustomobject]@{ pid = [int]$_.ProcessId; ppid = [int]$_.ParentProcessId; name = [string]$_.Name; commandLine = [string]$_.CommandLine }',
|
|
77
|
+
"})",
|
|
78
|
+
"$listeners = @(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | ForEach-Object {",
|
|
79
|
+
' [pscustomobject]@{ address = [string]$_.LocalAddress; port = [int]$_.LocalPort; pid = [int]$_.OwningProcess }',
|
|
80
|
+
"})",
|
|
81
|
+
'[pscustomobject]@{ tasks = $tasks; processes = $processes; listeners = $listeners } | ConvertTo-Json -Depth 4 -Compress',
|
|
82
|
+
].join("\n");
|
|
83
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
84
|
+
const result = spawnSync("powershell", ["-NoProfile", "-EncodedCommand", encoded], {
|
|
85
|
+
encoding: "utf8",
|
|
86
|
+
windowsHide: true,
|
|
87
|
+
timeout: 20_000,
|
|
88
|
+
});
|
|
89
|
+
if (result.error) {
|
|
90
|
+
throw new Error(`could not run powershell for the door snapshot: ${result.error.message}`);
|
|
91
|
+
}
|
|
92
|
+
if (result.status !== 0) {
|
|
93
|
+
throw new Error(`the door snapshot query failed (powershell exit ${result.status})`);
|
|
94
|
+
}
|
|
95
|
+
const raw = (result.stdout ?? "").replace(/^\uFEFF/, "").trim();
|
|
96
|
+
if (raw.length === 0) {
|
|
97
|
+
throw new Error("the door snapshot query returned nothing");
|
|
98
|
+
}
|
|
99
|
+
const parsed = JSON.parse(raw) as Partial<DoorSystemSnapshot>;
|
|
100
|
+
return {
|
|
101
|
+
tasks: (parsed.tasks ?? []).map((row) => ({ ...row, arguments: row.arguments ?? "" })),
|
|
102
|
+
processes: (parsed.processes ?? []).map((row) => ({
|
|
103
|
+
pid: Number(row.pid),
|
|
104
|
+
ppid: Number(row.ppid),
|
|
105
|
+
name: row.name ?? "",
|
|
106
|
+
commandLine: row.commandLine ?? "",
|
|
107
|
+
})),
|
|
108
|
+
listeners: (parsed.listeners ?? []).map((row) => ({
|
|
109
|
+
address: row.address ?? "",
|
|
110
|
+
port: Number(row.port),
|
|
111
|
+
pid: Number(row.pid),
|
|
112
|
+
})),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Door discovery and per-door evaluation
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
export interface DoorReport {
|
|
121
|
+
taskName: string;
|
|
122
|
+
taskState: string;
|
|
123
|
+
/** Resolved launcher (.vbs) path; absent when the action path could not
|
|
124
|
+
* be resolved (task is then structurally failed). */
|
|
125
|
+
launcherPath?: string;
|
|
126
|
+
scriptPath?: string;
|
|
127
|
+
serve?: { endpoint?: string; select?: string[]; host?: string; port?: number };
|
|
128
|
+
/** Main root-to-leaf chain (wscript -> cmd -> ... -> bun); absent when no
|
|
129
|
+
* launcher process was found. */
|
|
130
|
+
tree?: Array<{ pid: number; name: string }>;
|
|
131
|
+
port?: {
|
|
132
|
+
port: number;
|
|
133
|
+
listening: boolean;
|
|
134
|
+
holderPid?: number;
|
|
135
|
+
holderName?: string;
|
|
136
|
+
inTree?: boolean;
|
|
137
|
+
};
|
|
138
|
+
log?: {
|
|
139
|
+
path: string;
|
|
140
|
+
banner: boolean;
|
|
141
|
+
lastListening?: string;
|
|
142
|
+
eaddrinuse: boolean;
|
|
143
|
+
sharingViolation: boolean;
|
|
144
|
+
};
|
|
145
|
+
warnings: string[];
|
|
146
|
+
status: "ok" | "warning" | "failed";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface DoorEvaluationEnv {
|
|
150
|
+
homeDir: string;
|
|
151
|
+
readTextFile: (path: string) => string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizeKeyPath(path: string): string {
|
|
155
|
+
return path.replaceAll("/", "\\").toLowerCase();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function stripSurroundingQuotes(value: string): string {
|
|
159
|
+
return value.trim().replace(/^"+/, "").replace(/"+$/, "");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A door task = an action running wscript.exe on a .vbs under a `.ukp`
|
|
163
|
+
* directory (the print-task launcher shape, rename-tolerant: any task/vbs
|
|
164
|
+
* name qualifies as long as the shape holds). */
|
|
165
|
+
export function isDoorTaskAction(execute: string, args: string): boolean {
|
|
166
|
+
if (!/wscript\.exe$/i.test(execute.trim())) return false;
|
|
167
|
+
const clean = stripSurroundingQuotes(args).replaceAll("/", "\\");
|
|
168
|
+
return /\\.ukp\\.*\.vbs$/i.test(clean);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Task action argument -> launcher path. Only %USERPROFILE% (the
|
|
172
|
+
* template's variable) is expanded; any other %VAR% marks the action
|
|
173
|
+
* unresolvable from userland. */
|
|
174
|
+
export function resolveDoorLauncherPath(
|
|
175
|
+
args: string,
|
|
176
|
+
homeDir: string,
|
|
177
|
+
): { path: string; resolved: boolean } {
|
|
178
|
+
const clean = stripSurroundingQuotes(args);
|
|
179
|
+
const expanded = clean.replace(/%USERPROFILE%/gi, homeDir);
|
|
180
|
+
return { path: expanded, resolved: !expanded.includes("%") };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The .vbs path from a live wscript command line (last quoted .vbs token). */
|
|
184
|
+
function vbsPathFromCommandLine(commandLine: string): string | undefined {
|
|
185
|
+
const matches = [...commandLine.matchAll(/"([^"]+\.vbs)"/gi)].map((m) => m[1]!);
|
|
186
|
+
return matches.at(-1);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The serve line's flags from the start script text; undefined pieces are
|
|
190
|
+
* simply absent. Only these values are ever echoed - the token line in the
|
|
191
|
+
* same file is never reproduced. `--select` (ADR-REM-010 subset doors)
|
|
192
|
+
* parses to the name list. */
|
|
193
|
+
export function parseServeLine(scriptText: string): { endpoint?: string; select?: string[]; host?: string; port?: number } | undefined {
|
|
194
|
+
const line = scriptText
|
|
195
|
+
.split(/\r?\n/)
|
|
196
|
+
.map((entry) => entry.trim())
|
|
197
|
+
.find((entry) => /^ukp\s+serve(\s|$)/.test(entry));
|
|
198
|
+
if (line === undefined) return undefined;
|
|
199
|
+
const endpoint = /--endpoint\s+(\S+)/.exec(line)?.[1];
|
|
200
|
+
const selectRaw = /--select\s+(\S+)/.exec(line)?.[1];
|
|
201
|
+
const host = /--host\s+(\S+)/.exec(line)?.[1];
|
|
202
|
+
const portRaw = /--port\s+(\d+)/.exec(line)?.[1];
|
|
203
|
+
return {
|
|
204
|
+
...(endpoint !== undefined ? { endpoint } : {}),
|
|
205
|
+
...(selectRaw !== undefined ? { select: selectRaw.split(",") } : {}),
|
|
206
|
+
...(host !== undefined ? { host } : {}),
|
|
207
|
+
...(portRaw !== undefined ? { port: Number(portRaw) } : {}),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** door.log path: the live cmd process carries the full redirect target;
|
|
212
|
+
* statically, the vbs names the log file fragment under .ukp. */
|
|
213
|
+
function findLogPath(liveCmdCommandLine: string | undefined, vbsText: string | undefined, launcherPath: string): string | undefined {
|
|
214
|
+
const live = liveCmdCommandLine !== undefined ? />>\s*"([^"]+)"/.exec(liveCmdCommandLine)?.[1] : undefined;
|
|
215
|
+
if (live !== undefined) return live;
|
|
216
|
+
const fragment = vbsText !== undefined ? /\\\.ukp\\([^\r\n"']+?\.log)/i.exec(vbsText)?.[1] : undefined;
|
|
217
|
+
if (fragment !== undefined) {
|
|
218
|
+
const dir = launcherPath.slice(0, Math.max(launcherPath.lastIndexOf("\\"), launcherPath.lastIndexOf("/")));
|
|
219
|
+
return `${dir}\\${fragment.replaceAll("/", "\\")}`;
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function evaluateDoor(report: DoorReport, snapshot: DoorSystemSnapshot, env: DoorEvaluationEnv): DoorReport {
|
|
225
|
+
const warnings = report.warnings;
|
|
226
|
+
const failed = report.status === "failed";
|
|
227
|
+
|
|
228
|
+
// Launcher script readability (operator-authored; tolerance, not failure).
|
|
229
|
+
let vbsText: string | undefined;
|
|
230
|
+
if (report.launcherPath !== undefined) {
|
|
231
|
+
try {
|
|
232
|
+
vbsText = env.readTextFile(report.launcherPath);
|
|
233
|
+
} catch {
|
|
234
|
+
warnings.push(
|
|
235
|
+
`launcher script not found at ${report.launcherPath} - the task references a deleted file; remove the task ('ukp serve --print-task' section 5, level 2) or restore the file`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Start script (the -hidden.vbs -> .cmd naming convention) and its serve line.
|
|
241
|
+
let scriptText: string | undefined;
|
|
242
|
+
if (report.scriptPath !== undefined) {
|
|
243
|
+
try {
|
|
244
|
+
scriptText = env.readTextFile(report.scriptPath);
|
|
245
|
+
} catch {
|
|
246
|
+
warnings.push(
|
|
247
|
+
`start script not found at ${report.scriptPath} - the door's port cannot be determined; restore it or remove the door ('ukp serve --print-task' section 5)`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (scriptText !== undefined) {
|
|
252
|
+
const serve = parseServeLine(scriptText);
|
|
253
|
+
if (serve === undefined) {
|
|
254
|
+
warnings.push(
|
|
255
|
+
`no 'ukp serve' line found in ${report.scriptPath} - the door's port cannot be determined from the script`,
|
|
256
|
+
);
|
|
257
|
+
} else {
|
|
258
|
+
report.serve = serve;
|
|
259
|
+
if (serve.port === undefined) {
|
|
260
|
+
warnings.push(`the serve line in ${report.scriptPath} carries no --port - the door's port cannot be determined`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Process tree: the wscript whose command line names this door's vbs.
|
|
266
|
+
const launcherKey = report.launcherPath !== undefined ? normalizeKeyPath(report.launcherPath) : undefined;
|
|
267
|
+
const root = snapshot.processes.find((proc) => {
|
|
268
|
+
if (!/^wscript\.exe$/i.test(proc.name)) return false;
|
|
269
|
+
const vbs = vbsPathFromCommandLine(proc.commandLine);
|
|
270
|
+
return vbs !== undefined && launcherKey !== undefined && normalizeKeyPath(vbs) === launcherKey;
|
|
271
|
+
});
|
|
272
|
+
let treePidSet: Set<number> | undefined;
|
|
273
|
+
if (root !== undefined) {
|
|
274
|
+
const children = new Map<number, DoorProcessRow[]>();
|
|
275
|
+
for (const proc of snapshot.processes) {
|
|
276
|
+
const list = children.get(proc.ppid) ?? [];
|
|
277
|
+
list.push(proc);
|
|
278
|
+
children.set(proc.ppid, list);
|
|
279
|
+
}
|
|
280
|
+
const treePids = new Set<number>([root.pid]);
|
|
281
|
+
const chain: Array<{ pid: number; name: string }> = [{ pid: root.pid, name: root.name.replace(/\.exe$/i, "") }];
|
|
282
|
+
let frontier: DoorProcessRow[] = [root];
|
|
283
|
+
while (frontier.length > 0) {
|
|
284
|
+
// Main chain = first child per level; siblings only join the pid set.
|
|
285
|
+
const next: DoorProcessRow[] = [];
|
|
286
|
+
for (const parent of frontier) {
|
|
287
|
+
for (const child of children.get(parent.pid) ?? []) {
|
|
288
|
+
treePids.add(child.pid);
|
|
289
|
+
next.push(child);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (next.length > 0) {
|
|
293
|
+
chain.push({ pid: next[0]!.pid, name: next[0]!.name.replace(/\.exe$/i, "") });
|
|
294
|
+
}
|
|
295
|
+
frontier = next;
|
|
296
|
+
}
|
|
297
|
+
treePidSet = treePids;
|
|
298
|
+
report.tree = chain;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Port cross-check: the listener table decides, never a real bind.
|
|
302
|
+
const port = report.serve?.port;
|
|
303
|
+
if (port !== undefined) {
|
|
304
|
+
const holder = snapshot.listeners.find((entry) => entry.port === port);
|
|
305
|
+
if (holder === undefined) {
|
|
306
|
+
report.port = { port, listening: false };
|
|
307
|
+
} else {
|
|
308
|
+
const holderName = snapshot.processes.find((proc) => proc.pid === holder.pid)?.name ?? "(unknown)";
|
|
309
|
+
const inTree = treePidSet?.has(holder.pid) ?? false;
|
|
310
|
+
report.port = {
|
|
311
|
+
port,
|
|
312
|
+
listening: true,
|
|
313
|
+
holderPid: holder.pid,
|
|
314
|
+
holderName: holderName.replace(/\.exe$/i, ""),
|
|
315
|
+
inTree,
|
|
316
|
+
};
|
|
317
|
+
if (!inTree) {
|
|
318
|
+
warnings.push(
|
|
319
|
+
`port ${port} is held by ${holderName.replace(/\.exe$/i, "")}(${holder.pid}) OUTSIDE this door's process tree - a leftover listener the door cannot bind past (its log would carry EADDRINUSE); find it with netstat -ano | findstr :${port}`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// door.log tail triage (ISSUE-016 vocabulary made actionable).
|
|
326
|
+
if (report.launcherPath !== undefined) {
|
|
327
|
+
const liveCmd =
|
|
328
|
+
root !== undefined
|
|
329
|
+
? snapshot.processes.find((proc) => proc.ppid === root!.pid && /^cmd\.exe$/i.test(proc.name))
|
|
330
|
+
: undefined;
|
|
331
|
+
const logPath = findLogPath(liveCmd?.commandLine, vbsText, report.launcherPath);
|
|
332
|
+
if (logPath === undefined) {
|
|
333
|
+
warnings.push("the door.log path could not be determined (neither the live launcher nor the vbs names it)");
|
|
334
|
+
} else {
|
|
335
|
+
try {
|
|
336
|
+
const tail = env.readTextFile(logPath).slice(-8192);
|
|
337
|
+
// Latest-evidence triage: the log is append-only, so a failure
|
|
338
|
+
// signature only counts when it lands AFTER the newest serving
|
|
339
|
+
// banner (a banner that follows supersedes the failure - the door
|
|
340
|
+
// restarted or the artifacts were regenerated; field door 8570
|
|
341
|
+
// carries ISSUE-016-era scar lines under a healthy banner).
|
|
342
|
+
const lastBanner = Math.max(
|
|
343
|
+
tail.lastIndexOf("serving endpoint '"),
|
|
344
|
+
tail.lastIndexOf("serving host door"),
|
|
345
|
+
);
|
|
346
|
+
const lastEaddrinuse = tail.lastIndexOf("EADDRINUSE");
|
|
347
|
+
const lastSharingViolation = tail.lastIndexOf("being used by another process");
|
|
348
|
+
const banner = lastBanner !== -1;
|
|
349
|
+
const lastListening = [...tail.matchAll(/listening: (\S+)/g)].map((m) => m[1]!).at(-1);
|
|
350
|
+
const eaddrinuse = lastEaddrinuse > lastBanner;
|
|
351
|
+
const sharingViolation = lastSharingViolation > lastBanner;
|
|
352
|
+
report.log = { path: logPath, banner, lastListening, eaddrinuse, sharingViolation };
|
|
353
|
+
if (!banner) {
|
|
354
|
+
warnings.push(
|
|
355
|
+
`no serving banner in the tail of ${logPath} - the door never reached serving in what the log remembers`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
if (eaddrinuse) {
|
|
359
|
+
warnings.push(
|
|
360
|
+
`door.log tail carries EADDRINUSE as its latest evidence - the port was already taken when the door last tried to bind; see the port row above, then restart the door by killing the launcher root (taskkill /PID <wscript-pid> /T /F) and Start-ScheduledTask -TaskName ${report.taskName}`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
if (sharingViolation) {
|
|
364
|
+
warnings.push(
|
|
365
|
+
"door.log carries 'being used by another process' as its latest evidence - the double-redirect deadlock shape (a redirect on the serve line inside the start script while the hidden launcher also redirects); regenerate the artifacts with a current ukp ('ukp serve --print-task') - the serve line must carry no redirect",
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
} catch {
|
|
369
|
+
warnings.push(`no readable door.log at ${logPath} - the door has not logged a start here`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Door-level findings.
|
|
375
|
+
if (report.tree === undefined) {
|
|
376
|
+
if (/^running$/i.test(report.taskState)) {
|
|
377
|
+
warnings.push(
|
|
378
|
+
`the task state says Running but no launcher process exists - the door crashed past the launcher's bounded retries or was killed; start it again (Start-ScheduledTask -TaskName ${report.taskName})`,
|
|
379
|
+
);
|
|
380
|
+
} else {
|
|
381
|
+
warnings.push(
|
|
382
|
+
`door not running - the task is registered but nothing holds the process tree; start it with Start-ScheduledTask -TaskName ${report.taskName}, or remove it ('ukp serve --print-task' section 5)`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
} else if (report.port?.listening === false) {
|
|
386
|
+
warnings.push(
|
|
387
|
+
`the door's process tree is alive but nothing listens on port ${port} - the serve process may be mid-retry or failed to bind; check the log tail above`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
report.status = failed ? "failed" : warnings.length > 0 ? "warning" : "ok";
|
|
392
|
+
return report;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Snapshot -> per-door reports. Pure apart from the injected file reader. */
|
|
396
|
+
export function evaluateDoorSnapshot(snapshot: DoorSystemSnapshot, env: DoorEvaluationEnv): DoorReport[] {
|
|
397
|
+
const reports: DoorReport[] = [];
|
|
398
|
+
for (const task of snapshot.tasks) {
|
|
399
|
+
if (!isDoorTaskAction(task.execute, task.arguments)) continue;
|
|
400
|
+
const report: DoorReport = { taskName: task.name, taskState: task.state, warnings: [], status: "ok" };
|
|
401
|
+
const resolved = resolveDoorLauncherPath(task.arguments, env.homeDir);
|
|
402
|
+
if (!resolved.resolved) {
|
|
403
|
+
report.status = "failed";
|
|
404
|
+
report.warnings.push(
|
|
405
|
+
`the task action could not be resolved to a launcher path: ${task.arguments} (only %USERPROFILE% is expanded)`,
|
|
406
|
+
);
|
|
407
|
+
} else {
|
|
408
|
+
report.launcherPath = resolved.path;
|
|
409
|
+
if (/-hidden\.vbs$/i.test(resolved.path)) {
|
|
410
|
+
report.scriptPath = resolved.path.replace(/-hidden\.vbs$/i, ".cmd");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
reports.push(evaluateDoor(report, snapshot, env));
|
|
414
|
+
}
|
|
415
|
+
return reports.sort((a, b) => a.taskName.localeCompare(b.taskName));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
// Rendering (diagnose wire vocabulary: key: value rows, status:, warning:)
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
export function renderDoorReport(report: DoorReport): string[] {
|
|
423
|
+
const lines = [
|
|
424
|
+
`== door: ${report.taskName} ==`,
|
|
425
|
+
`task: ${report.taskState}`,
|
|
426
|
+
...(report.launcherPath !== undefined ? [`launcher: ${report.launcherPath}`] : []),
|
|
427
|
+
];
|
|
428
|
+
if (report.scriptPath !== undefined) {
|
|
429
|
+
lines.push(`script: ${report.scriptPath}`);
|
|
430
|
+
}
|
|
431
|
+
if (report.serve !== undefined) {
|
|
432
|
+
const shape = report.serve.endpoint !== undefined
|
|
433
|
+
? `endpoint ${report.serve.endpoint}`
|
|
434
|
+
: report.serve.select !== undefined
|
|
435
|
+
? `subset ${report.serve.select.join(",")}`
|
|
436
|
+
: "host door";
|
|
437
|
+
const host = report.serve.host !== undefined ? `, host ${report.serve.host}` : "";
|
|
438
|
+
const port = report.serve.port !== undefined ? `, port ${report.serve.port}` : "";
|
|
439
|
+
lines.push(`serve: ${shape}${host}${port}`);
|
|
440
|
+
}
|
|
441
|
+
lines.push(
|
|
442
|
+
report.tree !== undefined
|
|
443
|
+
? `tree: ${report.tree.map((node) => `${node.name}(${node.pid})`).join(" > ")}`
|
|
444
|
+
: "tree: (no launcher process)",
|
|
445
|
+
);
|
|
446
|
+
if (report.port !== undefined) {
|
|
447
|
+
if (!report.port.listening) {
|
|
448
|
+
lines.push(`port: ${report.port.port} free (nothing listens)`);
|
|
449
|
+
} else if (report.port.inTree) {
|
|
450
|
+
lines.push(`port: ${report.port.port} listening (pid ${report.port.holderPid}, inside the door tree)`);
|
|
451
|
+
} else {
|
|
452
|
+
lines.push(`port: ${report.port.port} listening (pid ${report.port.holderPid}, OUTSIDE the door tree)`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (report.log !== undefined) {
|
|
456
|
+
const tail = report.log.banner
|
|
457
|
+
? report.log.lastListening !== undefined
|
|
458
|
+
? `banner present (last: listening: ${report.log.lastListening})`
|
|
459
|
+
: "banner present"
|
|
460
|
+
: "no serving banner in the tail";
|
|
461
|
+
lines.push(`log: ${report.log.path}`, `log_tail: ${tail}`);
|
|
462
|
+
}
|
|
463
|
+
for (const warning of report.warnings) {
|
|
464
|
+
lines.push(`warning: ${warning}`);
|
|
465
|
+
}
|
|
466
|
+
lines.push(`status: ${report.status}`);
|
|
467
|
+
return lines;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function renderDoorHealthReport(reports: readonly DoorReport[]): string {
|
|
471
|
+
if (reports.length === 0) {
|
|
472
|
+
return [
|
|
473
|
+
"no resident doors found on this host (no scheduled task launches a .ukp",
|
|
474
|
+
"hidden launcher). This is not an error - doors exist only where",
|
|
475
|
+
"'ukp serve --print-task' was applied; the ssh:// path runs none.",
|
|
476
|
+
"",
|
|
477
|
+
].join("\n");
|
|
478
|
+
}
|
|
479
|
+
return `${reports.map((report) => renderDoorReport(report).join("\n")).join("\n\n")}\n`;
|
|
480
|
+
}
|
|
@@ -124,8 +124,9 @@ export interface SelfSignedIdentity {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/** Idempotent self-signed identity for `ukp serve --tls`: persisted under
|
|
127
|
-
* `<serviceFolder>/.ukp/tls/` (machine-local
|
|
128
|
-
*
|
|
127
|
+
* `<serviceFolder>/.ukp/tls/` (machine-local; `init service` self-ignores
|
|
128
|
+
* `.ukp/` via its `.gitignore` — the instance-uid posture). openssl signs
|
|
129
|
+
* it because node:crypto cannot issue X.509; the
|
|
129
130
|
* identity is the keypair, so losing the key loses the identity (clients
|
|
130
131
|
* re-register). `extraSanEntries` (the `--tls-san` flag, already normalized
|
|
131
132
|
* `IP:x`/`DNS:y`) merges into the SAN coverage; when a persisted certificate
|
package/src/cli.ts
CHANGED
|
@@ -95,6 +95,10 @@ export interface CliContext {
|
|
|
95
95
|
artifactRoot?: string;
|
|
96
96
|
artifactRunId?: string;
|
|
97
97
|
now?: Date;
|
|
98
|
+
/** Overrides the door system snapshot behind `diagnose --door` for tests. */
|
|
99
|
+
doorProbe?: () => import("./capabilities/door-health.ts").DoorSystemSnapshot;
|
|
100
|
+
/** Overrides door-file reads behind `diagnose --door` for tests. */
|
|
101
|
+
readTextFile?: (path: string) => string;
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
interface CliCommandResult {
|
|
@@ -353,6 +357,8 @@ export function runCli(
|
|
|
353
357
|
currentDirectory,
|
|
354
358
|
registryPath,
|
|
355
359
|
resolveProvider: context.resolveProvider,
|
|
360
|
+
doorProbe: context.doorProbe,
|
|
361
|
+
readTextFile: context.readTextFile,
|
|
356
362
|
}), stdout, stderr);
|
|
357
363
|
}
|
|
358
364
|
|
package/src/commands/diagnose.ts
CHANGED
|
@@ -8,6 +8,15 @@ import { HelpRequestError, isHelpRequest } from "./flags.ts";
|
|
|
8
8
|
import { defaultQmdCommand } from "../capabilities/qmd.ts";
|
|
9
9
|
import { resolveProposeFolder } from "../capabilities/propose.ts";
|
|
10
10
|
import { rgExecutableAvailable } from "../capabilities/rg.ts";
|
|
11
|
+
import {
|
|
12
|
+
defaultDoorSystemProbe,
|
|
13
|
+
defaultReadDoorFile,
|
|
14
|
+
evaluateDoorSnapshot,
|
|
15
|
+
renderDoorHealthReport,
|
|
16
|
+
type DoorSystemProbe,
|
|
17
|
+
type DoorSystemSnapshot,
|
|
18
|
+
} from "../capabilities/door-health.ts";
|
|
19
|
+
import { homedir } from "node:os";
|
|
11
20
|
|
|
12
21
|
export interface ProviderCheck {
|
|
13
22
|
supported: boolean;
|
|
@@ -31,6 +40,15 @@ export interface DiagnoseCommandContext {
|
|
|
31
40
|
currentDirectory: string;
|
|
32
41
|
registryPath: string;
|
|
33
42
|
resolveProvider?: ProviderResolver;
|
|
43
|
+
/** Overrides the PowerShell door snapshot for tests (--door mode). */
|
|
44
|
+
doorProbe?: DoorSystemProbe;
|
|
45
|
+
/** Overrides file reads of the operator-authored door files for tests. */
|
|
46
|
+
readTextFile?: (path: string) => string;
|
|
47
|
+
/** Overrides the %USERPROFILE% expansion root for tests (--door mode). */
|
|
48
|
+
homeDir?: string;
|
|
49
|
+
/** Overrides the door-mode platform admission for tests (--door mode);
|
|
50
|
+
* process.platform in production. */
|
|
51
|
+
platform?: string;
|
|
34
52
|
}
|
|
35
53
|
|
|
36
54
|
export interface DiagnoseCommandResult {
|
|
@@ -52,8 +70,11 @@ export const DIAGNOSE_SPEC: UkpCommandSpec = {
|
|
|
52
70
|
name: "diagnose",
|
|
53
71
|
summary: "check a Service or endpoints for wiring problems (manifest, provider setup)",
|
|
54
72
|
group: "operations",
|
|
55
|
-
description: "Validate a Service folder or selected registered Service endpoints.",
|
|
56
|
-
usage: "[--endpoint <name> ... | -g]",
|
|
73
|
+
description: "Validate a Service folder or selected registered Service endpoints, or this host's resident doors (--door).",
|
|
74
|
+
usage: "[--door] [--endpoint <name> ... | -g]",
|
|
75
|
+
options: [
|
|
76
|
+
{ flags: "--door", help: "check this host's resident doors (Windows scheduled tasks running the print-task hidden launcher): task state, process tree, port holder, door.log tail - read-only" },
|
|
77
|
+
],
|
|
57
78
|
scope: {
|
|
58
79
|
endpointHelp: "validate one registered endpoint; repeat to validate multiple endpoints",
|
|
59
80
|
globalHelp: "validate every endpoint in the Host Registry; takes no value",
|
|
@@ -65,6 +86,16 @@ export const DIAGNOSE_SPEC: UkpCommandSpec = {
|
|
|
65
86
|
" Service (Manifest and provider wiring). -g validates every registered",
|
|
66
87
|
" endpoint; -g cannot be combined with --endpoint.",
|
|
67
88
|
"",
|
|
89
|
+
"Doors:",
|
|
90
|
+
" --door skips Service validation and checks this host's resident doors",
|
|
91
|
+
" (Windows): every scheduled task running the print-task hidden launcher",
|
|
92
|
+
" is reported with its task state, the wscript->cmd->bun process tree,",
|
|
93
|
+
" its port (and who holds it), and the door.log tail. Read-only -",
|
|
94
|
+
" repairs live in 'ukp serve --print-task' section 5 and the deployment",
|
|
95
|
+
" handbook. A door that is down is a finding (exit 1); a host with no",
|
|
96
|
+
" doors is healthy. Not combinable with --endpoint or -g; on Linux the",
|
|
97
|
+
" door is a systemd socket-activation unit (see the handbook).",
|
|
98
|
+
"",
|
|
68
99
|
].join("\n"),
|
|
69
100
|
};
|
|
70
101
|
|
|
@@ -208,18 +239,55 @@ export function renderDependencyRegistryWarnings(
|
|
|
208
239
|
}
|
|
209
240
|
|
|
210
241
|
function parseDiagnoseArgs(args: readonly string[]): {
|
|
242
|
+
door: boolean;
|
|
211
243
|
explicitEndpoints?: string[];
|
|
212
244
|
global: boolean;
|
|
213
245
|
warnings: string[];
|
|
214
246
|
} {
|
|
215
247
|
const parsed = parseKitArgs(DIAGNOSE_SPEC, args);
|
|
216
248
|
return {
|
|
249
|
+
door: (parsed.options as { door?: boolean }).door === true,
|
|
217
250
|
explicitEndpoints: parsed.scope.explicitEndpoints,
|
|
218
251
|
global: parsed.scope.global,
|
|
219
252
|
warnings: parsed.scope.warnings,
|
|
220
253
|
};
|
|
221
254
|
}
|
|
222
255
|
|
|
256
|
+
/** Door-mode platform admission (mirrors printTaskPreflightError: pure,
|
|
257
|
+
* `process.platform` in production). Linux doors are systemd units with
|
|
258
|
+
* their own check surface; running the Windows probe there is meaningless. */
|
|
259
|
+
export function doorModePlatformError(platform: string): string | undefined {
|
|
260
|
+
if (platform !== "win32") {
|
|
261
|
+
return "--door is Windows-only; on Linux the door is a systemd socket-activation unit - check it with systemctl --user status ukp-door.socket (the deployment handbook carries the recipes)";
|
|
262
|
+
}
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function executeDoorDiagnose(context: DiagnoseCommandContext): DiagnoseCommandResult {
|
|
267
|
+
const probe = context.doorProbe ?? defaultDoorSystemProbe;
|
|
268
|
+
const readTextFile = context.readTextFile ?? defaultReadDoorFile;
|
|
269
|
+
let snapshot: DoorSystemSnapshot;
|
|
270
|
+
try {
|
|
271
|
+
snapshot = probe();
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return {
|
|
274
|
+
exitCode: 1,
|
|
275
|
+
stdout: "",
|
|
276
|
+
stderr: `ukp diagnose: door snapshot failed: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
const doors = evaluateDoorSnapshot(snapshot, {
|
|
280
|
+
homeDir: context.homeDir ?? homedir(),
|
|
281
|
+
readTextFile,
|
|
282
|
+
});
|
|
283
|
+
const unhealthy = doors.some((door) => door.status !== "ok");
|
|
284
|
+
return {
|
|
285
|
+
exitCode: unhealthy ? 1 : 0,
|
|
286
|
+
stdout: renderDoorHealthReport(doors),
|
|
287
|
+
stderr: "",
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
223
291
|
function diagnoseBinding(
|
|
224
292
|
binding: RegistryBinding,
|
|
225
293
|
resolveProvider: ProviderResolver | undefined,
|
|
@@ -254,6 +322,16 @@ export function executeDiagnoseCommand(
|
|
|
254
322
|
|
|
255
323
|
try {
|
|
256
324
|
const parsed = parseDiagnoseArgs(args);
|
|
325
|
+
if (parsed.door) {
|
|
326
|
+
if (parsed.explicitEndpoints !== undefined || parsed.global) {
|
|
327
|
+
throw new KitUsageError("--door checks this host's resident doors and cannot be combined with --endpoint or -g");
|
|
328
|
+
}
|
|
329
|
+
const gate = doorModePlatformError(context.platform ?? process.platform);
|
|
330
|
+
if (gate !== undefined) {
|
|
331
|
+
throw new Error(gate);
|
|
332
|
+
}
|
|
333
|
+
return executeDoorDiagnose(context);
|
|
334
|
+
}
|
|
257
335
|
if (!parsed.explicitEndpoints && !parsed.global) {
|
|
258
336
|
try {
|
|
259
337
|
const report = diagnoseService(context.currentDirectory, context.resolveProvider);
|
|
@@ -5,7 +5,7 @@ Goal: expose registered Services from one machine and consume them from another
|
|
|
5
5
|
Current model:
|
|
6
6
|
- The ssh:// path needs nothing running on the host: each invocation wakes a loopback door over the SSH connection (it serves the host Registry fresh and self-reaps when idle). Host prerequisites: the host is ssh reachable and has ukp installed the usual way (npm i -g or bun add -g - the wake finds the standard locations).
|
|
7
7
|
- The https path (LAN, public IP, or behind a reverse proxy) uses a resident door: run ukp serve under your process manager. Arbitrary consumers have no SSH credentials, so the listener itself is the trust boundary (TLS + bearer token).
|
|
8
|
-
- Prefer host door mode: run ukp serve without --endpoint to expose every local endpoint behind one port.
|
|
8
|
+
- Prefer host door mode when the whole Registry is safe to declare: run ukp serve without --endpoint to expose every local endpoint behind one port. A Registry that mixes in endpoints you do not want to expose uses --select instead: ukp serve --select a,b,c keeps one port and one door for exactly the named endpoints - everything unselected is not served, routed, or declared (probing its name behaves exactly like probing one that never existed). Reserve per-endpoint --endpoint <name> doors for exposing a single endpoint to different consumers.
|
|
9
9
|
- The door reads the host Registry fresh on each request, so newly registered local endpoints appear without restarting the door.
|
|
10
10
|
- Declared endpoint names come from the host Service Manifest or discovery document; your registry binds each one under a local handle - the declared name by default, or --name <handle> when that name is already taken (two hosts both declaring 'notes' become your 'notes' and 'ali-notes'). --endpoint during register stays an expected-name assertion on the declared name.
|
|
11
11
|
|