@tikoci/centrs 0.1.6 → 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 +342 -0
- package/GLOSSARY.txt +32 -0
- package/README.md +10 -6
- package/SECURITY.md +1 -1
- package/commands/AGENTS.md +2 -1
- package/commands/api/AGENTS.md +55 -30
- package/commands/api/README.md +135 -42
- package/commands/api/examples.md +275 -19
- package/commands/devices/README.md +21 -4
- package/commands/devices/examples.md +33 -2
- package/commands/execute/README.md +60 -18
- package/commands/execute/examples.md +224 -0
- package/commands/explain/README.md +42 -5
- package/commands/mcp/README.md +2 -1
- package/commands/retrieve/README.md +348 -4
- package/commands/retrieve/examples.md +442 -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 +29 -21
- package/docs/CONSTITUTION.md +41 -16
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -1
- package/docs/errors/cdb/create-failed.md +20 -0
- package/docs/errors/cdb/file-exists.md +23 -0
- package/docs/errors/input/invalid-query.md +36 -0
- package/docs/errors/input/invalid-target.md +25 -3
- 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/routeros/invalid-value.md +3 -2
- package/docs/errors/settings/invalid-timeout.md +6 -0
- package/docs/errors/transport/cancel-unacknowledged.md +24 -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 +603 -194
- package/src/btest.ts +19 -17
- package/src/cli/api.ts +126 -34
- package/src/cli/devices.ts +16 -3
- 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 +70 -1
- package/src/core/execute-command.ts +313 -6
- package/src/core/inspect.ts +65 -27
- package/src/core/query.ts +508 -0
- package/src/core/routeros-errors.ts +67 -1
- package/src/data/winbox-cdb-write.ts +22 -8
- package/src/devices.ts +148 -37
- package/src/discover.ts +7 -8
- package/src/errors.ts +116 -7
- package/src/execute-fanout.ts +14 -9
- package/src/execute.ts +128 -116
- package/src/explain/catalog.ts +64 -16
- package/src/explain/menus.ts +27 -6
- package/src/explain.ts +6 -6
- package/src/index.ts +31 -1
- package/src/mcp/tools.ts +10 -15
- package/src/protocols/adapter.ts +235 -118
- package/src/protocols/btest-session.ts +3 -2
- package/src/protocols/endpoint.ts +10 -0
- package/src/protocols/mac-telnet-console.ts +12 -7
- package/src/protocols/native-api.ts +252 -73
- 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 +84 -18
- 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 +741 -109
- 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,18 +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,
|
|
13
|
+
inspectArgumentNames,
|
|
11
14
|
inspectChildren,
|
|
12
15
|
inspectChildrenOrEmpty,
|
|
13
16
|
inspectCompletions,
|
|
17
|
+
inspectWhereBooleans,
|
|
14
18
|
isArgumentNode,
|
|
15
19
|
isCommandNode,
|
|
16
20
|
pathTokens,
|
|
17
21
|
} from "./core/inspect.ts";
|
|
22
|
+
import {
|
|
23
|
+
compileQueryWords,
|
|
24
|
+
type ParsedQuery,
|
|
25
|
+
parseQueries,
|
|
26
|
+
} from "./core/query.ts";
|
|
18
27
|
import { toYaml } from "./core/yaml.ts";
|
|
19
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
CentrsError,
|
|
30
|
+
formatCentrsErrorText,
|
|
31
|
+
serializeCentrsError,
|
|
32
|
+
} from "./errors.ts";
|
|
20
33
|
import {
|
|
21
34
|
createProtocolAdapter,
|
|
22
35
|
type ProtocolAdapter,
|
|
@@ -49,7 +62,12 @@ import {
|
|
|
49
62
|
toCoreSource,
|
|
50
63
|
} from "./resolver/index.ts";
|
|
51
64
|
|
|
52
|
-
export const retrieveOutputFormats = [
|
|
65
|
+
export const retrieveOutputFormats = [
|
|
66
|
+
"text",
|
|
67
|
+
"json",
|
|
68
|
+
"yaml",
|
|
69
|
+
"ndjson",
|
|
70
|
+
] as const;
|
|
53
71
|
export type RetrieveOutputFormat = (typeof retrieveOutputFormats)[number];
|
|
54
72
|
|
|
55
73
|
export interface RetrieveRequest {
|
|
@@ -74,8 +92,10 @@ export interface RetrieveRequest {
|
|
|
74
92
|
attributes?: string | readonly string[];
|
|
75
93
|
allAttributes?: boolean;
|
|
76
94
|
listAttributes?: boolean;
|
|
77
|
-
|
|
78
|
-
|
|
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[];
|
|
79
99
|
maxResultsBytes?: number;
|
|
80
100
|
cdbFile?: string;
|
|
81
101
|
cdbPassword?: string;
|
|
@@ -85,6 +105,22 @@ export interface RetrieveRequest {
|
|
|
85
105
|
group?: string;
|
|
86
106
|
/** Bounded worker-pool size for group fanout (defaults are transport-aware). */
|
|
87
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;
|
|
88
124
|
}
|
|
89
125
|
|
|
90
126
|
export interface RetrieveWarning {
|
|
@@ -103,8 +139,116 @@ export interface RetrieveRequestSummary {
|
|
|
103
139
|
timeoutMs: number;
|
|
104
140
|
format: RetrieveOutputFormat;
|
|
105
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;
|
|
106
226
|
}
|
|
107
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
|
+
|
|
108
252
|
export interface RetrieveOperationMeta {
|
|
109
253
|
kind: "attributes" | "data";
|
|
110
254
|
objectCount: number;
|
|
@@ -113,6 +257,10 @@ export interface RetrieveOperationMeta {
|
|
|
113
257
|
username?: string;
|
|
114
258
|
passwordProvided: boolean;
|
|
115
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;
|
|
116
264
|
}
|
|
117
265
|
|
|
118
266
|
export type RetrieveEnvelope = CentrsEnvelope<unknown, RetrieveOperationMeta>;
|
|
@@ -122,8 +270,9 @@ export type RetrieveSuccessEnvelope = CentrsSuccessEnvelope<
|
|
|
122
270
|
>;
|
|
123
271
|
export type RetrieveErrorEnvelope = CentrsErrorEnvelope<RetrieveOperationMeta>;
|
|
124
272
|
|
|
125
|
-
interface RetrieveInspection {
|
|
273
|
+
export interface RetrieveInspection {
|
|
126
274
|
command: "get" | "print";
|
|
275
|
+
singleton: boolean;
|
|
127
276
|
}
|
|
128
277
|
|
|
129
278
|
export interface ResolvedRetrieveRequest {
|
|
@@ -147,15 +296,108 @@ export interface ResolvedRetrieveRequest {
|
|
|
147
296
|
allAttributes: boolean;
|
|
148
297
|
listAttributes: boolean;
|
|
149
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;
|
|
150
304
|
warnings: readonly RetrieveWarning[];
|
|
151
305
|
}
|
|
152
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
|
+
|
|
153
335
|
export async function retrieve(
|
|
154
336
|
request: RetrieveRequest,
|
|
155
337
|
env: Record<string, string | undefined> = Bun.env,
|
|
338
|
+
options: RetrieveOptions = {},
|
|
156
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
|
+
}
|
|
157
369
|
const resolved = await resolveRetrieveRequest(request, env);
|
|
158
|
-
|
|
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
|
+
}
|
|
159
401
|
}
|
|
160
402
|
|
|
161
403
|
/**
|
|
@@ -167,10 +409,9 @@ export async function retrieve(
|
|
|
167
409
|
*/
|
|
168
410
|
export async function runResolvedRetrieve(
|
|
169
411
|
resolved: ResolvedRetrieveRequest,
|
|
412
|
+
trace: ValidationTrace = {},
|
|
170
413
|
): Promise<RetrieveSuccessEnvelope> {
|
|
171
414
|
const warnings: RetrieveWarning[] = [...resolved.warnings];
|
|
172
|
-
let availableAttributes: string[] | undefined;
|
|
173
|
-
let inspection: RetrieveInspection | undefined;
|
|
174
415
|
const backend = createProtocolAdapter({
|
|
175
416
|
protocol: resolved.via.value,
|
|
176
417
|
host: resolved.target.host,
|
|
@@ -184,78 +425,46 @@ export async function runResolvedRetrieve(
|
|
|
184
425
|
});
|
|
185
426
|
|
|
186
427
|
try {
|
|
187
|
-
if (resolved.listAttributes
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
resolved.listAttributes ||
|
|
193
|
-
(resolved.validate.value && resolved.attributes.length > 0)
|
|
194
|
-
) {
|
|
195
|
-
availableAttributes = await inspectAttributes(
|
|
428
|
+
if (resolved.listAttributes) {
|
|
429
|
+
const availableAttributes = await inspectAttributes(
|
|
196
430
|
resolved,
|
|
197
|
-
|
|
431
|
+
await inspectRetrievePath(resolved, backend),
|
|
198
432
|
backend,
|
|
199
433
|
);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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;
|
|
203
441
|
const envelope = buildSuccessEnvelope(
|
|
204
442
|
resolved,
|
|
205
443
|
{
|
|
206
444
|
kind: "attributes",
|
|
207
|
-
data: availableAttributes
|
|
208
|
-
},
|
|
209
|
-
{
|
|
210
|
-
enabled: true,
|
|
211
|
-
source: "live /console/inspect request=child+completion",
|
|
212
|
-
availableAttributes: availableAttributes ?? [],
|
|
445
|
+
data: availableAttributes,
|
|
213
446
|
},
|
|
447
|
+
validation,
|
|
214
448
|
warnings,
|
|
215
449
|
);
|
|
216
450
|
return applyMaxResultsBudget(envelope);
|
|
217
451
|
}
|
|
218
452
|
|
|
219
|
-
|
|
220
|
-
resolved
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (missing.length > 0) {
|
|
228
|
-
throw new CentrsError({
|
|
229
|
-
code: "validation/unknown-attribute",
|
|
230
|
-
summary: `Unknown RouterOS attribute ${missing.join(", ")} for ${resolved.path}.`,
|
|
231
|
-
remediation:
|
|
232
|
-
"Check the attribute name, or use `--list-attributes` to inspect the available properties first.",
|
|
233
|
-
context: {
|
|
234
|
-
path: resolved.path,
|
|
235
|
-
parameter: missing[0],
|
|
236
|
-
requestedAttributes: resolved.attributes,
|
|
237
|
-
availableAttributes,
|
|
238
|
-
},
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const data = await executeRetrieve(resolved, backend);
|
|
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);
|
|
244
461
|
const envelope = buildSuccessEnvelope(
|
|
245
462
|
resolved,
|
|
246
463
|
{
|
|
247
464
|
kind: "data",
|
|
248
465
|
data,
|
|
249
466
|
},
|
|
250
|
-
|
|
251
|
-
enabled: resolved.validate.value,
|
|
252
|
-
source: resolved.validate.value
|
|
253
|
-
? availableAttributes
|
|
254
|
-
? "live /console/inspect request=child+completion"
|
|
255
|
-
: "live /console/inspect request=child"
|
|
256
|
-
: "disabled",
|
|
257
|
-
availableAttributes,
|
|
258
|
-
},
|
|
467
|
+
validation,
|
|
259
468
|
warnings,
|
|
260
469
|
);
|
|
261
470
|
return applyMaxResultsBudget(envelope);
|
|
@@ -264,6 +473,53 @@ export async function runResolvedRetrieve(
|
|
|
264
473
|
}
|
|
265
474
|
}
|
|
266
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
|
+
|
|
267
523
|
export function buildRetrieveErrorEnvelope(
|
|
268
524
|
request: RetrieveRequest,
|
|
269
525
|
error: unknown,
|
|
@@ -308,6 +564,7 @@ export function buildRetrieveErrorEnvelope(
|
|
|
308
564
|
export function buildRetrieveErrorEnvelopeFromResolved(
|
|
309
565
|
resolved: ResolvedRetrieveRequest,
|
|
310
566
|
error: unknown,
|
|
567
|
+
trace?: ValidationTrace,
|
|
311
568
|
): RetrieveErrorEnvelope {
|
|
312
569
|
const centrsError =
|
|
313
570
|
error instanceof CentrsError
|
|
@@ -320,11 +577,10 @@ export function buildRetrieveErrorEnvelopeFromResolved(
|
|
|
320
577
|
cause: error,
|
|
321
578
|
});
|
|
322
579
|
|
|
323
|
-
const meta = metaFromResolved(
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
});
|
|
580
|
+
const meta = metaFromResolved(
|
|
581
|
+
resolved,
|
|
582
|
+
trace?.validation ?? unsettledValidationMeta(resolved, centrsError),
|
|
583
|
+
);
|
|
328
584
|
|
|
329
585
|
return {
|
|
330
586
|
ok: false,
|
|
@@ -335,6 +591,53 @@ export function buildRetrieveErrorEnvelopeFromResolved(
|
|
|
335
591
|
};
|
|
336
592
|
}
|
|
337
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
|
+
|
|
338
641
|
export function renderRetrieveEnvelope(
|
|
339
642
|
envelope: RetrieveEnvelope,
|
|
340
643
|
format: RetrieveOutputFormat,
|
|
@@ -345,10 +648,17 @@ export function renderRetrieveEnvelope(
|
|
|
345
648
|
return JSON.stringify(envelope, null, 2);
|
|
346
649
|
case "yaml":
|
|
347
650
|
return toYaml(envelope);
|
|
651
|
+
case "ndjson":
|
|
652
|
+
return JSON.stringify(envelope);
|
|
348
653
|
case "text":
|
|
349
|
-
return
|
|
350
|
-
|
|
351
|
-
|
|
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
|
+
);
|
|
352
662
|
default:
|
|
353
663
|
return exhaustiveOutputFormat(format);
|
|
354
664
|
}
|
|
@@ -414,26 +724,7 @@ function renderRetrieveSuccessText(
|
|
|
414
724
|
return lines.join("\n");
|
|
415
725
|
}
|
|
416
726
|
|
|
417
|
-
function
|
|
418
|
-
envelope: RetrieveErrorEnvelope,
|
|
419
|
-
options: { verbose?: boolean },
|
|
420
|
-
): string {
|
|
421
|
-
const { error } = envelope;
|
|
422
|
-
const lines = [`[${error.code}] ${error.summary}`];
|
|
423
|
-
|
|
424
|
-
if (error.remediation) {
|
|
425
|
-
lines.push(`Fix: ${error.remediation}`);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
if (options.verbose && error.context) {
|
|
429
|
-
lines.push("");
|
|
430
|
-
lines.push(JSON.stringify(error.context, null, 2));
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
return lines.join("\n");
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
function retrieveRequestSummary(
|
|
727
|
+
export function retrieveRequestSummary(
|
|
437
728
|
resolved: ResolvedRetrieveRequest,
|
|
438
729
|
): RetrieveRequestSummary {
|
|
439
730
|
return {
|
|
@@ -446,6 +737,10 @@ function retrieveRequestSummary(
|
|
|
446
737
|
timeoutMs: resolved.timeoutMs.value,
|
|
447
738
|
format: resolved.format.value,
|
|
448
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 } : {}),
|
|
449
744
|
};
|
|
450
745
|
}
|
|
451
746
|
|
|
@@ -453,7 +748,7 @@ function retrieveRequestSummary(
|
|
|
453
748
|
* Common `target` / `via` / `settings` meta for a resolved target. Shared by
|
|
454
749
|
* success and per-target error envelopes so both carry identical provenance.
|
|
455
750
|
*/
|
|
456
|
-
function metaFromResolved(
|
|
751
|
+
export function metaFromResolved(
|
|
457
752
|
resolved: ResolvedRetrieveRequest,
|
|
458
753
|
validation: EnvelopeValidationMeta,
|
|
459
754
|
operation?: RetrieveOperationMeta,
|
|
@@ -511,7 +806,7 @@ function metaFromResolved(
|
|
|
511
806
|
};
|
|
512
807
|
}
|
|
513
808
|
|
|
514
|
-
function buildSuccessEnvelope(
|
|
809
|
+
export function buildSuccessEnvelope(
|
|
515
810
|
resolved: ResolvedRetrieveRequest,
|
|
516
811
|
result: { kind: "attributes" | "data"; data: unknown },
|
|
517
812
|
validation: EnvelopeValidationMeta,
|
|
@@ -536,7 +831,7 @@ function buildSuccessEnvelope(
|
|
|
536
831
|
};
|
|
537
832
|
}
|
|
538
833
|
|
|
539
|
-
function applyMaxResultsBudget(
|
|
834
|
+
export function applyMaxResultsBudget(
|
|
540
835
|
envelope: RetrieveSuccessEnvelope,
|
|
541
836
|
): RetrieveSuccessEnvelope {
|
|
542
837
|
const operation = envelope.meta.operation;
|
|
@@ -589,20 +884,20 @@ export function validateRetrieveRequestShape(
|
|
|
589
884
|
});
|
|
590
885
|
}
|
|
591
886
|
|
|
592
|
-
|
|
887
|
+
const query = resolveRetrieveQuery(request);
|
|
888
|
+
resolveWaitSettings(request);
|
|
889
|
+
if (query && request.listAttributes) {
|
|
593
890
|
throw new CentrsError({
|
|
594
|
-
code: "
|
|
595
|
-
summary:
|
|
596
|
-
"`--filter` and `--query` are not implemented yet for `retrieve`.",
|
|
891
|
+
code: "usage/conflicting-flags",
|
|
892
|
+
summary: "`--list-attributes` cannot be combined with `--query`.",
|
|
597
893
|
remediation:
|
|
598
|
-
"
|
|
599
|
-
context: {
|
|
600
|
-
filter: request.filter,
|
|
601
|
-
query: request.query,
|
|
602
|
-
},
|
|
894
|
+
"Run `--list-attributes` by itself to see the property names, then filter with `--query`.",
|
|
895
|
+
context: { flag: "--query" },
|
|
603
896
|
});
|
|
604
897
|
}
|
|
605
898
|
|
|
899
|
+
assertStreamShape(request);
|
|
900
|
+
|
|
606
901
|
const attributeSelections = normalizeAttributeSelection(request);
|
|
607
902
|
if (request.allAttributes && attributeSelections.length > 0) {
|
|
608
903
|
throw new CentrsError({
|
|
@@ -630,6 +925,195 @@ export function validateRetrieveRequestShape(
|
|
|
630
925
|
return attributeSelections;
|
|
631
926
|
}
|
|
632
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
|
+
|
|
633
1117
|
/**
|
|
634
1118
|
* Build a {@link ResolvedRetrieveRequest} from the static request plus an
|
|
635
1119
|
* already-resolved CDB record (or `undefined` for a literal target). Fanout
|
|
@@ -723,6 +1207,10 @@ export function buildResolvedRetrieve(
|
|
|
723
1207
|
allAttributes: request.allAttributes ?? false,
|
|
724
1208
|
listAttributes: request.listAttributes ?? false,
|
|
725
1209
|
verbose: request.verbose ?? false,
|
|
1210
|
+
query: resolveRetrieveQuery(request),
|
|
1211
|
+
follow: resolveFollowSettings(request),
|
|
1212
|
+
sample: resolveSampleSettings(request),
|
|
1213
|
+
wait: resolveWaitSettings(request),
|
|
726
1214
|
warnings: connection
|
|
727
1215
|
? [...connection.warnings]
|
|
728
1216
|
: (cdbResolution?.warnings ?? []),
|
|
@@ -786,6 +1274,9 @@ export function resolveRetrieveGlobalContext(
|
|
|
786
1274
|
timeoutMs: timeoutMs.value,
|
|
787
1275
|
format: format.value,
|
|
788
1276
|
maxResultsBytes: maxResultsBytes?.value,
|
|
1277
|
+
...(queryExpressions(request).length > 0
|
|
1278
|
+
? { query: queryExpressions(request) }
|
|
1279
|
+
: {}),
|
|
789
1280
|
},
|
|
790
1281
|
settings: {
|
|
791
1282
|
via: toCoreSource(via.source),
|
|
@@ -883,11 +1374,13 @@ function resolveProtocol(
|
|
|
883
1374
|
cdb?: CdbResolution,
|
|
884
1375
|
config: Record<string, string | undefined> = {},
|
|
885
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.
|
|
886
1379
|
const via = resolveStringSetting(
|
|
887
1380
|
request.via,
|
|
888
1381
|
env,
|
|
889
1382
|
"CENTRS_VIA",
|
|
890
|
-
"rest-api",
|
|
1383
|
+
request.follow ? "native-api" : "rest-api",
|
|
891
1384
|
"via",
|
|
892
1385
|
undefined,
|
|
893
1386
|
cdb?.overrides.via,
|
|
@@ -972,7 +1465,7 @@ function resolveFormat(
|
|
|
972
1465
|
* trap. Attribute/completion discovery does NOT use the swallowing probe — traps
|
|
973
1466
|
* there surface as-is.
|
|
974
1467
|
*/
|
|
975
|
-
async function inspectRetrievePath(
|
|
1468
|
+
export async function inspectRetrievePath(
|
|
976
1469
|
resolved: ResolvedRetrieveRequest,
|
|
977
1470
|
backend: ProtocolAdapter,
|
|
978
1471
|
): Promise<RetrieveInspection> {
|
|
@@ -999,12 +1492,145 @@ async function inspectRetrievePath(
|
|
|
999
1492
|
});
|
|
1000
1493
|
}
|
|
1001
1494
|
|
|
1002
|
-
|
|
1495
|
+
// A singleton's `get` takes only `value-name`; a list menu's also takes
|
|
1496
|
+
// `number` (CHR 7.23.7: /tool/romon, /ip/dns, /system/identity vs
|
|
1497
|
+
// /ip/address). A hardcoded path list missed every other singleton and
|
|
1498
|
+
// validated its properties against `print`'s flags (issue #377).
|
|
1499
|
+
const singleton =
|
|
1500
|
+
supportsGet &&
|
|
1501
|
+
!(await inspectArgumentNames(backend, [...tokens, "get"])).includes(
|
|
1502
|
+
"number",
|
|
1503
|
+
);
|
|
1003
1504
|
return {
|
|
1004
|
-
command: singleton
|
|
1505
|
+
command: singleton ? "get" : "print",
|
|
1506
|
+
singleton,
|
|
1005
1507
|
};
|
|
1006
1508
|
}
|
|
1007
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
|
+
|
|
1008
1634
|
async function inspectAttributes(
|
|
1009
1635
|
resolved: ResolvedRetrieveRequest,
|
|
1010
1636
|
inspection: RetrieveInspection,
|
|
@@ -1037,12 +1663,15 @@ async function inspectAttributes(
|
|
|
1037
1663
|
.sort();
|
|
1038
1664
|
}
|
|
1039
1665
|
|
|
1040
|
-
|
|
1666
|
+
/** The data half of a read: one `get` (singleton) or `print` (list), projected. */
|
|
1667
|
+
export async function executeRetrieve(
|
|
1041
1668
|
resolved: ResolvedRetrieveRequest,
|
|
1042
1669
|
backend: ProtocolAdapter,
|
|
1670
|
+
plan: RetrieveReadPlan,
|
|
1671
|
+
signal?: AbortSignal,
|
|
1043
1672
|
): Promise<unknown> {
|
|
1044
|
-
if (isKnownSingletonPath(resolved.path)) {
|
|
1045
|
-
const data = await backend.getSingleton(resolved.path);
|
|
1673
|
+
if (plan.inspection?.singleton ?? isKnownSingletonPath(resolved.path)) {
|
|
1674
|
+
const data = await backend.getSingleton(resolved.path, { signal });
|
|
1046
1675
|
if (resolved.attributes.length > 0) {
|
|
1047
1676
|
return projectSingletonAttributes(data, resolved.attributes);
|
|
1048
1677
|
}
|
|
@@ -1052,10 +1681,13 @@ async function executeRetrieve(
|
|
|
1052
1681
|
return backend.list(resolved.path, {
|
|
1053
1682
|
proplist: resolved.attributes.length > 0 ? resolved.attributes : undefined,
|
|
1054
1683
|
detail: resolved.allAttributes,
|
|
1684
|
+
query: plan.query,
|
|
1685
|
+
signal,
|
|
1055
1686
|
});
|
|
1056
1687
|
}
|
|
1057
1688
|
|
|
1058
|
-
|
|
1689
|
+
/** Fallback for `--validate=false`, which skips the inspect that detects singletons. */
|
|
1690
|
+
export function isKnownSingletonPath(path: string): boolean {
|
|
1059
1691
|
return ["/system/resource", "/system/identity"].includes(
|
|
1060
1692
|
path.replace(/\/$/, ""),
|
|
1061
1693
|
);
|