@tikoci/centrs 0.1.7 → 0.1.8
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/CHANGELOG.md +256 -0
- package/GLOSSARY.txt +32 -0
- package/README.md +2 -2
- package/SECURITY.md +1 -1
- package/commands/AGENTS.md +2 -1
- package/commands/api/AGENTS.md +55 -30
- package/commands/api/README.md +123 -41
- package/commands/api/examples.md +205 -17
- package/commands/devices/README.md +11 -3
- package/commands/execute/README.md +31 -3
- package/commands/execute/examples.md +136 -1
- package/commands/explain/README.md +30 -4
- package/commands/mcp/README.md +5 -1
- package/commands/mcp/examples.md +18 -1
- package/commands/retrieve/README.md +348 -4
- package/commands/retrieve/examples.md +422 -5
- package/commands/terminal/README.md +14 -4
- package/commands/terminal/examples.md +22 -3
- package/commands/transfer/README.md +55 -9
- package/commands/transfer/examples.md +106 -7
- package/docs/CLI.md +28 -20
- package/docs/CONSTITUTION.md +30 -10
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -1
- package/docs/errors/input/invalid-query.md +36 -0
- package/docs/errors/input/local-destination.md +20 -0
- package/docs/errors/input/unsupported-query.md +19 -0
- package/docs/errors/internal/byte-string.md +13 -0
- package/docs/errors/settings/invalid-timeout.md +6 -0
- package/docs/errors/transport/stream-overflow.md +21 -0
- package/docs/errors/usage/removed-flag.md +20 -0
- package/docs/errors/validation/menu-unavailable.md +22 -0
- package/docs/errors/validation/not-followable.md +23 -0
- package/docs/errors/validation/package-missing.md +29 -0
- package/docs/errors/validation/unknown-path.md +16 -4
- package/docs/errors/wait/deadline-exceeded.md +14 -0
- package/docs/errors/wait/interrupted.md +10 -0
- package/package.json +14 -11
- package/src/absent-menu.ts +280 -0
- package/src/api-fanout.ts +22 -12
- package/src/api.ts +511 -186
- package/src/btest.ts +19 -17
- package/src/cli/api.ts +126 -34
- package/src/cli/execute.ts +18 -5
- package/src/cli/retrieve.ts +223 -22
- package/src/cli/terminal.ts +10 -5
- package/src/cli/transfer.ts +23 -8
- package/src/core/envelope.ts +14 -0
- package/src/core/error-catalog.ts +56 -1
- package/src/core/inspect.ts +43 -5
- package/src/core/query.ts +508 -0
- package/src/core/routeros-errors.ts +1 -1
- package/src/devices.ts +6 -9
- package/src/discover.ts +7 -8
- package/src/errors.ts +116 -7
- package/src/execute-fanout.ts +13 -8
- package/src/execute.ts +106 -65
- package/src/explain/catalog.ts +58 -10
- package/src/explain/menus.ts +23 -2
- package/src/explain.ts +6 -6
- package/src/index.ts +29 -1
- package/src/mcp/safety.ts +43 -18
- package/src/mcp/tools.ts +14 -17
- package/src/protocols/adapter.ts +224 -118
- package/src/protocols/btest-session.ts +3 -2
- package/src/protocols/endpoint.ts +10 -0
- package/src/protocols/mac-telnet-console.ts +6 -3
- package/src/protocols/native-api.ts +223 -70
- package/src/protocols/sftp.ts +44 -14
- package/src/protocols/ssh.ts +42 -14
- package/src/resolver/cdb.ts +28 -7
- package/src/resolver/index.ts +1 -0
- package/src/resolver/target.ts +53 -7
- package/src/retrieve-fanout.ts +51 -12
- package/src/retrieve-follow.ts +862 -0
- package/src/retrieve-sample.ts +244 -0
- package/src/retrieve-stream.ts +115 -0
- package/src/retrieve-wait.ts +286 -0
- package/src/retrieve.ts +732 -108
- package/src/settings.ts +15 -15
- package/src/transfer-fanout.ts +40 -15
- package/src/transfer.ts +234 -32
package/src/protocols/ssh.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { CentrsError } from "../errors.ts";
|
|
18
|
+
import { formatEndpoint } from "./endpoint.ts";
|
|
18
19
|
|
|
19
20
|
export interface SshConnectionConfig {
|
|
20
21
|
host: string;
|
|
@@ -54,7 +55,8 @@ export function sshCommonOptions(config: SshConnectionConfig): string[] {
|
|
|
54
55
|
const connectSeconds = Math.max(1, Math.ceil(config.timeoutMs / 1000));
|
|
55
56
|
const options: string[] = [];
|
|
56
57
|
if (config.sshKey) {
|
|
57
|
-
|
|
58
|
+
// Exclude unrelated agent keys; explicit IdentityFile config still applies.
|
|
59
|
+
options.push("-i", config.sshKey, "-o", "IdentitiesOnly=yes");
|
|
58
60
|
}
|
|
59
61
|
// Batch clients (sftp/execute) keep BatchMode so a missing/encrypted credential
|
|
60
62
|
// errors out rather than blocking on a prompt; the interactive terminal relay
|
|
@@ -79,6 +81,36 @@ export function sshCommonOptions(config: SshConnectionConfig): string[] {
|
|
|
79
81
|
return options;
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
/**
|
|
85
|
+
* OpenSSH's own pre-login refusals. A bare `Permission denied` is not one: sftp
|
|
86
|
+
* prints it after login when RouterOS refuses an operation (`dest open "/x":
|
|
87
|
+
* Permission denied` — CHR 7.24.5, a user without `write` policy), and that is
|
|
88
|
+
* a policy refusal, not an authentication failure (#391).
|
|
89
|
+
*/
|
|
90
|
+
export function isSshAuthFailure(lowerStderr: string): boolean {
|
|
91
|
+
return (
|
|
92
|
+
/permission denied \(/.test(lowerStderr) ||
|
|
93
|
+
lowerStderr.includes("authentication failed") ||
|
|
94
|
+
lowerStderr.includes("no such identity") ||
|
|
95
|
+
lowerStderr.includes("too many authentication failures")
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Remediation for an `auth/failed` from a batch (`BatchMode=yes`) client —
|
|
101
|
+
* `execute --via ssh` and sftp `transfer`. A batch client never sends a
|
|
102
|
+
* password, so it names the key source it used and leaves RouterOS's password
|
|
103
|
+
* policy as a separate fact rather than a guessed cause. A user without `ssh`
|
|
104
|
+
* policy is refused at login (`Permission denied (publickey)`); CHR controls in
|
|
105
|
+
* `test/integration/ssh-auth-policy.test.ts` (#391).
|
|
106
|
+
*/
|
|
107
|
+
export function batchSshAuthRemediation(sshKeySelected: boolean): string {
|
|
108
|
+
const keys = sshKeySelected
|
|
109
|
+
? "the selected SSH key (plus any `IdentityFile` in the host OpenSSH configuration)"
|
|
110
|
+
: "keys from the host OpenSSH configuration or agent";
|
|
111
|
+
return `Batch SSH never sends a password; it authenticates with ${keys}. Install the matching public key for the user on the device (\`/user/ssh-keys import\`; \`/user/ssh-keys print\` lists the user's keys) and confirm the user's group has the \`ssh\` policy. Password login is separate: on RouterOS 7.21+, \`/ip/ssh password-authentication\` (default \`yes-if-no-key\`) refuses it for a user who has a key.`;
|
|
112
|
+
}
|
|
113
|
+
|
|
82
114
|
/** `[user@]host` for the ssh/sftp command line. */
|
|
83
115
|
export function sshUserHost(config: SshConnectionConfig): string {
|
|
84
116
|
return `${config.username ? `${config.username}@` : ""}${config.host}`;
|
|
@@ -94,7 +126,7 @@ export function sshUserHost(config: SshConnectionConfig): string {
|
|
|
94
126
|
export function mapSshConnectError(
|
|
95
127
|
stderr: string,
|
|
96
128
|
exitCode: number,
|
|
97
|
-
ctx: { host: string; port: number; op: string },
|
|
129
|
+
ctx: { host: string; port: number; op: string; sshKeySelected?: boolean },
|
|
98
130
|
): CentrsError {
|
|
99
131
|
const { host, port, op } = ctx;
|
|
100
132
|
const trimmed = stderr.trim();
|
|
@@ -111,25 +143,20 @@ export function mapSshConnectError(
|
|
|
111
143
|
causeData: trimmed,
|
|
112
144
|
});
|
|
113
145
|
}
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
lower.includes("authentication failed") ||
|
|
117
|
-
lower.includes("no such identity") ||
|
|
118
|
-
lower.includes("too many authentication failures")
|
|
119
|
-
) {
|
|
146
|
+
if (isSshAuthFailure(lower)) {
|
|
147
|
+
const sshKeySelected = ctx.sshKeySelected ?? false;
|
|
120
148
|
return new CentrsError({
|
|
121
149
|
code: "auth/failed",
|
|
122
150
|
summary: `SSH authentication to ${host} failed.`,
|
|
123
|
-
remediation:
|
|
124
|
-
|
|
125
|
-
context,
|
|
151
|
+
remediation: batchSshAuthRemediation(sshKeySelected),
|
|
152
|
+
context: { ...context, sshKeySelected },
|
|
126
153
|
causeData: trimmed,
|
|
127
154
|
});
|
|
128
155
|
}
|
|
129
156
|
if (lower.includes("connection refused")) {
|
|
130
157
|
return new CentrsError({
|
|
131
158
|
code: "transport/connection-refused",
|
|
132
|
-
summary: `SSH connection to ${host
|
|
159
|
+
summary: `SSH connection to ${formatEndpoint(host, port)} was refused.`,
|
|
133
160
|
remediation:
|
|
134
161
|
"Enable the RouterOS SSH service (`/ip service enable ssh`) and confirm the port / firewall.",
|
|
135
162
|
context,
|
|
@@ -143,7 +170,7 @@ export function mapSshConnectError(
|
|
|
143
170
|
) {
|
|
144
171
|
return new CentrsError({
|
|
145
172
|
code: "transport/timeout",
|
|
146
|
-
summary: `SSH connection to ${host
|
|
173
|
+
summary: `SSH connection to ${formatEndpoint(host, port)} timed out.`,
|
|
147
174
|
remediation:
|
|
148
175
|
"Raise `--timeout`, or confirm the host and port are reachable.",
|
|
149
176
|
context,
|
|
@@ -166,7 +193,7 @@ export function mapSshConnectError(
|
|
|
166
193
|
}
|
|
167
194
|
return new CentrsError({
|
|
168
195
|
code: "transport/network",
|
|
169
|
-
summary: `SSH ${op} failed against ${host
|
|
196
|
+
summary: `SSH ${op} failed against ${formatEndpoint(host, port)} (exit ${exitCode}).`,
|
|
170
197
|
remediation:
|
|
171
198
|
"Re-run with `--format json` for the structured cause, and confirm SSH access (`/ip service`) to the device.",
|
|
172
199
|
context,
|
|
@@ -244,6 +271,7 @@ export class SshExecClient {
|
|
|
244
271
|
host: this.config.host,
|
|
245
272
|
port: this.config.port,
|
|
246
273
|
op: "execute",
|
|
274
|
+
sshKeySelected: Boolean(this.config.sshKey),
|
|
247
275
|
});
|
|
248
276
|
}
|
|
249
277
|
return cleanConsoleOutput(result.stdout);
|
package/src/resolver/cdb.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* (WP-2c / WP-1b); the shape below leaves a clean seam for them.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
import { isIP } from "node:net";
|
|
29
30
|
import type { WinBoxCdbEntry } from "../data/winbox-cdb.ts";
|
|
30
31
|
import {
|
|
31
32
|
type DevicesWarning,
|
|
@@ -37,7 +38,7 @@ import {
|
|
|
37
38
|
import { CentrsError } from "../errors.ts";
|
|
38
39
|
import { plannedProtocols, type RouterOsProtocol } from "../protocols/index.ts";
|
|
39
40
|
import { parseCommentKv } from "./comment-kv.ts";
|
|
40
|
-
import { normalizeMac } from "./mac.ts";
|
|
41
|
+
import { isMacAddress, normalizeMac } from "./mac.ts";
|
|
41
42
|
import {
|
|
42
43
|
parseBoolean,
|
|
43
44
|
parseDuration,
|
|
@@ -146,9 +147,11 @@ function warningFromDevices(warning: DevicesWarning): ResolverWarning {
|
|
|
146
147
|
*
|
|
147
148
|
* Returns `undefined` when no CDB is in play (no explicit CDB inputs and the
|
|
148
149
|
* default file is absent, or the default CDB has no matching record) — the
|
|
149
|
-
* caller then treats `<router>` as a literal host.
|
|
150
|
-
*
|
|
151
|
-
*
|
|
150
|
+
* caller then treats `<router>` as a literal host. An address-shaped
|
|
151
|
+
* `<router>` ({@link isLiteralHostTarget}) that misses an explicit CDB is also a
|
|
152
|
+
* literal host. Otherwise, with an explicit CDB, a missing file/record surfaces
|
|
153
|
+
* as the underlying CDB error so the "name not found without credentials"
|
|
154
|
+
* contract stays intact upstream.
|
|
152
155
|
*/
|
|
153
156
|
export async function resolveCdb(
|
|
154
157
|
input: CdbResolveInput,
|
|
@@ -227,8 +230,9 @@ export async function resolveCdb(
|
|
|
227
230
|
// target has no record of its own. On the CLI/API this fills creds for
|
|
228
231
|
// an ad-hoc target; on MCP the allowlist gate upstream still rejects
|
|
229
232
|
// unregistered targets, so `__default__` never widens it. Without a
|
|
230
|
-
// `__default__
|
|
231
|
-
// CDB
|
|
233
|
+
// `__default__`: undefined (treat `<router>` as a literal host) for an
|
|
234
|
+
// implicit CDB or an address-shaped target; rethrow a name miss in an
|
|
235
|
+
// explicit CDB, since a name can't be told apart from a typo (#427).
|
|
232
236
|
if (defaults) {
|
|
233
237
|
return {
|
|
234
238
|
target: input.targetInput,
|
|
@@ -242,7 +246,7 @@ export async function resolveCdb(
|
|
|
242
246
|
defaults,
|
|
243
247
|
};
|
|
244
248
|
}
|
|
245
|
-
if (!explicitCdb) {
|
|
249
|
+
if (!explicitCdb || isLiteralHostTarget(input.targetInput)) {
|
|
246
250
|
return undefined;
|
|
247
251
|
}
|
|
248
252
|
}
|
|
@@ -250,6 +254,23 @@ export async function resolveCdb(
|
|
|
250
254
|
}
|
|
251
255
|
}
|
|
252
256
|
|
|
257
|
+
/**
|
|
258
|
+
* True when `<router>` is an address rather than a name: an IPv4 or IPv6 literal
|
|
259
|
+
* (with an optional `:port`, IPv6 as `[…]:port`), an `http(s)://` URL, or a MAC.
|
|
260
|
+
* A miss on one of these in an explicit CDB can't be a mistyped record name, so
|
|
261
|
+
* {@link resolveCdb} lets it through as a literal host. A DNS name or identity
|
|
262
|
+
* keeps failing closed with `cdb/not-found-target`.
|
|
263
|
+
*/
|
|
264
|
+
export function isLiteralHostTarget(value: string): boolean {
|
|
265
|
+
const trimmed = value.trim();
|
|
266
|
+
if (isMacAddress(trimmed) || /^https?:\/\//i.test(trimmed)) {
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(trimmed)?.[1];
|
|
270
|
+
const host = bracketed ?? trimmed.replace(/^([^:]+):\d+$/, "$1");
|
|
271
|
+
return isIP(host) !== 0;
|
|
272
|
+
}
|
|
273
|
+
|
|
253
274
|
/** A single CDB group member resolved for fanout. */
|
|
254
275
|
export interface CdbGroupTarget {
|
|
255
276
|
resolution: CdbResolution;
|
package/src/resolver/index.ts
CHANGED
package/src/resolver/target.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* a future `mac` field.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { isIP } from "node:net";
|
|
17
18
|
import { CentrsError } from "../errors.ts";
|
|
18
19
|
import type { RouterOsProtocol } from "../protocols/index.ts";
|
|
19
20
|
import { MAC_TELNET_PORT } from "../protocols/mac-telnet.ts";
|
|
@@ -244,17 +245,22 @@ export function resolveTarget(
|
|
|
244
245
|
// SSH (sftp/scp/exec) addresses host:22 and carries its own crypto, so the
|
|
245
246
|
// URL scheme is irrelevant. An explicit `--port` / URL port wins over 22.
|
|
246
247
|
tls = false;
|
|
247
|
-
port =
|
|
248
|
-
portSetting?.value ??
|
|
249
|
-
(parsedUrl.port.length > 0 ? Number.parseInt(parsedUrl.port, 10) : 22);
|
|
248
|
+
port = portSetting?.value ?? typedPort(candidate, parsedUrl) ?? 22;
|
|
250
249
|
baseUrl = `ssh://${formatHostForUrl(parsedUrl.hostname)}:${port}`;
|
|
251
250
|
} else if (isNative) {
|
|
252
251
|
// Native API ignores the URL scheme for its wire protocol; it defaults to
|
|
253
252
|
// TCP 8728, or TLS (api-ssl) 8729 when the caller passed `https://` or an
|
|
254
253
|
// explicit 8729. An explicit well-known port wins over the scheme so
|
|
255
254
|
// `https://host --port 8728` stays plaintext instead of running a TLS
|
|
256
|
-
// handshake against the plaintext API port.
|
|
257
|
-
|
|
255
|
+
// handshake against the plaintext API port. A port the caller typed in
|
|
256
|
+
// the host (`host:9`, `http://host:9`) counts as explicit, as it does for
|
|
257
|
+
// ssh and REST (#426); a matched CDB record's port may be WinBox's, so it
|
|
258
|
+
// is left alone.
|
|
259
|
+
const explicitPort =
|
|
260
|
+
portSetting?.value ??
|
|
261
|
+
(hostFromCdbRecord(hostSetting, cdb)
|
|
262
|
+
? undefined
|
|
263
|
+
: typedPort(candidate, parsedUrl));
|
|
258
264
|
if (explicitPort === NATIVE_API_PORT) {
|
|
259
265
|
tls = false;
|
|
260
266
|
} else if (explicitPort === NATIVE_API_TLS_PORT) {
|
|
@@ -285,7 +291,9 @@ export function resolveTarget(
|
|
|
285
291
|
|
|
286
292
|
return {
|
|
287
293
|
input: input.targetInput,
|
|
288
|
-
|
|
294
|
+
// WHATWG keeps an IPv6 hostname bracketed; sockets and ssh need the bare
|
|
295
|
+
// address (`baseUrl` re-brackets it).
|
|
296
|
+
host: parsedUrl.hostname.replace(/^\[(.*)\]$/, "$1"),
|
|
289
297
|
port,
|
|
290
298
|
scheme,
|
|
291
299
|
tls,
|
|
@@ -299,6 +307,42 @@ export function resolveTarget(
|
|
|
299
307
|
};
|
|
300
308
|
}
|
|
301
309
|
|
|
310
|
+
/**
|
|
311
|
+
* The port written in the host candidate, if any. WHATWG `URL` drops a
|
|
312
|
+
* scheme-default port (`http://h:80`, `https://h:443`, and a bare `h:80`, which
|
|
313
|
+
* parses as http), so an empty `URL.port` is re-read from the text. The URL has
|
|
314
|
+
* already parsed, so a trailing `:digits` can only be that port, except in a
|
|
315
|
+
* bare IPv6 literal (`2001:db8::1`), which carries no port.
|
|
316
|
+
*/
|
|
317
|
+
function typedPort(candidate: string, parsedUrl: URL): number | undefined {
|
|
318
|
+
if (parsedUrl.port.length > 0) {
|
|
319
|
+
return Number.parseInt(parsedUrl.port, 10);
|
|
320
|
+
}
|
|
321
|
+
if (isIP(candidate) === 6) {
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
const authority = candidate.replace(/^https?:\/\//i, "").replace(/\/$/, "");
|
|
325
|
+
const match = /:(\d+)$/.exec(authority);
|
|
326
|
+
return match?.[1] !== undefined ? Number.parseInt(match[1], 10) : undefined;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* True when the host is a matched CDB record's `target` rather than something
|
|
331
|
+
* the caller typed (`--host`, `CENTRS_HOST`, config, or the positional itself).
|
|
332
|
+
* A `__default__`-only resolution carries the positional as its target, so its
|
|
333
|
+
* record index is the default record's.
|
|
334
|
+
*/
|
|
335
|
+
function hostFromCdbRecord(
|
|
336
|
+
hostSetting: ResolvedSetting<string>,
|
|
337
|
+
cdb: CdbResolution | undefined,
|
|
338
|
+
): boolean {
|
|
339
|
+
return (
|
|
340
|
+
cdb !== undefined &&
|
|
341
|
+
hostSetting.source.kind === "target-input" &&
|
|
342
|
+
cdb.recordIndex !== cdb.defaults?.recordIndex
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
302
346
|
/**
|
|
303
347
|
* Port provenance. When a port came from the settings ladder
|
|
304
348
|
* (cli/env/comment-kv) its own source wins. Otherwise the port mirrors the
|
|
@@ -429,9 +473,11 @@ export function resolveAuth(
|
|
|
429
473
|
|
|
430
474
|
export function parseHostCandidate(value: string): URL {
|
|
431
475
|
const hasScheme = /^https?:\/\//i.test(value);
|
|
476
|
+
// A bare IPv6 literal (`2001:db8::1`) has no port; URL needs it bracketed.
|
|
477
|
+
const authority = isIP(value) === 6 ? `[${value}]` : value;
|
|
432
478
|
let url: URL;
|
|
433
479
|
try {
|
|
434
|
-
url = new URL(hasScheme ? value : `http://${
|
|
480
|
+
url = new URL(hasScheme ? value : `http://${authority}`);
|
|
435
481
|
} catch (cause) {
|
|
436
482
|
throw invalidTargetError(value, cause);
|
|
437
483
|
}
|
package/src/retrieve-fanout.ts
CHANGED
|
@@ -36,6 +36,7 @@ import type {
|
|
|
36
36
|
CentrsSuccessEnvelope,
|
|
37
37
|
FanoutData,
|
|
38
38
|
FanoutSummary,
|
|
39
|
+
ValidationTrace,
|
|
39
40
|
} from "./core/envelope.ts";
|
|
40
41
|
import {
|
|
41
42
|
buildFanoutEnvelope,
|
|
@@ -47,7 +48,12 @@ import {
|
|
|
47
48
|
summarizeFanout,
|
|
48
49
|
} from "./core/fanout.ts";
|
|
49
50
|
import { toYaml } from "./core/yaml.ts";
|
|
50
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
CentrsError,
|
|
53
|
+
formatCentrsErrorText,
|
|
54
|
+
serializeCentrsError,
|
|
55
|
+
terminalSafeLine,
|
|
56
|
+
} from "./errors.ts";
|
|
51
57
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
52
58
|
import {
|
|
53
59
|
expandSelection,
|
|
@@ -104,9 +110,13 @@ export type RetrieveFanoutErrorEnvelope =
|
|
|
104
110
|
|
|
105
111
|
/** Test/override seams; production callers pass nothing. */
|
|
106
112
|
export interface RetrieveFanoutInternals {
|
|
107
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Per-target executor (attempts; throws on failure). Defaults to the live
|
|
115
|
+
* tail, which records the settled gate in `trace` before reading.
|
|
116
|
+
*/
|
|
108
117
|
execute?: (
|
|
109
118
|
resolved: ResolvedRetrieveRequest,
|
|
119
|
+
trace: ValidationTrace,
|
|
110
120
|
) => Promise<RetrieveSuccessEnvelope>;
|
|
111
121
|
/** Selection expansion. Defaults to the real CDB-backed expansion. */
|
|
112
122
|
expand?: (
|
|
@@ -190,6 +200,24 @@ export async function retrieveFanout(
|
|
|
190
200
|
internals: RetrieveFanoutInternals = {},
|
|
191
201
|
options: RetrieveFanoutOptions = {},
|
|
192
202
|
): Promise<RetrieveFanoutEnvelope> {
|
|
203
|
+
if (request.wait !== undefined)
|
|
204
|
+
throw new CentrsError({
|
|
205
|
+
code: "usage/fanout-not-supported",
|
|
206
|
+
summary: "`retrieve --wait` waits on one router and cannot fan out.",
|
|
207
|
+
remediation:
|
|
208
|
+
"Call `retrieveWait()` once per router with its own deadline.",
|
|
209
|
+
});
|
|
210
|
+
if (request.follow || request.sample !== undefined) {
|
|
211
|
+
const [flag, fn, capability] = request.follow
|
|
212
|
+
? ["--follow", "retrieveFollow", "follow"]
|
|
213
|
+
: ["--sample", "retrieveSample", "sample"];
|
|
214
|
+
throw new CentrsError({
|
|
215
|
+
code: "usage/fanout-not-supported",
|
|
216
|
+
summary: `\`retrieve ${flag}\` watches one router and cannot fan out across multiple targets.`,
|
|
217
|
+
remediation: `Call \`${fn}()\` once per router to watch several.`,
|
|
218
|
+
context: { capability },
|
|
219
|
+
});
|
|
220
|
+
}
|
|
193
221
|
const attributeSelections = validateRetrieveRequestShape(request);
|
|
194
222
|
const config = await loadEnvFileDefaults(env);
|
|
195
223
|
const global = resolveRetrieveGlobalContext(
|
|
@@ -237,6 +265,10 @@ export async function retrieveFanout(
|
|
|
237
265
|
}
|
|
238
266
|
|
|
239
267
|
const execute = internals.execute ?? runResolvedRetrieve;
|
|
268
|
+
// Per-target validation trace, keyed by the resolved request that both
|
|
269
|
+
// callbacks share, so a target that passed the gate and then failed reports
|
|
270
|
+
// the gate that ran (GH#435).
|
|
271
|
+
const traces = new WeakMap<ResolvedRetrieveRequest, ValidationTrace>();
|
|
240
272
|
const sleep = internals.sleep ?? defaultFanoutSleep;
|
|
241
273
|
|
|
242
274
|
const targets = await runFanout<
|
|
@@ -295,11 +327,18 @@ export async function retrieveFanout(
|
|
|
295
327
|
warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
|
|
296
328
|
summary: "Failed to resolve a fanout target.",
|
|
297
329
|
}),
|
|
298
|
-
execute: async (resolved, member) =>
|
|
299
|
-
|
|
330
|
+
execute: async (resolved, member) => {
|
|
331
|
+
const trace: ValidationTrace = {};
|
|
332
|
+
traces.set(resolved, trace);
|
|
333
|
+
return relabelAdhocMember(await execute(resolved, trace), member);
|
|
334
|
+
},
|
|
300
335
|
onExecuteError: (resolved, member, error) =>
|
|
301
336
|
relabelAdhocMember(
|
|
302
|
-
buildRetrieveErrorEnvelopeFromResolved(
|
|
337
|
+
buildRetrieveErrorEnvelopeFromResolved(
|
|
338
|
+
resolved,
|
|
339
|
+
error,
|
|
340
|
+
traces.get(resolved),
|
|
341
|
+
),
|
|
303
342
|
member,
|
|
304
343
|
),
|
|
305
344
|
sleep,
|
|
@@ -458,6 +497,8 @@ export function renderRetrieveFanoutEnvelope(
|
|
|
458
497
|
return JSON.stringify(envelope, null, 2);
|
|
459
498
|
case "yaml":
|
|
460
499
|
return toYaml(envelope);
|
|
500
|
+
case "ndjson":
|
|
501
|
+
return JSON.stringify(envelope);
|
|
461
502
|
default:
|
|
462
503
|
return renderFanoutText(envelope, options);
|
|
463
504
|
}
|
|
@@ -468,11 +509,7 @@ function renderFanoutText(
|
|
|
468
509
|
options: { verbose?: boolean },
|
|
469
510
|
): string {
|
|
470
511
|
if (!envelope.ok) {
|
|
471
|
-
|
|
472
|
-
if (envelope.error.remediation) {
|
|
473
|
-
lines.push(`Fix: ${envelope.error.remediation}`);
|
|
474
|
-
}
|
|
475
|
-
return lines.join("\n");
|
|
512
|
+
return formatCentrsErrorText(envelope.error, options);
|
|
476
513
|
}
|
|
477
514
|
|
|
478
515
|
const operation = envelope.meta.operation;
|
|
@@ -495,11 +532,13 @@ function renderFanoutText(
|
|
|
495
532
|
);
|
|
496
533
|
} else {
|
|
497
534
|
lines.push(
|
|
498
|
-
|
|
535
|
+
terminalSafeLine(
|
|
536
|
+
` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
|
|
537
|
+
),
|
|
499
538
|
);
|
|
500
539
|
}
|
|
501
540
|
if (options.verbose && !target.ok && target.error.remediation) {
|
|
502
|
-
lines.push(` Fix: ${target.error.remediation}`);
|
|
541
|
+
lines.push(terminalSafeLine(` Fix: ${target.error.remediation}`));
|
|
503
542
|
}
|
|
504
543
|
}
|
|
505
544
|
|