@tikoci/centrs 0.1.7 → 0.1.8-next.0
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 +235 -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/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 +216 -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/tools.ts +9 -14
- 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 +727 -108
- package/src/settings.ts +15 -15
- package/src/transfer-fanout.ts +40 -15
- package/src/transfer.ts +234 -32
package/src/retrieve.ts
CHANGED
|
@@ -5,19 +5,31 @@ import type {
|
|
|
5
5
|
CommonSettingsMeta,
|
|
6
6
|
SettingSource as CoreSettingSource,
|
|
7
7
|
EnvelopeValidationMeta,
|
|
8
|
+
ValidationTrace,
|
|
8
9
|
} from "./core/envelope.ts";
|
|
10
|
+
import { notReachedReason } from "./core/envelope.ts";
|
|
9
11
|
import {
|
|
10
12
|
extractCompletionNames,
|
|
11
13
|
inspectArgumentNames,
|
|
12
14
|
inspectChildren,
|
|
13
15
|
inspectChildrenOrEmpty,
|
|
14
16
|
inspectCompletions,
|
|
17
|
+
inspectWhereBooleans,
|
|
15
18
|
isArgumentNode,
|
|
16
19
|
isCommandNode,
|
|
17
20
|
pathTokens,
|
|
18
21
|
} from "./core/inspect.ts";
|
|
22
|
+
import {
|
|
23
|
+
compileQueryWords,
|
|
24
|
+
type ParsedQuery,
|
|
25
|
+
parseQueries,
|
|
26
|
+
} from "./core/query.ts";
|
|
19
27
|
import { toYaml } from "./core/yaml.ts";
|
|
20
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
CentrsError,
|
|
30
|
+
formatCentrsErrorText,
|
|
31
|
+
serializeCentrsError,
|
|
32
|
+
} from "./errors.ts";
|
|
21
33
|
import {
|
|
22
34
|
createProtocolAdapter,
|
|
23
35
|
type ProtocolAdapter,
|
|
@@ -50,7 +62,12 @@ import {
|
|
|
50
62
|
toCoreSource,
|
|
51
63
|
} from "./resolver/index.ts";
|
|
52
64
|
|
|
53
|
-
export const retrieveOutputFormats = [
|
|
65
|
+
export const retrieveOutputFormats = [
|
|
66
|
+
"text",
|
|
67
|
+
"json",
|
|
68
|
+
"yaml",
|
|
69
|
+
"ndjson",
|
|
70
|
+
] as const;
|
|
54
71
|
export type RetrieveOutputFormat = (typeof retrieveOutputFormats)[number];
|
|
55
72
|
|
|
56
73
|
export interface RetrieveRequest {
|
|
@@ -75,8 +92,10 @@ export interface RetrieveRequest {
|
|
|
75
92
|
attributes?: string | readonly string[];
|
|
76
93
|
allAttributes?: boolean;
|
|
77
94
|
listAttributes?: boolean;
|
|
78
|
-
|
|
79
|
-
|
|
95
|
+
/** Alias of `query`; both lists are AND-ed together. */
|
|
96
|
+
filter?: string | readonly string[];
|
|
97
|
+
/** Row filters written as after `print where` (`disabled`, `mtu>=1500 and !dynamic`); several are AND-ed. */
|
|
98
|
+
query?: string | readonly string[];
|
|
80
99
|
maxResultsBytes?: number;
|
|
81
100
|
cdbFile?: string;
|
|
82
101
|
cdbPassword?: string;
|
|
@@ -86,6 +105,22 @@ export interface RetrieveRequest {
|
|
|
86
105
|
group?: string;
|
|
87
106
|
/** Bounded worker-pool size for group fanout (defaults are transport-aware). */
|
|
88
107
|
concurrency?: number;
|
|
108
|
+
/** Keep the menu's rows current over native-api `listen` (`retrieveFollow`). */
|
|
109
|
+
follow?: boolean;
|
|
110
|
+
/** `--follow` only: `.id` sweep period (`10s` default; `0` turns it off). */
|
|
111
|
+
sweep?: string | number;
|
|
112
|
+
/** Read the menu again every interval (`retrieveSample`), e.g. `5s`. */
|
|
113
|
+
sample?: string | number;
|
|
114
|
+
/** `--follow`: stop after this many `live` frames; `--sample`: after this many samples. */
|
|
115
|
+
count?: number;
|
|
116
|
+
/** `--follow`/`--sample`: wall-clock bound counted from the end of validation (e.g. `30s`). */
|
|
117
|
+
duration?: string | number;
|
|
118
|
+
/** Overall readiness/condition deadline; consumed by `retrieveWait()`. */
|
|
119
|
+
wait?: string | number;
|
|
120
|
+
/** Same grammar as `--query`: succeed when any selected row matches. */
|
|
121
|
+
until?: string | readonly string[];
|
|
122
|
+
/** Succeed on a completed read with zero selected rows. */
|
|
123
|
+
untilEmpty?: boolean;
|
|
89
124
|
}
|
|
90
125
|
|
|
91
126
|
export interface RetrieveWarning {
|
|
@@ -104,8 +139,116 @@ export interface RetrieveRequestSummary {
|
|
|
104
139
|
timeoutMs: number;
|
|
105
140
|
format: RetrieveOutputFormat;
|
|
106
141
|
maxResultsBytes?: number;
|
|
142
|
+
/** The `--query` expressions, as given. */
|
|
143
|
+
query?: readonly string[];
|
|
144
|
+
/** Present when the request is a `--follow`. */
|
|
145
|
+
follow?: RetrieveFollowSettings;
|
|
146
|
+
/** Present when the request is a `--sample`. */
|
|
147
|
+
sample?: RetrieveSampleSettings;
|
|
148
|
+
wait?: RetrieveWaitSettings;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface RetrieveWaitSettings {
|
|
152
|
+
deadlineMs: number;
|
|
153
|
+
intervalMs: number;
|
|
154
|
+
until: readonly string[];
|
|
155
|
+
untilEmpty: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface RetrieveWaitMeta {
|
|
159
|
+
stopReason:
|
|
160
|
+
| "ready"
|
|
161
|
+
| "condition-met"
|
|
162
|
+
| "deadline-elapsed"
|
|
163
|
+
| "interrupted"
|
|
164
|
+
| "failed";
|
|
165
|
+
elapsedMs: number;
|
|
166
|
+
attempts: number;
|
|
167
|
+
observations: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The resolved cadence and bounds of a `--sample`. */
|
|
171
|
+
export interface RetrieveSampleSettings {
|
|
172
|
+
/** Time between the starts of two reads; a slow read delays the next one. */
|
|
173
|
+
intervalMs: number;
|
|
174
|
+
count?: number;
|
|
175
|
+
durationMs?: number;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The resolved bounds of a `--follow`. */
|
|
179
|
+
export interface RetrieveFollowSettings {
|
|
180
|
+
/** `.id` sweep period; `0` = off. */
|
|
181
|
+
sweepMs: number;
|
|
182
|
+
count?: number;
|
|
183
|
+
durationMs?: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Why a `--follow` ended. */
|
|
187
|
+
export type RetrieveFollowStopReason =
|
|
188
|
+
| "completed"
|
|
189
|
+
| "routeros-error"
|
|
190
|
+
| "count-reached"
|
|
191
|
+
| "duration-elapsed"
|
|
192
|
+
| "interrupted"
|
|
193
|
+
| "transport-error";
|
|
194
|
+
|
|
195
|
+
/** Counts carried by a `--follow` summary (also its `data`). */
|
|
196
|
+
export interface RetrieveFollowCounts {
|
|
197
|
+
/** Every frame line, snapshot and live. */
|
|
198
|
+
frames: number;
|
|
199
|
+
/** Frames before `synced`. */
|
|
200
|
+
snapshot: number;
|
|
201
|
+
/** `live` frames after `synced`; `--count` counts these. */
|
|
202
|
+
changes: number;
|
|
203
|
+
/** Completed `.id` sweeps. */
|
|
204
|
+
sweeps: number;
|
|
205
|
+
/** Whether the bootstrap finished before the follow ended. */
|
|
206
|
+
synced: boolean;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface RetrieveFollowSummary extends RetrieveFollowCounts {
|
|
210
|
+
stopReason: RetrieveFollowStopReason;
|
|
211
|
+
durationMs: number;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Why a `--sample` ended: it never completes on its own. */
|
|
215
|
+
export type RetrieveSampleStopReason = Exclude<
|
|
216
|
+
RetrieveFollowStopReason,
|
|
217
|
+
"completed"
|
|
218
|
+
>;
|
|
219
|
+
|
|
220
|
+
/** A `--sample` summary (also its `data`). */
|
|
221
|
+
export interface RetrieveSampleSummary {
|
|
222
|
+
stopReason: RetrieveSampleStopReason;
|
|
223
|
+
/** Sample lines emitted; a read still in flight at the stop is not one. */
|
|
224
|
+
samples: number;
|
|
225
|
+
durationMs: number;
|
|
107
226
|
}
|
|
108
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Per-line marker on `--follow` and `--sample` output
|
|
230
|
+
* (`commands/retrieve/README.md` → Follow, Sample). A `frame` is one row
|
|
231
|
+
* change: `upsert` carries the latest full row, `removed` carries
|
|
232
|
+
* `data: null`; `id` is the row identity even when the projection leaves `.id`
|
|
233
|
+
* out of `data`. A `sample` is one complete read; `at` is when it was sent
|
|
234
|
+
* (host clock) and `readMs` how long it took. The two summaries are told apart
|
|
235
|
+
* by their counts (`frames` vs `samples`).
|
|
236
|
+
*/
|
|
237
|
+
export type RetrieveStreamMeta =
|
|
238
|
+
| { kind: "notice" }
|
|
239
|
+
| {
|
|
240
|
+
kind: "frame";
|
|
241
|
+
index: number;
|
|
242
|
+
phase: "snapshot" | "live";
|
|
243
|
+
change: "upsert" | "removed";
|
|
244
|
+
id: string;
|
|
245
|
+
source: "print" | "listen" | "sweep" | "membership";
|
|
246
|
+
}
|
|
247
|
+
| { kind: "synced"; rows: number }
|
|
248
|
+
| { kind: "sample"; index: number; at: string; readMs: number }
|
|
249
|
+
| ({ kind: "summary" } & RetrieveFollowSummary)
|
|
250
|
+
| ({ kind: "summary" } & RetrieveSampleSummary);
|
|
251
|
+
|
|
109
252
|
export interface RetrieveOperationMeta {
|
|
110
253
|
kind: "attributes" | "data";
|
|
111
254
|
objectCount: number;
|
|
@@ -114,6 +257,10 @@ export interface RetrieveOperationMeta {
|
|
|
114
257
|
username?: string;
|
|
115
258
|
passwordProvided: boolean;
|
|
116
259
|
};
|
|
260
|
+
/** Present only on `--follow` and `--sample` output. */
|
|
261
|
+
stream?: RetrieveStreamMeta;
|
|
262
|
+
/** The bounded waiter outcome; a watch duration never satisfies a condition. */
|
|
263
|
+
wait?: RetrieveWaitMeta;
|
|
117
264
|
}
|
|
118
265
|
|
|
119
266
|
export type RetrieveEnvelope = CentrsEnvelope<unknown, RetrieveOperationMeta>;
|
|
@@ -123,7 +270,7 @@ export type RetrieveSuccessEnvelope = CentrsSuccessEnvelope<
|
|
|
123
270
|
>;
|
|
124
271
|
export type RetrieveErrorEnvelope = CentrsErrorEnvelope<RetrieveOperationMeta>;
|
|
125
272
|
|
|
126
|
-
interface RetrieveInspection {
|
|
273
|
+
export interface RetrieveInspection {
|
|
127
274
|
command: "get" | "print";
|
|
128
275
|
singleton: boolean;
|
|
129
276
|
}
|
|
@@ -149,15 +296,108 @@ export interface ResolvedRetrieveRequest {
|
|
|
149
296
|
allAttributes: boolean;
|
|
150
297
|
listAttributes: boolean;
|
|
151
298
|
verbose: boolean;
|
|
299
|
+
/** `--query`, parsed offline; compiled to words once the device says which bare names are booleans. */
|
|
300
|
+
query?: ResolvedRetrieveQuery;
|
|
301
|
+
follow?: RetrieveFollowSettings;
|
|
302
|
+
sample?: RetrieveSampleSettings;
|
|
303
|
+
wait?: RetrieveWaitSettings;
|
|
152
304
|
warnings: readonly RetrieveWarning[];
|
|
153
305
|
}
|
|
154
306
|
|
|
307
|
+
export interface ResolvedRetrieveQuery {
|
|
308
|
+
expressions: readonly string[];
|
|
309
|
+
parsed: ParsedQuery;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* What a data read needs after validation: the path's shape (undefined under
|
|
314
|
+
* `--validate=false`) and the compiled `--query` words.
|
|
315
|
+
*/
|
|
316
|
+
export interface RetrieveReadPlan {
|
|
317
|
+
inspection: RetrieveInspection | undefined;
|
|
318
|
+
query?: readonly string[];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export interface RetrieveOptions {
|
|
322
|
+
/**
|
|
323
|
+
* Called once resolution succeeds, before any connection. A caller that
|
|
324
|
+
* catches the later throw uses it to keep the resolved target, via and
|
|
325
|
+
* setting sources in its error envelope.
|
|
326
|
+
*/
|
|
327
|
+
onResolved?: (resolved: ResolvedRetrieveRequest) => void;
|
|
328
|
+
/**
|
|
329
|
+
* Receives the gate's result the moment validation settles, so an error
|
|
330
|
+
* envelope built after a later throw reports the validation that ran.
|
|
331
|
+
*/
|
|
332
|
+
trace?: ValidationTrace;
|
|
333
|
+
}
|
|
334
|
+
|
|
155
335
|
export async function retrieve(
|
|
156
336
|
request: RetrieveRequest,
|
|
157
337
|
env: Record<string, string | undefined> = Bun.env,
|
|
338
|
+
options: RetrieveOptions = {},
|
|
158
339
|
): Promise<RetrieveSuccessEnvelope> {
|
|
340
|
+
if (request.wait !== undefined) {
|
|
341
|
+
throw new CentrsError({
|
|
342
|
+
code: "input/invalid-command",
|
|
343
|
+
summary:
|
|
344
|
+
"Use `retrieveWait()` for a bounded readiness or condition wait.",
|
|
345
|
+
remediation:
|
|
346
|
+
"Call `retrieveWait(request)` (library) or `centrs retrieve … --wait 30s` (CLI); inspect its success or failure envelope.",
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
if (request.follow) {
|
|
350
|
+
throw new CentrsError({
|
|
351
|
+
code: "input/invalid-command",
|
|
352
|
+
summary:
|
|
353
|
+
"`follow` yields a stream of envelopes instead of one `retrieve` result.",
|
|
354
|
+
remediation:
|
|
355
|
+
"Consume it with `retrieveFollow()` (library) or `centrs retrieve … --follow` (CLI): snapshot frames, `synced`, live frames, then a summary.",
|
|
356
|
+
context: { capability: "follow" },
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
if (request.sample !== undefined) {
|
|
360
|
+
throw new CentrsError({
|
|
361
|
+
code: "input/invalid-command",
|
|
362
|
+
summary:
|
|
363
|
+
"`sample` yields a stream of envelopes instead of one `retrieve` result.",
|
|
364
|
+
remediation:
|
|
365
|
+
"Consume it with `retrieveSample()` (library) or `centrs retrieve … --sample <interval>` (CLI): one line per complete read, then a summary.",
|
|
366
|
+
context: { capability: "sample" },
|
|
367
|
+
});
|
|
368
|
+
}
|
|
159
369
|
const resolved = await resolveRetrieveRequest(request, env);
|
|
160
|
-
|
|
370
|
+
options.onResolved?.(resolved);
|
|
371
|
+
return runResolvedRetrieve(resolved, options.trace);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* {@link retrieve} as an envelope that never throws. A failure after resolution
|
|
376
|
+
* carries the resolved target, via and setting sources
|
|
377
|
+
* ({@link buildRetrieveErrorEnvelopeFromResolved}); a failure before it keeps
|
|
378
|
+
* only the caller's input ({@link buildRetrieveErrorEnvelope}).
|
|
379
|
+
*/
|
|
380
|
+
export async function retrieveEnvelope(
|
|
381
|
+
request: RetrieveRequest,
|
|
382
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
383
|
+
options: RetrieveOptions = {},
|
|
384
|
+
): Promise<RetrieveEnvelope> {
|
|
385
|
+
let resolved: ResolvedRetrieveRequest | undefined;
|
|
386
|
+
const trace = options.trace ?? {};
|
|
387
|
+
try {
|
|
388
|
+
return await retrieve(request, env, {
|
|
389
|
+
...options,
|
|
390
|
+
trace,
|
|
391
|
+
onResolved: (value) => {
|
|
392
|
+
resolved = value;
|
|
393
|
+
options.onResolved?.(value);
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
} catch (error) {
|
|
397
|
+
return resolved
|
|
398
|
+
? buildRetrieveErrorEnvelopeFromResolved(resolved, error, trace)
|
|
399
|
+
: buildRetrieveErrorEnvelope(request, error);
|
|
400
|
+
}
|
|
161
401
|
}
|
|
162
402
|
|
|
163
403
|
/**
|
|
@@ -169,10 +409,9 @@ export async function retrieve(
|
|
|
169
409
|
*/
|
|
170
410
|
export async function runResolvedRetrieve(
|
|
171
411
|
resolved: ResolvedRetrieveRequest,
|
|
412
|
+
trace: ValidationTrace = {},
|
|
172
413
|
): Promise<RetrieveSuccessEnvelope> {
|
|
173
414
|
const warnings: RetrieveWarning[] = [...resolved.warnings];
|
|
174
|
-
let availableAttributes: string[] | undefined;
|
|
175
|
-
let inspection: RetrieveInspection | undefined;
|
|
176
415
|
const backend = createProtocolAdapter({
|
|
177
416
|
protocol: resolved.via.value,
|
|
178
417
|
host: resolved.target.host,
|
|
@@ -186,78 +425,46 @@ export async function runResolvedRetrieve(
|
|
|
186
425
|
});
|
|
187
426
|
|
|
188
427
|
try {
|
|
189
|
-
if (resolved.listAttributes
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (
|
|
194
|
-
resolved.listAttributes ||
|
|
195
|
-
(resolved.validate.value && resolved.attributes.length > 0)
|
|
196
|
-
) {
|
|
197
|
-
availableAttributes = await inspectAttributes(
|
|
428
|
+
if (resolved.listAttributes) {
|
|
429
|
+
const availableAttributes = await inspectAttributes(
|
|
198
430
|
resolved,
|
|
199
|
-
|
|
431
|
+
await inspectRetrievePath(resolved, backend),
|
|
200
432
|
backend,
|
|
201
433
|
);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
434
|
+
const validation: EnvelopeValidationMeta = {
|
|
435
|
+
enabled: true,
|
|
436
|
+
source: "live /console/inspect request=child+completion",
|
|
437
|
+
result: "passed",
|
|
438
|
+
availableAttributes,
|
|
439
|
+
};
|
|
440
|
+
trace.validation = validation;
|
|
205
441
|
const envelope = buildSuccessEnvelope(
|
|
206
442
|
resolved,
|
|
207
443
|
{
|
|
208
444
|
kind: "attributes",
|
|
209
|
-
data: availableAttributes
|
|
210
|
-
},
|
|
211
|
-
{
|
|
212
|
-
enabled: true,
|
|
213
|
-
source: "live /console/inspect request=child+completion",
|
|
214
|
-
availableAttributes: availableAttributes ?? [],
|
|
445
|
+
data: availableAttributes,
|
|
215
446
|
},
|
|
447
|
+
validation,
|
|
216
448
|
warnings,
|
|
217
449
|
);
|
|
218
450
|
return applyMaxResultsBudget(envelope);
|
|
219
451
|
}
|
|
220
452
|
|
|
221
|
-
|
|
222
|
-
resolved
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (missing.length > 0) {
|
|
230
|
-
throw new CentrsError({
|
|
231
|
-
code: "validation/unknown-attribute",
|
|
232
|
-
summary: `Unknown RouterOS attribute ${missing.join(", ")} for ${resolved.path}.`,
|
|
233
|
-
remediation:
|
|
234
|
-
"Check the attribute name, or use `--list-attributes` to inspect the available properties first.",
|
|
235
|
-
context: {
|
|
236
|
-
path: resolved.path,
|
|
237
|
-
parameter: missing[0],
|
|
238
|
-
requestedAttributes: resolved.attributes,
|
|
239
|
-
availableAttributes,
|
|
240
|
-
},
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const data = await executeRetrieve(resolved, backend, inspection);
|
|
453
|
+
const { validation, ...plan } = await validateRetrieveRead(
|
|
454
|
+
resolved,
|
|
455
|
+
backend,
|
|
456
|
+
);
|
|
457
|
+
// Recorded BEFORE the read, so a RouterOS fault or transport drop below
|
|
458
|
+
// reports the gate that passed rather than a guess (GH#435).
|
|
459
|
+
trace.validation = validation;
|
|
460
|
+
const data = await executeRetrieve(resolved, backend, plan);
|
|
246
461
|
const envelope = buildSuccessEnvelope(
|
|
247
462
|
resolved,
|
|
248
463
|
{
|
|
249
464
|
kind: "data",
|
|
250
465
|
data,
|
|
251
466
|
},
|
|
252
|
-
|
|
253
|
-
enabled: resolved.validate.value,
|
|
254
|
-
source: resolved.validate.value
|
|
255
|
-
? availableAttributes
|
|
256
|
-
? "live /console/inspect request=child+completion"
|
|
257
|
-
: "live /console/inspect request=child"
|
|
258
|
-
: "disabled",
|
|
259
|
-
availableAttributes,
|
|
260
|
-
},
|
|
467
|
+
validation,
|
|
261
468
|
warnings,
|
|
262
469
|
);
|
|
263
470
|
return applyMaxResultsBudget(envelope);
|
|
@@ -266,6 +473,53 @@ export async function runResolvedRetrieve(
|
|
|
266
473
|
}
|
|
267
474
|
}
|
|
268
475
|
|
|
476
|
+
/**
|
|
477
|
+
* The inspect half of a data read: the path's shape (singleton or list) and
|
|
478
|
+
* the requested attributes, checked once. `--validate=false` skips both.
|
|
479
|
+
* Shared by one-shot retrieve and `--sample`, which reads many times after it.
|
|
480
|
+
*/
|
|
481
|
+
export async function validateRetrieveRead(
|
|
482
|
+
resolved: ResolvedRetrieveRequest,
|
|
483
|
+
backend: ProtocolAdapter,
|
|
484
|
+
): Promise<RetrieveReadPlan & { validation: EnvelopeValidationMeta }> {
|
|
485
|
+
if (!resolved.validate.value) {
|
|
486
|
+
return {
|
|
487
|
+
inspection: undefined,
|
|
488
|
+
query: compileQueryWithoutValidation(resolved),
|
|
489
|
+
validation: { enabled: false, source: "disabled" },
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
const inspection = await inspectRetrievePath(resolved, backend);
|
|
493
|
+
if (resolved.query && inspection.singleton) {
|
|
494
|
+
throw singletonQueryError(resolved, resolved.query);
|
|
495
|
+
}
|
|
496
|
+
const availableAttributes =
|
|
497
|
+
resolved.attributes.length > 0 || resolved.query !== undefined
|
|
498
|
+
? await assertKnownAttributes(resolved, inspection, backend)
|
|
499
|
+
: undefined;
|
|
500
|
+
const query =
|
|
501
|
+
resolved.query && availableAttributes
|
|
502
|
+
? await compileValidatedQuery(
|
|
503
|
+
resolved,
|
|
504
|
+
resolved.query,
|
|
505
|
+
availableAttributes,
|
|
506
|
+
backend,
|
|
507
|
+
)
|
|
508
|
+
: undefined;
|
|
509
|
+
return {
|
|
510
|
+
inspection,
|
|
511
|
+
query,
|
|
512
|
+
validation: {
|
|
513
|
+
enabled: true,
|
|
514
|
+
source: availableAttributes
|
|
515
|
+
? "live /console/inspect request=child+completion"
|
|
516
|
+
: "live /console/inspect request=child",
|
|
517
|
+
result: "passed",
|
|
518
|
+
availableAttributes,
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
269
523
|
export function buildRetrieveErrorEnvelope(
|
|
270
524
|
request: RetrieveRequest,
|
|
271
525
|
error: unknown,
|
|
@@ -310,6 +564,7 @@ export function buildRetrieveErrorEnvelope(
|
|
|
310
564
|
export function buildRetrieveErrorEnvelopeFromResolved(
|
|
311
565
|
resolved: ResolvedRetrieveRequest,
|
|
312
566
|
error: unknown,
|
|
567
|
+
trace?: ValidationTrace,
|
|
313
568
|
): RetrieveErrorEnvelope {
|
|
314
569
|
const centrsError =
|
|
315
570
|
error instanceof CentrsError
|
|
@@ -322,11 +577,10 @@ export function buildRetrieveErrorEnvelopeFromResolved(
|
|
|
322
577
|
cause: error,
|
|
323
578
|
});
|
|
324
579
|
|
|
325
|
-
const meta = metaFromResolved(
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
});
|
|
580
|
+
const meta = metaFromResolved(
|
|
581
|
+
resolved,
|
|
582
|
+
trace?.validation ?? unsettledValidationMeta(resolved, centrsError),
|
|
583
|
+
);
|
|
330
584
|
|
|
331
585
|
return {
|
|
332
586
|
ok: false,
|
|
@@ -337,6 +591,53 @@ export function buildRetrieveErrorEnvelopeFromResolved(
|
|
|
337
591
|
};
|
|
338
592
|
}
|
|
339
593
|
|
|
594
|
+
const RETRIEVE_DEVICE_STAGE_SOURCE = "live /console/inspect";
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* `meta.validation` for a failure with no settled gate result: the gate
|
|
598
|
+
* refused, or something stopped it before it answered (GH#435).
|
|
599
|
+
*
|
|
600
|
+
* Every retrieve gate refusal is a `CentrsError` the gate itself raised from
|
|
601
|
+
* an inspect answer: `validation/*`, and the singleton `--query` refusal
|
|
602
|
+
* (`usage/conflicting-flags`). A `transport/*` or `auth/*` failure means the
|
|
603
|
+
* device never answered; a `routeros/*` error is a device answer that is not
|
|
604
|
+
* a verdict; `internal/*` is a centrs defect. Those four report no `result`.
|
|
605
|
+
*/
|
|
606
|
+
function unsettledValidationMeta(
|
|
607
|
+
resolved: ResolvedRetrieveRequest,
|
|
608
|
+
error: CentrsError,
|
|
609
|
+
): EnvelopeValidationMeta {
|
|
610
|
+
if (!resolved.validate.value) {
|
|
611
|
+
return { enabled: false, source: "disabled" };
|
|
612
|
+
}
|
|
613
|
+
if (/^(transport|auth|routeros|internal)\//.test(error.code)) {
|
|
614
|
+
return {
|
|
615
|
+
enabled: true,
|
|
616
|
+
source: RETRIEVE_DEVICE_STAGE_SOURCE,
|
|
617
|
+
stages: [
|
|
618
|
+
{
|
|
619
|
+
stage: "device",
|
|
620
|
+
source: RETRIEVE_DEVICE_STAGE_SOURCE,
|
|
621
|
+
result: "skipped",
|
|
622
|
+
reason: notReachedReason(error.code),
|
|
623
|
+
},
|
|
624
|
+
],
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
return {
|
|
628
|
+
enabled: true,
|
|
629
|
+
source: RETRIEVE_DEVICE_STAGE_SOURCE,
|
|
630
|
+
result: "failed",
|
|
631
|
+
stages: [
|
|
632
|
+
{
|
|
633
|
+
stage: "device",
|
|
634
|
+
source: RETRIEVE_DEVICE_STAGE_SOURCE,
|
|
635
|
+
result: "failed",
|
|
636
|
+
},
|
|
637
|
+
],
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
340
641
|
export function renderRetrieveEnvelope(
|
|
341
642
|
envelope: RetrieveEnvelope,
|
|
342
643
|
format: RetrieveOutputFormat,
|
|
@@ -347,10 +648,17 @@ export function renderRetrieveEnvelope(
|
|
|
347
648
|
return JSON.stringify(envelope, null, 2);
|
|
348
649
|
case "yaml":
|
|
349
650
|
return toYaml(envelope);
|
|
651
|
+
case "ndjson":
|
|
652
|
+
return JSON.stringify(envelope);
|
|
350
653
|
case "text":
|
|
351
|
-
return
|
|
352
|
-
|
|
353
|
-
|
|
654
|
+
return (
|
|
655
|
+
(envelope.ok
|
|
656
|
+
? renderRetrieveSuccessText(envelope, options)
|
|
657
|
+
: formatCentrsErrorText(envelope.error, options)) +
|
|
658
|
+
(envelope.meta.operation?.wait
|
|
659
|
+
? `\nwait: ${envelope.meta.operation.wait.stopReason}; ${envelope.meta.operation.wait.elapsedMs}ms; ${envelope.meta.operation.wait.attempts} attempt(s), ${envelope.meta.operation.wait.observations} observation(s)`
|
|
660
|
+
: "")
|
|
661
|
+
);
|
|
354
662
|
default:
|
|
355
663
|
return exhaustiveOutputFormat(format);
|
|
356
664
|
}
|
|
@@ -416,26 +724,7 @@ function renderRetrieveSuccessText(
|
|
|
416
724
|
return lines.join("\n");
|
|
417
725
|
}
|
|
418
726
|
|
|
419
|
-
function
|
|
420
|
-
envelope: RetrieveErrorEnvelope,
|
|
421
|
-
options: { verbose?: boolean },
|
|
422
|
-
): string {
|
|
423
|
-
const { error } = envelope;
|
|
424
|
-
const lines = [`[${error.code}] ${error.summary}`];
|
|
425
|
-
|
|
426
|
-
if (error.remediation) {
|
|
427
|
-
lines.push(`Fix: ${error.remediation}`);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
if (options.verbose && error.context) {
|
|
431
|
-
lines.push("");
|
|
432
|
-
lines.push(JSON.stringify(error.context, null, 2));
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
return lines.join("\n");
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
function retrieveRequestSummary(
|
|
727
|
+
export function retrieveRequestSummary(
|
|
439
728
|
resolved: ResolvedRetrieveRequest,
|
|
440
729
|
): RetrieveRequestSummary {
|
|
441
730
|
return {
|
|
@@ -448,6 +737,10 @@ function retrieveRequestSummary(
|
|
|
448
737
|
timeoutMs: resolved.timeoutMs.value,
|
|
449
738
|
format: resolved.format.value,
|
|
450
739
|
maxResultsBytes: resolved.maxResultsBytes?.value,
|
|
740
|
+
...(resolved.query ? { query: resolved.query.expressions } : {}),
|
|
741
|
+
...(resolved.follow ? { follow: resolved.follow } : {}),
|
|
742
|
+
...(resolved.sample ? { sample: resolved.sample } : {}),
|
|
743
|
+
...(resolved.wait ? { wait: resolved.wait } : {}),
|
|
451
744
|
};
|
|
452
745
|
}
|
|
453
746
|
|
|
@@ -455,7 +748,7 @@ function retrieveRequestSummary(
|
|
|
455
748
|
* Common `target` / `via` / `settings` meta for a resolved target. Shared by
|
|
456
749
|
* success and per-target error envelopes so both carry identical provenance.
|
|
457
750
|
*/
|
|
458
|
-
function metaFromResolved(
|
|
751
|
+
export function metaFromResolved(
|
|
459
752
|
resolved: ResolvedRetrieveRequest,
|
|
460
753
|
validation: EnvelopeValidationMeta,
|
|
461
754
|
operation?: RetrieveOperationMeta,
|
|
@@ -513,7 +806,7 @@ function metaFromResolved(
|
|
|
513
806
|
};
|
|
514
807
|
}
|
|
515
808
|
|
|
516
|
-
function buildSuccessEnvelope(
|
|
809
|
+
export function buildSuccessEnvelope(
|
|
517
810
|
resolved: ResolvedRetrieveRequest,
|
|
518
811
|
result: { kind: "attributes" | "data"; data: unknown },
|
|
519
812
|
validation: EnvelopeValidationMeta,
|
|
@@ -538,7 +831,7 @@ function buildSuccessEnvelope(
|
|
|
538
831
|
};
|
|
539
832
|
}
|
|
540
833
|
|
|
541
|
-
function applyMaxResultsBudget(
|
|
834
|
+
export function applyMaxResultsBudget(
|
|
542
835
|
envelope: RetrieveSuccessEnvelope,
|
|
543
836
|
): RetrieveSuccessEnvelope {
|
|
544
837
|
const operation = envelope.meta.operation;
|
|
@@ -591,20 +884,20 @@ export function validateRetrieveRequestShape(
|
|
|
591
884
|
});
|
|
592
885
|
}
|
|
593
886
|
|
|
594
|
-
|
|
887
|
+
const query = resolveRetrieveQuery(request);
|
|
888
|
+
resolveWaitSettings(request);
|
|
889
|
+
if (query && request.listAttributes) {
|
|
595
890
|
throw new CentrsError({
|
|
596
|
-
code: "
|
|
597
|
-
summary:
|
|
598
|
-
"`--filter` and `--query` are not implemented yet for `retrieve`.",
|
|
891
|
+
code: "usage/conflicting-flags",
|
|
892
|
+
summary: "`--list-attributes` cannot be combined with `--query`.",
|
|
599
893
|
remediation:
|
|
600
|
-
"
|
|
601
|
-
context: {
|
|
602
|
-
filter: request.filter,
|
|
603
|
-
query: request.query,
|
|
604
|
-
},
|
|
894
|
+
"Run `--list-attributes` by itself to see the property names, then filter with `--query`.",
|
|
895
|
+
context: { flag: "--query" },
|
|
605
896
|
});
|
|
606
897
|
}
|
|
607
898
|
|
|
899
|
+
assertStreamShape(request);
|
|
900
|
+
|
|
608
901
|
const attributeSelections = normalizeAttributeSelection(request);
|
|
609
902
|
if (request.allAttributes && attributeSelections.length > 0) {
|
|
610
903
|
throw new CentrsError({
|
|
@@ -632,6 +925,195 @@ export function validateRetrieveRequestShape(
|
|
|
632
925
|
return attributeSelections;
|
|
633
926
|
}
|
|
634
927
|
|
|
928
|
+
/**
|
|
929
|
+
* `--follow`/`--sample` flag pairings, checked before any network work: the
|
|
930
|
+
* two are exclusive, their bounds need one of them, and a byte budget or an
|
|
931
|
+
* attribute listing has no meaning on a stream.
|
|
932
|
+
*/
|
|
933
|
+
function assertStreamShape(request: RetrieveRequest): void {
|
|
934
|
+
const sample = request.sample !== undefined && request.wait === undefined;
|
|
935
|
+
if (request.follow && sample) {
|
|
936
|
+
throw new CentrsError({
|
|
937
|
+
code: "usage/conflicting-flags",
|
|
938
|
+
summary: "`--follow` cannot be combined with `--sample`.",
|
|
939
|
+
remediation:
|
|
940
|
+
"Use `--follow` for object changes RouterOS announces, or `--sample <interval>` to read the menu on a timer (counters, state `listen` never reports).",
|
|
941
|
+
context: { flag: "--sample" },
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
if (!request.follow) {
|
|
945
|
+
const orphan = (
|
|
946
|
+
[
|
|
947
|
+
["--sweep", request.sweep],
|
|
948
|
+
["--count", sample ? undefined : request.count],
|
|
949
|
+
["--duration", sample ? undefined : request.duration],
|
|
950
|
+
] as const
|
|
951
|
+
).find(([, value]) => value !== undefined);
|
|
952
|
+
if (orphan) {
|
|
953
|
+
const followOnly = orphan[0] === "--sweep";
|
|
954
|
+
throw new CentrsError({
|
|
955
|
+
code: "usage/conflicting-flags",
|
|
956
|
+
summary: `\`${orphan[0]}\` only applies to ${followOnly ? "`--follow`" : "`--follow` or `--sample`"}.`,
|
|
957
|
+
remediation: followOnly
|
|
958
|
+
? "Add `--follow` to follow the menu, or drop `--sweep`."
|
|
959
|
+
: `Add \`--follow\` or \`--sample <interval>\`, or drop \`${orphan[0]}\` for a one-shot read.`,
|
|
960
|
+
context: { flag: orphan[0] },
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
if (!sample) return;
|
|
964
|
+
}
|
|
965
|
+
const mode = request.follow ? "--follow" : "--sample";
|
|
966
|
+
const conflict = request.listAttributes
|
|
967
|
+
? "--list-attributes"
|
|
968
|
+
: request.maxResultsBytes !== undefined
|
|
969
|
+
? "--max-results"
|
|
970
|
+
: undefined;
|
|
971
|
+
if (conflict) {
|
|
972
|
+
throw new CentrsError({
|
|
973
|
+
code: "usage/conflicting-flags",
|
|
974
|
+
summary: `\`${mode}\` cannot be combined with \`${conflict}\`.`,
|
|
975
|
+
remediation: `Run \`${conflict}\` as a one-shot read, then use \`${mode}\` without it.`,
|
|
976
|
+
context: { flag: conflict },
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
if (
|
|
980
|
+
request.count !== undefined &&
|
|
981
|
+
(!Number.isInteger(request.count) || request.count < 1)
|
|
982
|
+
) {
|
|
983
|
+
throw new CentrsError({
|
|
984
|
+
code: "settings/invalid-integer",
|
|
985
|
+
summary: `\`--count\` must be a positive integer. Received: ${request.count}`,
|
|
986
|
+
remediation: request.follow
|
|
987
|
+
? "Pass how many live changes to wait for, e.g. `--count 1` for the next change."
|
|
988
|
+
: "Pass how many samples to take, e.g. `--count 3`.",
|
|
989
|
+
context: { flag: "--count", count: request.count },
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function resolveWaitSettings(
|
|
995
|
+
request: RetrieveRequest,
|
|
996
|
+
): RetrieveWaitSettings | undefined {
|
|
997
|
+
const until = request.until === undefined ? [] : [request.until].flat();
|
|
998
|
+
if (until.length > 0) parseQueries(until, "--until");
|
|
999
|
+
if (request.wait === undefined) {
|
|
1000
|
+
if (until.length > 0 || request.untilEmpty)
|
|
1001
|
+
throw new CentrsError({
|
|
1002
|
+
code: "usage/conflicting-flags",
|
|
1003
|
+
summary: "`--until` and `--until-empty` need a `--wait` deadline.",
|
|
1004
|
+
remediation:
|
|
1005
|
+
"Add an overall bound, e.g. `--wait 2m`, so the condition cannot wait forever.",
|
|
1006
|
+
});
|
|
1007
|
+
return undefined;
|
|
1008
|
+
}
|
|
1009
|
+
const conflict = request.follow
|
|
1010
|
+
? "--follow"
|
|
1011
|
+
: request.count !== undefined
|
|
1012
|
+
? "--count"
|
|
1013
|
+
: request.duration !== undefined
|
|
1014
|
+
? "--duration"
|
|
1015
|
+
: request.listAttributes
|
|
1016
|
+
? "--list-attributes"
|
|
1017
|
+
: until.length > 0 && request.untilEmpty
|
|
1018
|
+
? "--until-empty"
|
|
1019
|
+
: undefined;
|
|
1020
|
+
if (conflict)
|
|
1021
|
+
throw new CentrsError({
|
|
1022
|
+
code: "usage/conflicting-flags",
|
|
1023
|
+
summary: `\`--wait\` cannot be combined with \`${conflict}\`.`,
|
|
1024
|
+
remediation:
|
|
1025
|
+
"Use `--wait` for a readiness/condition deadline, `--sample` for its polling cadence, and either `--until` or `--until-empty` for a condition.",
|
|
1026
|
+
});
|
|
1027
|
+
// A unit is required for the same reason as `--sample`: a bare `30` would
|
|
1028
|
+
// be a 30 ms deadline, which expires before validation completes.
|
|
1029
|
+
const text = String(request.wait).trim();
|
|
1030
|
+
const deadlineMs = /^\d+$/.test(text) ? -1 : parseDuration(text);
|
|
1031
|
+
if (deadlineMs <= 0)
|
|
1032
|
+
throw new CentrsError({
|
|
1033
|
+
code: "settings/invalid-timeout",
|
|
1034
|
+
summary: `\`--wait\` needs a positive deadline with a unit. Received: ${text}`,
|
|
1035
|
+
remediation:
|
|
1036
|
+
"Pass an overall duration such as `--wait 30s` or `--wait 5m`.",
|
|
1037
|
+
context: { flag: "--wait", value: text },
|
|
1038
|
+
});
|
|
1039
|
+
return {
|
|
1040
|
+
deadlineMs,
|
|
1041
|
+
intervalMs:
|
|
1042
|
+
request.sample === undefined
|
|
1043
|
+
? 1_000
|
|
1044
|
+
: parseStreamInterval("--sample", request.sample),
|
|
1045
|
+
until,
|
|
1046
|
+
untilEmpty: request.untilEmpty ?? false,
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function resolveFollowSettings(
|
|
1051
|
+
request: RetrieveRequest,
|
|
1052
|
+
): RetrieveFollowSettings | undefined {
|
|
1053
|
+
if (!request.follow) return undefined;
|
|
1054
|
+
const durationMs =
|
|
1055
|
+
request.duration === undefined
|
|
1056
|
+
? undefined
|
|
1057
|
+
: parseDuration(String(request.duration));
|
|
1058
|
+
return {
|
|
1059
|
+
sweepMs:
|
|
1060
|
+
request.sweep === undefined
|
|
1061
|
+
? FOLLOW_SWEEP_DEFAULT_MS
|
|
1062
|
+
: parseStreamInterval("--sweep", request.sweep),
|
|
1063
|
+
...(request.count !== undefined ? { count: request.count } : {}),
|
|
1064
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** `--sweep` default (#396 slice decisions, 2026-10-08). */
|
|
1069
|
+
export const FOLLOW_SWEEP_DEFAULT_MS = 10_000;
|
|
1070
|
+
|
|
1071
|
+
function resolveSampleSettings(
|
|
1072
|
+
request: RetrieveRequest,
|
|
1073
|
+
): RetrieveSampleSettings | undefined {
|
|
1074
|
+
if (request.sample === undefined || request.wait !== undefined)
|
|
1075
|
+
return undefined;
|
|
1076
|
+
const durationMs =
|
|
1077
|
+
request.duration === undefined
|
|
1078
|
+
? undefined
|
|
1079
|
+
: parseDuration(String(request.duration));
|
|
1080
|
+
return {
|
|
1081
|
+
intervalMs: parseStreamInterval("--sample", request.sample),
|
|
1082
|
+
...(request.count !== undefined ? { count: request.count } : {}),
|
|
1083
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* A `--sample` or `--sweep` period needs a unit: RouterOS reads a bare
|
|
1089
|
+
* `interval=5` as seconds while centrs durations read a bare number as
|
|
1090
|
+
* milliseconds, so `--sample 5` is refused rather than guessed. `--sweep 0`
|
|
1091
|
+
* (off) is the one bare number allowed; `--sample` must be above zero.
|
|
1092
|
+
*/
|
|
1093
|
+
function parseStreamInterval(
|
|
1094
|
+
flag: "--sample" | "--sweep",
|
|
1095
|
+
value: string | number,
|
|
1096
|
+
): number {
|
|
1097
|
+
const text = String(value).trim();
|
|
1098
|
+
const off = flag === "--sweep" && text === "0";
|
|
1099
|
+
const intervalMs = off ? 0 : /^\d+$/.test(text) ? -1 : parseDuration(text);
|
|
1100
|
+
if (intervalMs < 0 || (flag === "--sample" && intervalMs === 0)) {
|
|
1101
|
+
throw new CentrsError({
|
|
1102
|
+
code: "settings/invalid-timeout",
|
|
1103
|
+
summary:
|
|
1104
|
+
flag === "--sample"
|
|
1105
|
+
? `\`--sample\` needs a positive interval with a unit. Received: ${text}`
|
|
1106
|
+
: `\`--sweep\` needs a unit (or \`0\` to turn it off). Received: ${text}`,
|
|
1107
|
+
remediation:
|
|
1108
|
+
flag === "--sample"
|
|
1109
|
+
? "Pass the time between reads with its unit, e.g. `--sample 5s` or `--sample 500ms`."
|
|
1110
|
+
: "Pass the sweep period with its unit, e.g. `--sweep 10s` or `--sweep 500ms`, or `--sweep 0` to turn it off.",
|
|
1111
|
+
context: { flag, value: text },
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
return intervalMs;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
635
1117
|
/**
|
|
636
1118
|
* Build a {@link ResolvedRetrieveRequest} from the static request plus an
|
|
637
1119
|
* already-resolved CDB record (or `undefined` for a literal target). Fanout
|
|
@@ -725,6 +1207,10 @@ export function buildResolvedRetrieve(
|
|
|
725
1207
|
allAttributes: request.allAttributes ?? false,
|
|
726
1208
|
listAttributes: request.listAttributes ?? false,
|
|
727
1209
|
verbose: request.verbose ?? false,
|
|
1210
|
+
query: resolveRetrieveQuery(request),
|
|
1211
|
+
follow: resolveFollowSettings(request),
|
|
1212
|
+
sample: resolveSampleSettings(request),
|
|
1213
|
+
wait: resolveWaitSettings(request),
|
|
728
1214
|
warnings: connection
|
|
729
1215
|
? [...connection.warnings]
|
|
730
1216
|
: (cdbResolution?.warnings ?? []),
|
|
@@ -788,6 +1274,9 @@ export function resolveRetrieveGlobalContext(
|
|
|
788
1274
|
timeoutMs: timeoutMs.value,
|
|
789
1275
|
format: format.value,
|
|
790
1276
|
maxResultsBytes: maxResultsBytes?.value,
|
|
1277
|
+
...(queryExpressions(request).length > 0
|
|
1278
|
+
? { query: queryExpressions(request) }
|
|
1279
|
+
: {}),
|
|
791
1280
|
},
|
|
792
1281
|
settings: {
|
|
793
1282
|
via: toCoreSource(via.source),
|
|
@@ -885,11 +1374,13 @@ function resolveProtocol(
|
|
|
885
1374
|
cdb?: CdbResolution,
|
|
886
1375
|
config: Record<string, string | undefined> = {},
|
|
887
1376
|
): ResolvedSetting<RouterOsProtocol> {
|
|
1377
|
+
// A follow infers native-api when `via` is unset (REST cannot follow); a
|
|
1378
|
+
// `via` from any source still wins, and `retrieveFollow` rejects non-native.
|
|
888
1379
|
const via = resolveStringSetting(
|
|
889
1380
|
request.via,
|
|
890
1381
|
env,
|
|
891
1382
|
"CENTRS_VIA",
|
|
892
|
-
"rest-api",
|
|
1383
|
+
request.follow ? "native-api" : "rest-api",
|
|
893
1384
|
"via",
|
|
894
1385
|
undefined,
|
|
895
1386
|
cdb?.overrides.via,
|
|
@@ -974,7 +1465,7 @@ function resolveFormat(
|
|
|
974
1465
|
* trap. Attribute/completion discovery does NOT use the swallowing probe — traps
|
|
975
1466
|
* there surface as-is.
|
|
976
1467
|
*/
|
|
977
|
-
async function inspectRetrievePath(
|
|
1468
|
+
export async function inspectRetrievePath(
|
|
978
1469
|
resolved: ResolvedRetrieveRequest,
|
|
979
1470
|
backend: ProtocolAdapter,
|
|
980
1471
|
): Promise<RetrieveInspection> {
|
|
@@ -1016,6 +1507,130 @@ async function inspectRetrievePath(
|
|
|
1016
1507
|
};
|
|
1017
1508
|
}
|
|
1018
1509
|
|
|
1510
|
+
/**
|
|
1511
|
+
* Inspect the menu's attribute names and reject requested ones it lacks with
|
|
1512
|
+
* `validation/unknown-attribute`. Shared by one-shot retrieve and `--follow`.
|
|
1513
|
+
*/
|
|
1514
|
+
export async function assertKnownAttributes(
|
|
1515
|
+
resolved: ResolvedRetrieveRequest,
|
|
1516
|
+
inspection: RetrieveInspection,
|
|
1517
|
+
backend: ProtocolAdapter,
|
|
1518
|
+
): Promise<string[]> {
|
|
1519
|
+
const availableAttributes = await inspectAttributes(
|
|
1520
|
+
resolved,
|
|
1521
|
+
inspection,
|
|
1522
|
+
backend,
|
|
1523
|
+
);
|
|
1524
|
+
const missing = resolved.attributes.filter(
|
|
1525
|
+
(attribute) => !availableAttributes.includes(attribute),
|
|
1526
|
+
);
|
|
1527
|
+
if (missing.length > 0) {
|
|
1528
|
+
throw new CentrsError({
|
|
1529
|
+
code: "validation/unknown-attribute",
|
|
1530
|
+
summary: `Unknown RouterOS attribute ${missing.join(", ")} for ${resolved.path}.`,
|
|
1531
|
+
remediation:
|
|
1532
|
+
"Check the attribute name, or use `--list-attributes` to inspect the available properties first.",
|
|
1533
|
+
context: {
|
|
1534
|
+
path: resolved.path,
|
|
1535
|
+
parameter: missing[0],
|
|
1536
|
+
requestedAttributes: resolved.attributes,
|
|
1537
|
+
availableAttributes,
|
|
1538
|
+
},
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
return availableAttributes;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
function queryExpressions(request: RetrieveRequest): string[] {
|
|
1545
|
+
return [request.filter, request.query].flatMap((value) =>
|
|
1546
|
+
value === undefined ? [] : [value].flat(),
|
|
1547
|
+
);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/** Parse `--query`/`--filter` offline; a bad expression fails before any network work. */
|
|
1551
|
+
function resolveRetrieveQuery(
|
|
1552
|
+
request: RetrieveRequest,
|
|
1553
|
+
): ResolvedRetrieveQuery | undefined {
|
|
1554
|
+
const expressions = queryExpressions(request);
|
|
1555
|
+
if (expressions.length === 0) return undefined;
|
|
1556
|
+
return { expressions, parsed: parseQueries(expressions) };
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
/**
|
|
1560
|
+
* Validate the predicate's property names against the menu's attributes (the
|
|
1561
|
+
* device fails an unknown one silently: no rows, no trap) and ask it which bare
|
|
1562
|
+
* names are booleans, then compile.
|
|
1563
|
+
*/
|
|
1564
|
+
async function compileValidatedQuery(
|
|
1565
|
+
resolved: ResolvedRetrieveRequest,
|
|
1566
|
+
query: ResolvedRetrieveQuery,
|
|
1567
|
+
availableAttributes: readonly string[],
|
|
1568
|
+
backend: ProtocolAdapter,
|
|
1569
|
+
): Promise<string[]> {
|
|
1570
|
+
const known = new Set([...availableAttributes, ".id"]);
|
|
1571
|
+
const unknown = query.parsed.names.filter((name) => !known.has(name));
|
|
1572
|
+
if (unknown.length > 0) {
|
|
1573
|
+
throw new CentrsError({
|
|
1574
|
+
code: "validation/unknown-attribute",
|
|
1575
|
+
summary: `Unknown RouterOS attribute ${unknown.join(", ")} in --query for ${resolved.path}.`,
|
|
1576
|
+
remediation:
|
|
1577
|
+
"RouterOS answers a query on a misspelled property with no rows, not an error. Check the name with `--list-attributes`.",
|
|
1578
|
+
context: {
|
|
1579
|
+
path: resolved.path,
|
|
1580
|
+
parameter: unknown[0],
|
|
1581
|
+
flag: "--query",
|
|
1582
|
+
query: query.expressions,
|
|
1583
|
+
availableAttributes: [...known].sort(),
|
|
1584
|
+
},
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
const booleans = await inspectWhereBooleans(
|
|
1588
|
+
backend,
|
|
1589
|
+
resolved.path,
|
|
1590
|
+
query.parsed.bareNames,
|
|
1591
|
+
);
|
|
1592
|
+
return compileQueryWords(query.parsed, booleans);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/**
|
|
1596
|
+
* `--validate=false` skips inspect, so nothing can say whether a bare name is a
|
|
1597
|
+
* boolean (`disabled=yes`) or "is set"; refuse rather than guess.
|
|
1598
|
+
*/
|
|
1599
|
+
function compileQueryWithoutValidation(
|
|
1600
|
+
resolved: ResolvedRetrieveRequest,
|
|
1601
|
+
): string[] | undefined {
|
|
1602
|
+
const query = resolved.query;
|
|
1603
|
+
if (!query) return undefined;
|
|
1604
|
+
if (isKnownSingletonPath(resolved.path)) {
|
|
1605
|
+
throw singletonQueryError(resolved, query);
|
|
1606
|
+
}
|
|
1607
|
+
if (query.parsed.bareNames.length > 0) {
|
|
1608
|
+
throw new CentrsError({
|
|
1609
|
+
code: "input/invalid-query",
|
|
1610
|
+
summary: `A bare name in --query (${query.parsed.bareNames.join(", ")}) needs validation to learn whether it is a boolean.`,
|
|
1611
|
+
remediation:
|
|
1612
|
+
"Drop `--validate=false`, or write a boolean out as `disabled=yes`.",
|
|
1613
|
+
context: {
|
|
1614
|
+
query: query.expressions,
|
|
1615
|
+
bareNames: query.parsed.bareNames,
|
|
1616
|
+
},
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
return compileQueryWords(query.parsed, new Set());
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
function singletonQueryError(
|
|
1623
|
+
resolved: ResolvedRetrieveRequest,
|
|
1624
|
+
query: ResolvedRetrieveQuery,
|
|
1625
|
+
): CentrsError {
|
|
1626
|
+
return new CentrsError({
|
|
1627
|
+
code: "usage/conflicting-flags",
|
|
1628
|
+
summary: `${resolved.path} is a single record, so --query has no rows to filter.`,
|
|
1629
|
+
remediation: `Read it with \`centrs retrieve <router> ${resolved.path}\`, using \`--attribute <name>\` for one value.`,
|
|
1630
|
+
context: { path: resolved.path, flag: "--query", query: query.expressions },
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1019
1634
|
async function inspectAttributes(
|
|
1020
1635
|
resolved: ResolvedRetrieveRequest,
|
|
1021
1636
|
inspection: RetrieveInspection,
|
|
@@ -1048,13 +1663,15 @@ async function inspectAttributes(
|
|
|
1048
1663
|
.sort();
|
|
1049
1664
|
}
|
|
1050
1665
|
|
|
1051
|
-
|
|
1666
|
+
/** The data half of a read: one `get` (singleton) or `print` (list), projected. */
|
|
1667
|
+
export async function executeRetrieve(
|
|
1052
1668
|
resolved: ResolvedRetrieveRequest,
|
|
1053
1669
|
backend: ProtocolAdapter,
|
|
1054
|
-
|
|
1670
|
+
plan: RetrieveReadPlan,
|
|
1671
|
+
signal?: AbortSignal,
|
|
1055
1672
|
): Promise<unknown> {
|
|
1056
|
-
if (inspection?.singleton ?? isKnownSingletonPath(resolved.path)) {
|
|
1057
|
-
const data = await backend.getSingleton(resolved.path);
|
|
1673
|
+
if (plan.inspection?.singleton ?? isKnownSingletonPath(resolved.path)) {
|
|
1674
|
+
const data = await backend.getSingleton(resolved.path, { signal });
|
|
1058
1675
|
if (resolved.attributes.length > 0) {
|
|
1059
1676
|
return projectSingletonAttributes(data, resolved.attributes);
|
|
1060
1677
|
}
|
|
@@ -1064,11 +1681,13 @@ async function executeRetrieve(
|
|
|
1064
1681
|
return backend.list(resolved.path, {
|
|
1065
1682
|
proplist: resolved.attributes.length > 0 ? resolved.attributes : undefined,
|
|
1066
1683
|
detail: resolved.allAttributes,
|
|
1684
|
+
query: plan.query,
|
|
1685
|
+
signal,
|
|
1067
1686
|
});
|
|
1068
1687
|
}
|
|
1069
1688
|
|
|
1070
1689
|
/** Fallback for `--validate=false`, which skips the inspect that detects singletons. */
|
|
1071
|
-
function isKnownSingletonPath(path: string): boolean {
|
|
1690
|
+
export function isKnownSingletonPath(path: string): boolean {
|
|
1072
1691
|
return ["/system/resource", "/system/identity"].includes(
|
|
1073
1692
|
path.replace(/\/$/, ""),
|
|
1074
1693
|
);
|