@tikoci/centrs 0.1.8 → 0.1.9-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 +48 -0
- package/README.md +4 -0
- package/commands/api/README.md +5 -0
- package/commands/execute/README.md +117 -0
- package/commands/execute/examples.md +162 -0
- package/commands/retrieve/README.md +2 -1
- package/docs/CLI.md +2 -0
- package/docs/CONSTITUTION.md +29 -1
- package/docs/MATRIX.md +19 -3
- package/docs/errors/cmr/cancelled.md +15 -0
- package/docs/errors/cmr/client-disconnected.md +19 -0
- package/docs/errors/cmr/client-script-not-validated.md +22 -0
- package/docs/errors/cmr/console-only.md +29 -0
- package/docs/errors/cmr/empty-selection.md +19 -0
- package/docs/errors/cmr/membership-changed.md +20 -0
- package/docs/errors/cmr/not-a-controller.md +26 -0
- package/docs/errors/cmr/outcome-unknown.md +29 -0
- package/docs/errors/cmr/output-truncated.md +16 -0
- package/docs/errors/cmr/policy-denied.md +31 -0
- package/docs/errors/cmr/selection-incomplete.md +18 -0
- package/docs/errors/cmr/validation-incomplete.md +23 -0
- package/docs/errors/routeros/version-unsupported.md +4 -0
- package/docs/errors/transport/write-outcome-unknown.md +24 -0
- package/docs/errors/usage/missing-labels.md +19 -0
- package/package.json +1 -1
- package/src/api-fanout.ts +7 -1
- package/src/api.ts +25 -6
- package/src/cli/execute.ts +132 -1
- package/src/core/cmr.ts +104 -0
- package/src/core/envelope.ts +16 -0
- package/src/core/error-catalog.ts +71 -0
- package/src/core/fanout.ts +77 -8
- package/src/errors.ts +1 -0
- package/src/execute-cmr.ts +1425 -0
- package/src/execute-fanout.ts +4 -0
- package/src/execute.ts +68 -17
- package/src/index.ts +17 -0
- package/src/protocols/adapter.ts +54 -12
|
@@ -0,0 +1,1425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `execute --cmr`: run one validated RouterOS command on CMR clients through
|
|
3
|
+
* their controller (#450).
|
|
4
|
+
*
|
|
5
|
+
* CMR (RouterOS 7.26beta1+) keeps a device inventory on a controller and runs
|
|
6
|
+
* scripts on paired clients with `/cmr/device/run-script`. centrs talks only to
|
|
7
|
+
* the controller, with the controller's credentials; a client's address is
|
|
8
|
+
* never dialed. CMR is a selection source here, not a protocol: `--via` pins
|
|
9
|
+
* the controller transport.
|
|
10
|
+
*
|
|
11
|
+
* Grounded on 7.26beta1 x86 CHR (#441 and the #450 probes; the device facts
|
|
12
|
+
* this module relies on are asserted in `test/integration/execute-cmr.test.ts`):
|
|
13
|
+
*
|
|
14
|
+
* - `run-script` rows are `{.section, device, status, output}` progress
|
|
15
|
+
* snapshots. They carry NO device id, and `device` is the controller's
|
|
16
|
+
* cached `identity@address`, which changed mid-call when a client dropped.
|
|
17
|
+
* So every call here selects exactly ONE device (`numbers=<.id>`): the rows
|
|
18
|
+
* of that call are that device's, with nothing to correlate.
|
|
19
|
+
* - `numbers` and `labels` together select their intersection; an empty one
|
|
20
|
+
* is refused up front with `failure: no devices selected` (nothing sent).
|
|
21
|
+
* That refusal is how a preflight call learns "not a member", and how a
|
|
22
|
+
* dispatch learns the label set moved since preflight.
|
|
23
|
+
* - `failed` with `timed out (6)` or `std failure: timeout (13)` (a ~60 s
|
|
24
|
+
* ceiling `timeout=` does not lift), and a cancelled call, all left the
|
|
25
|
+
* client script RUNNING; its side effects landed later. A call that did not
|
|
26
|
+
* end in a final row is an unknown outcome, never "did not run", and is
|
|
27
|
+
* never retried.
|
|
28
|
+
* - Output is clipped at ~60 KiB with an in-band `...(+N B)` and status
|
|
29
|
+
* `success`. A validation result therefore passes a completeness gate (a
|
|
30
|
+
* trailing nonce line) before its `:parse` text is classified.
|
|
31
|
+
* - Scripts run with only the `read`, `write`, `test` and `reboot` policies
|
|
32
|
+
* (no user session). `/user/add` and `/tool/sniffer` fail at RUN time with
|
|
33
|
+
* `not enough permissions (9)` although `:parse` accepted them, so that is
|
|
34
|
+
* a dispatch outcome (`cmr/policy-denied`), never a validation result.
|
|
35
|
+
* - A client cannot check its own key: inside `run-script`,
|
|
36
|
+
* `/cmr/client get local-id` reads `*****` (no `sensitive` policy), and
|
|
37
|
+
* `numbers=` takes `.id` or identity but not `ids`. So binding is a
|
|
38
|
+
* host-side `/cmr/device` read just before each call, not an atomic check.
|
|
39
|
+
* - `freeze-frame-interval=20ms` brings a trivial call from ~1 s to ~25 ms;
|
|
40
|
+
* `strategy`/`fail-policy` are pinned and `order` is never sent (it adds
|
|
41
|
+
* `pending` and policy-`skipped` statuses).
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { assertNotCmrDashboardIl, CMR_RUN_SCRIPT_PATH } from "./core/cmr.ts";
|
|
45
|
+
import type {
|
|
46
|
+
CentrsErrorEnvelope,
|
|
47
|
+
CentrsSuccessEnvelope,
|
|
48
|
+
CommonSettingsMeta,
|
|
49
|
+
EnvelopeTargetMeta,
|
|
50
|
+
EnvelopeValidationMeta,
|
|
51
|
+
FanoutData,
|
|
52
|
+
FanoutSummary,
|
|
53
|
+
Warning,
|
|
54
|
+
} from "./core/envelope.ts";
|
|
55
|
+
import { notReachedReason } from "./core/envelope.ts";
|
|
56
|
+
import { isWriteShaped } from "./core/execute-command.ts";
|
|
57
|
+
import {
|
|
58
|
+
NOT_SENT_TRANSPORT_CODES,
|
|
59
|
+
resolveFanoutConcurrency,
|
|
60
|
+
runBoundedPool,
|
|
61
|
+
summarizeFanout,
|
|
62
|
+
} from "./core/fanout.ts";
|
|
63
|
+
import {
|
|
64
|
+
assertCapability,
|
|
65
|
+
type CapabilityRequirement,
|
|
66
|
+
probeDeviceVersion,
|
|
67
|
+
} from "./core/preflight.ts";
|
|
68
|
+
import { mapRouterOsError } from "./core/routeros-errors.ts";
|
|
69
|
+
import { routerOsStringLiteral } from "./core/routeros-string.ts";
|
|
70
|
+
import { toYaml } from "./core/yaml.ts";
|
|
71
|
+
import {
|
|
72
|
+
CentrsError,
|
|
73
|
+
formatCentrsErrorText,
|
|
74
|
+
serializeCentrsError,
|
|
75
|
+
terminalSafeLine,
|
|
76
|
+
} from "./errors.ts";
|
|
77
|
+
import {
|
|
78
|
+
disabledValidationMeta,
|
|
79
|
+
type ExecuteEnvelope,
|
|
80
|
+
type ExecuteOperationMeta,
|
|
81
|
+
type ExecuteRequest,
|
|
82
|
+
type ExecuteRequestSummary,
|
|
83
|
+
offlineStagePassed,
|
|
84
|
+
passedStages,
|
|
85
|
+
promptForWriteConfirmation,
|
|
86
|
+
type ResolvedExecuteRequest,
|
|
87
|
+
renderExecuteEnvelope,
|
|
88
|
+
resolveExecuteRequest,
|
|
89
|
+
} from "./execute.ts";
|
|
90
|
+
import { assertOfflineSyntax, type OfflineGateResult } from "./offline-gate.ts";
|
|
91
|
+
import {
|
|
92
|
+
createProtocolAdapter,
|
|
93
|
+
type ProtocolAdapter,
|
|
94
|
+
type RouterOsProtocol,
|
|
95
|
+
} from "./protocols/index.ts";
|
|
96
|
+
import { classifyParseResult } from "./protocols/mac-telnet-console.ts";
|
|
97
|
+
import { toCoreSource } from "./resolver/index.ts";
|
|
98
|
+
|
|
99
|
+
/** The device-stage validator identity for a CMR client. */
|
|
100
|
+
export const CMR_VALIDATION_SOURCE = ":put [:parse ...] via cmr run-script";
|
|
101
|
+
|
|
102
|
+
/** CMR's hard per-call ceiling is ~60 s; stay under it so `timeout=` decides. */
|
|
103
|
+
export const CMR_MAX_CALL_TIMEOUT_MS = 55_000;
|
|
104
|
+
|
|
105
|
+
/** Local grace past the CMR `timeout=` before centrs stops waiting. */
|
|
106
|
+
export const CMR_CALL_GRACE_MS = 3_000;
|
|
107
|
+
|
|
108
|
+
/** Fixed per-call arguments (see the module header for why). */
|
|
109
|
+
const RUN_SCRIPT_PINNED = {
|
|
110
|
+
"freeze-frame-interval": "20ms",
|
|
111
|
+
strategy: "parallel",
|
|
112
|
+
"fail-policy": "continue",
|
|
113
|
+
} as const;
|
|
114
|
+
|
|
115
|
+
const FINAL_STATUSES = new Set(["success", "failed", "skipped"]);
|
|
116
|
+
const TRUNCATION_MARKER = /\.\.\.\(\+(\d+) B\)\s*$/;
|
|
117
|
+
const TIMEOUT_OUTPUT =
|
|
118
|
+
/^(?:timed out \(\d+\)|std failure: timeout \(\d+\))\s*$/;
|
|
119
|
+
const NO_DEVICES_SELECTED = /no devices selected/i;
|
|
120
|
+
/** run-script lacks `policy`, `sniff`, `sensitive` (…); `:parse` cannot see that. */
|
|
121
|
+
const POLICY_DENIED = /^not enough permissions \(9\).*$/m;
|
|
122
|
+
/** An invocation, optionally after a relative menu change (`/ip/address/;(evl …`). */
|
|
123
|
+
const ACCEPTED_IL = /^(?:\/[^\s;()]*;)?\(evl\s/;
|
|
124
|
+
/** A missing `/cmr/device`: native `no such command prefix`, REST `no such command or directory (cmr)`. */
|
|
125
|
+
const MISSING_MENU = /no such command (?:prefix|or directory)/i;
|
|
126
|
+
|
|
127
|
+
export interface ExecuteCmrRequest extends ExecuteRequest {
|
|
128
|
+
/** RouterOS label expression (comma list; plain = OR, `+` AND, `-` AND NOT, `all`). */
|
|
129
|
+
labels: string;
|
|
130
|
+
/** Max controller calls in flight (default: the transport fan-out default). */
|
|
131
|
+
concurrency?: number;
|
|
132
|
+
/** Cancels the operation; members already sent become unknown outcomes. */
|
|
133
|
+
signal?: AbortSignal;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One `/cmr/device` row, reduced to what binding needs. */
|
|
137
|
+
export interface CmrDevice {
|
|
138
|
+
id: string;
|
|
139
|
+
/** `<device key>/<controller key>` (hex); stable across rename and re-pair on 7.26beta1. */
|
|
140
|
+
ids: string;
|
|
141
|
+
identity: string;
|
|
142
|
+
address?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface CmrScriptRow {
|
|
146
|
+
device: string;
|
|
147
|
+
status: string;
|
|
148
|
+
output: string;
|
|
149
|
+
section: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface CmrScriptCall {
|
|
153
|
+
numbers: string;
|
|
154
|
+
labels: string;
|
|
155
|
+
script: string;
|
|
156
|
+
/** CMR `timeout=` in ms; the local wait adds {@link CMR_CALL_GRACE_MS}. */
|
|
157
|
+
timeoutMs: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* What one single-device `run-script` call produced. `selected: false` is the
|
|
162
|
+
* controller's up-front refusal (nothing sent). Otherwise `row` is the newest
|
|
163
|
+
* row seen, and `error` says why the call ended without one being final.
|
|
164
|
+
*/
|
|
165
|
+
export interface CmrScriptOutcome {
|
|
166
|
+
selected: boolean;
|
|
167
|
+
/** The request reached the controller (native: the sentence was written). */
|
|
168
|
+
sent: boolean;
|
|
169
|
+
row?: CmrScriptRow;
|
|
170
|
+
error?: CentrsError;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Controller seam: production wraps a protocol adapter; tests inject a fake. */
|
|
174
|
+
/** What a router without `/cmr/device` says about itself, read only on that failure. */
|
|
175
|
+
export interface CmrAbsence {
|
|
176
|
+
/** `/system/resource` version. */
|
|
177
|
+
version?: string;
|
|
178
|
+
/** `/cmr/client` `controller-identity` / `controller-address`, when it is a client. */
|
|
179
|
+
controllerIdentity?: string;
|
|
180
|
+
controllerAddress?: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface CmrController {
|
|
184
|
+
devices(): Promise<CmrDevice[]>;
|
|
185
|
+
/** Explain a missing `/cmr/device`; each read is best effort. */
|
|
186
|
+
absence?(): Promise<CmrAbsence>;
|
|
187
|
+
runScript(
|
|
188
|
+
call: CmrScriptCall,
|
|
189
|
+
signal: AbortSignal,
|
|
190
|
+
): Promise<CmrScriptOutcome>;
|
|
191
|
+
close(): Promise<void>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface ExecuteCmrInternals {
|
|
195
|
+
controller?: (
|
|
196
|
+
resolved: ResolvedExecuteRequest,
|
|
197
|
+
signal: AbortSignal,
|
|
198
|
+
) => CmrController;
|
|
199
|
+
nonce?: () => string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface ExecuteCmrOperationMeta {
|
|
203
|
+
kind: "cmr";
|
|
204
|
+
concurrency: number;
|
|
205
|
+
summary: FanoutSummary;
|
|
206
|
+
request: ExecuteRequestSummary;
|
|
207
|
+
cmr: {
|
|
208
|
+
labels: string;
|
|
209
|
+
/** `/cmr/device` rows considered. */
|
|
210
|
+
inventory: number;
|
|
211
|
+
/** Rows the label expression selected. */
|
|
212
|
+
selected: number;
|
|
213
|
+
/** Members the command was sent to. */
|
|
214
|
+
dispatched: number;
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export type ExecuteCmrData = FanoutData<unknown, ExecuteOperationMeta>;
|
|
219
|
+
export type ExecuteCmrEnvelope = CentrsSuccessEnvelope<
|
|
220
|
+
ExecuteCmrData,
|
|
221
|
+
ExecuteCmrOperationMeta
|
|
222
|
+
>;
|
|
223
|
+
export type ExecuteCmrErrorEnvelope =
|
|
224
|
+
CentrsErrorEnvelope<ExecuteCmrOperationMeta>;
|
|
225
|
+
|
|
226
|
+
interface Member {
|
|
227
|
+
device: CmrDevice;
|
|
228
|
+
/** Final envelope once decided; undefined while still eligible to run. */
|
|
229
|
+
result?: ExecuteEnvelope;
|
|
230
|
+
validation: EnvelopeValidationMeta;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Run `request.command` on every CMR client `request.labels` selects on the
|
|
235
|
+
* controller `request` resolves to. Throws (outer error) only before anything
|
|
236
|
+
* is dispatched: bad usage, an incomplete selection, a refused write gate.
|
|
237
|
+
*/
|
|
238
|
+
export async function executeCmr(
|
|
239
|
+
request: ExecuteCmrRequest,
|
|
240
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
241
|
+
internals: ExecuteCmrInternals = {},
|
|
242
|
+
): Promise<ExecuteCmrEnvelope> {
|
|
243
|
+
const labels = request.labels.trim();
|
|
244
|
+
if (labels.length === 0) {
|
|
245
|
+
throw new CentrsError({
|
|
246
|
+
code: "usage/missing-labels",
|
|
247
|
+
summary:
|
|
248
|
+
"`--cmr` needs a `--labels` expression naming the clients to run on.",
|
|
249
|
+
remediation:
|
|
250
|
+
"Pass RouterOS label syntax, e.g. `--labels all`, `--labels site-a,+edge` or `--labels lab,-controller`.",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
const resolved = await resolveExecuteRequest(request, env);
|
|
254
|
+
const via = resolved.via.value;
|
|
255
|
+
if (via !== "native-api" && via !== "rest-api") {
|
|
256
|
+
throw new CentrsError({
|
|
257
|
+
code: "usage/conflicting-flags",
|
|
258
|
+
summary: `\`--cmr\` talks to the controller over native-api or rest-api, not ${via}.`,
|
|
259
|
+
remediation:
|
|
260
|
+
"Drop `--via` (native-api is the default) or pin `--via native-api` / `--via rest-api`.",
|
|
261
|
+
context: { via },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const offline = resolved.validate.value
|
|
265
|
+
? assertOfflineSyntax(resolved.command, { surface: "execute", via })
|
|
266
|
+
: undefined;
|
|
267
|
+
const signal = request.signal ?? new AbortController().signal;
|
|
268
|
+
const concurrency = resolveFanoutConcurrency(request.concurrency, via);
|
|
269
|
+
const callTimeoutMs = Math.min(
|
|
270
|
+
resolved.timeoutMs.value,
|
|
271
|
+
CMR_MAX_CALL_TIMEOUT_MS,
|
|
272
|
+
);
|
|
273
|
+
const write = isWriteShaped(resolved.canonical);
|
|
274
|
+
const controllerName =
|
|
275
|
+
resolved.target.identity ?? resolved.target.input ?? resolved.target.host;
|
|
276
|
+
const controller = (internals.controller ?? adapterController)(
|
|
277
|
+
resolved,
|
|
278
|
+
signal,
|
|
279
|
+
);
|
|
280
|
+
const nonce = (internals.nonce ?? defaultNonce)();
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
// 1. Inventory and per-row preflight (membership + client validation).
|
|
284
|
+
const before = await readInventoryBeforeDispatch(controller, signal);
|
|
285
|
+
const preflightScript = resolved.validate.value
|
|
286
|
+
? `:put [:parse ${routerOsStringLiteral(resolved.command)}]; :put "${nonce}"`
|
|
287
|
+
: `:put "${nonce}"`;
|
|
288
|
+
const preflight = await runBoundedPool(before, concurrency, (device) =>
|
|
289
|
+
signal.aborted
|
|
290
|
+
? Promise.resolve<CmrScriptOutcome>({
|
|
291
|
+
selected: true,
|
|
292
|
+
sent: false,
|
|
293
|
+
error: cancelledError(),
|
|
294
|
+
})
|
|
295
|
+
: controller.runScript(
|
|
296
|
+
{
|
|
297
|
+
numbers: device.id,
|
|
298
|
+
labels,
|
|
299
|
+
script: preflightScript,
|
|
300
|
+
timeoutMs: callTimeoutMs,
|
|
301
|
+
},
|
|
302
|
+
signal,
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
if (signal.aborted) throw cancelledError();
|
|
306
|
+
const unknownMembership = before.filter(
|
|
307
|
+
(_, index) =>
|
|
308
|
+
preflight[index]?.selected === true &&
|
|
309
|
+
preflight[index]?.row === undefined,
|
|
310
|
+
);
|
|
311
|
+
if (unknownMembership.length > 0) {
|
|
312
|
+
throw new CentrsError({
|
|
313
|
+
code: "cmr/selection-incomplete",
|
|
314
|
+
summary: `The controller did not say whether ${unknownMembership.length} CMR device(s) match \`${labels}\`, so nothing was run.`,
|
|
315
|
+
remediation:
|
|
316
|
+
"Check the controller is reachable and responsive, raise `--timeout`, then retry. A partial selection is never run.",
|
|
317
|
+
context: {
|
|
318
|
+
labels,
|
|
319
|
+
devices: unknownMembership.map((d) => ({
|
|
320
|
+
id: d.id,
|
|
321
|
+
identity: d.identity,
|
|
322
|
+
})),
|
|
323
|
+
causes: unknownMembership.map((d) => {
|
|
324
|
+
const error = preflight[before.indexOf(d)]?.error;
|
|
325
|
+
return error ? error.code : "no reply";
|
|
326
|
+
}),
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const members: Member[] = [];
|
|
332
|
+
before.forEach((device, index) => {
|
|
333
|
+
const outcome = preflight[index];
|
|
334
|
+
if (!outcome?.selected || !outcome.row) return;
|
|
335
|
+
members.push(
|
|
336
|
+
judgePreflight(
|
|
337
|
+
device,
|
|
338
|
+
outcome,
|
|
339
|
+
resolved,
|
|
340
|
+
controllerName,
|
|
341
|
+
offline,
|
|
342
|
+
nonce,
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
const warnings: Warning[] = [...resolved.warnings];
|
|
347
|
+
if (members.length === 0) {
|
|
348
|
+
warnings.push({
|
|
349
|
+
code: "cmr/empty-selection",
|
|
350
|
+
message: `No CMR device on the controller matches \`${labels}\`. Labels are case-sensitive and RouterOS accepts unknown ones silently.`,
|
|
351
|
+
context: { labels, inventory: before.length },
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 2. Bind: a member whose `ids` changed since S1 was validated as another key.
|
|
356
|
+
const middle = await readInventoryBeforeDispatch(controller, signal);
|
|
357
|
+
for (const member of members) {
|
|
358
|
+
if (member.result) continue;
|
|
359
|
+
const now = middle.find((d) => d.id === member.device.id);
|
|
360
|
+
if (!now || now.ids !== member.device.ids) {
|
|
361
|
+
member.result = memberError(
|
|
362
|
+
member,
|
|
363
|
+
resolved,
|
|
364
|
+
controllerName,
|
|
365
|
+
notDispatched(
|
|
366
|
+
"cmr/membership-changed",
|
|
367
|
+
`CMR device ${member.device.id} (${member.device.identity}) changed or left the inventory between validation and dispatch.`,
|
|
368
|
+
{ before: member.device, after: now ?? null },
|
|
369
|
+
),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 3. One write gate, naming the blast radius.
|
|
375
|
+
const runnable = members.filter((m) => !m.result);
|
|
376
|
+
if (write && runnable.length > 0) {
|
|
377
|
+
await confirmCmrWrite(
|
|
378
|
+
request,
|
|
379
|
+
signal,
|
|
380
|
+
resolved,
|
|
381
|
+
controllerName,
|
|
382
|
+
runnable.length,
|
|
383
|
+
members.length - runnable.length,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// 4. Dispatch: one call per member, no retry. The prompt and the pool
|
|
388
|
+
// queue can both wait arbitrarily long after S2, so each member is
|
|
389
|
+
// re-bound to the key it was validated on just before its own call.
|
|
390
|
+
const dispatched = await runBoundedPool(
|
|
391
|
+
runnable,
|
|
392
|
+
concurrency,
|
|
393
|
+
async (member) => {
|
|
394
|
+
const unsent = (error: CentrsError) => ({
|
|
395
|
+
member,
|
|
396
|
+
outcome: { selected: true, sent: false, error },
|
|
397
|
+
});
|
|
398
|
+
if (signal.aborted) return unsent(cancelledError());
|
|
399
|
+
const rebound = await rebindBeforeSend(controller, member, signal);
|
|
400
|
+
if (rebound) return unsent(rebound);
|
|
401
|
+
const outcome = await controller.runScript(
|
|
402
|
+
{
|
|
403
|
+
numbers: member.device.id,
|
|
404
|
+
labels,
|
|
405
|
+
script: resolved.command,
|
|
406
|
+
timeoutMs: callTimeoutMs,
|
|
407
|
+
},
|
|
408
|
+
signal,
|
|
409
|
+
);
|
|
410
|
+
return { member, outcome };
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
// S3 re-binds every sent result to the key it was validated on. If it
|
|
414
|
+
// cannot be read (or the run was cancelled), sent results are unknown.
|
|
415
|
+
const after =
|
|
416
|
+
dispatched.length > 0 && !signal.aborted
|
|
417
|
+
? await readInventory(controller).catch(() => undefined)
|
|
418
|
+
: undefined;
|
|
419
|
+
let sentCount = 0;
|
|
420
|
+
for (const { member, outcome } of dispatched) {
|
|
421
|
+
if (outcome.sent) sentCount += 1;
|
|
422
|
+
const now = after?.find((d) => d.id === member.device.id);
|
|
423
|
+
const binding =
|
|
424
|
+
after === undefined
|
|
425
|
+
? "unverified"
|
|
426
|
+
: now?.ids === member.device.ids
|
|
427
|
+
? "bound"
|
|
428
|
+
: "drifted";
|
|
429
|
+
member.result = applyMemberBudget(
|
|
430
|
+
judgeDispatch(member, outcome, resolved, controllerName, binding),
|
|
431
|
+
member,
|
|
432
|
+
resolved,
|
|
433
|
+
controllerName,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const targets = members.map(
|
|
438
|
+
(member) =>
|
|
439
|
+
member.result ??
|
|
440
|
+
memberError(member, resolved, controllerName, internalUndecided()),
|
|
441
|
+
);
|
|
442
|
+
const summary = summarizeFanout(targets);
|
|
443
|
+
return {
|
|
444
|
+
ok: true,
|
|
445
|
+
data: { summary, targets },
|
|
446
|
+
warnings,
|
|
447
|
+
tips: [],
|
|
448
|
+
meta: {
|
|
449
|
+
target: controllerTargetMeta(resolved),
|
|
450
|
+
via,
|
|
451
|
+
settings: settingsMeta(resolved),
|
|
452
|
+
operation: {
|
|
453
|
+
kind: "cmr",
|
|
454
|
+
concurrency,
|
|
455
|
+
summary,
|
|
456
|
+
request: requestSummary(resolved),
|
|
457
|
+
cmr: {
|
|
458
|
+
labels,
|
|
459
|
+
inventory: before.length,
|
|
460
|
+
selected: members.length,
|
|
461
|
+
dispatched: sentCount,
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
} finally {
|
|
467
|
+
await controller.close();
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
// Preflight and dispatch judgement
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
function judgePreflight(
|
|
476
|
+
device: CmrDevice,
|
|
477
|
+
outcome: CmrScriptOutcome,
|
|
478
|
+
resolved: ResolvedExecuteRequest,
|
|
479
|
+
controllerName: string,
|
|
480
|
+
offline: OfflineGateResult | undefined,
|
|
481
|
+
nonce: string,
|
|
482
|
+
): Member {
|
|
483
|
+
const row = outcome.row as CmrScriptRow;
|
|
484
|
+
const member: Member = {
|
|
485
|
+
device,
|
|
486
|
+
validation: resolved.validate.value
|
|
487
|
+
? devicePassed(resolved, offline)
|
|
488
|
+
: disabledValidationMeta(),
|
|
489
|
+
};
|
|
490
|
+
if (row.status === "skipped") {
|
|
491
|
+
member.validation = deviceNotReached(
|
|
492
|
+
resolved,
|
|
493
|
+
offline,
|
|
494
|
+
"cmr/client-disconnected",
|
|
495
|
+
);
|
|
496
|
+
member.result = memberError(
|
|
497
|
+
member,
|
|
498
|
+
resolved,
|
|
499
|
+
controllerName,
|
|
500
|
+
disconnectedError(device, row),
|
|
501
|
+
);
|
|
502
|
+
return member;
|
|
503
|
+
}
|
|
504
|
+
const incomplete = (why: string) => {
|
|
505
|
+
member.validation = deviceNotReached(
|
|
506
|
+
resolved,
|
|
507
|
+
offline,
|
|
508
|
+
"cmr/validation-incomplete",
|
|
509
|
+
);
|
|
510
|
+
member.result = memberError(
|
|
511
|
+
member,
|
|
512
|
+
resolved,
|
|
513
|
+
controllerName,
|
|
514
|
+
notDispatched(
|
|
515
|
+
"cmr/validation-incomplete",
|
|
516
|
+
`The validation result from CMR device ${device.identity} was incomplete (${why}), so the command was not sent to it.`,
|
|
517
|
+
{ id: device.id, status: row.status, output: row.output },
|
|
518
|
+
),
|
|
519
|
+
);
|
|
520
|
+
return member;
|
|
521
|
+
};
|
|
522
|
+
if (!FINAL_STATUSES.has(row.status))
|
|
523
|
+
return incomplete(
|
|
524
|
+
outcome.error
|
|
525
|
+
? `${outcome.error.code}, last status ${row.status}`
|
|
526
|
+
: `last status ${row.status}`,
|
|
527
|
+
);
|
|
528
|
+
if (row.status !== "success")
|
|
529
|
+
return incomplete(`status ${row.status}: ${row.output.trim()}`);
|
|
530
|
+
const text = row.output.replaceAll("\r\n", "\n");
|
|
531
|
+
if (TRUNCATION_MARKER.test(text)) return incomplete("output clipped by CMR");
|
|
532
|
+
const lines = text.replace(/\n+$/, "").split("\n");
|
|
533
|
+
if (lines.at(-1) !== nonce) return incomplete("completion marker missing");
|
|
534
|
+
if (!resolved.validate.value) return member;
|
|
535
|
+
const parse = lines.slice(0, -1).join("\n").trim();
|
|
536
|
+
if (parse.length === 0) return incomplete("empty `:parse` return");
|
|
537
|
+
try {
|
|
538
|
+
classifyParseResult(parse, resolved.command, "cmr run-script");
|
|
539
|
+
assertNotCmrDashboardIl(parse, resolved.command);
|
|
540
|
+
} catch (error) {
|
|
541
|
+
const rejected = error as CentrsError;
|
|
542
|
+
member.validation = deviceRejected(resolved, offline, rejected);
|
|
543
|
+
member.result = memberError(member, resolved, controllerName, rejected);
|
|
544
|
+
return member;
|
|
545
|
+
}
|
|
546
|
+
// Positive evidence: every accepted `:parse` return seen (structured,
|
|
547
|
+
// console spacing, multi-statement, blocks, `:if`/`:foreach`; 7.24.5 and
|
|
548
|
+
// 7.26beta1) starts `(evl `, after a relative menu change if the command
|
|
549
|
+
// opens with one (7.26beta1). A comment-only script returns `/`.
|
|
550
|
+
if (!ACCEPTED_IL.test(parse)) return incomplete("`:parse` return is not IL");
|
|
551
|
+
return member;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function judgeDispatch(
|
|
555
|
+
member: Member,
|
|
556
|
+
outcome: CmrScriptOutcome,
|
|
557
|
+
resolved: ResolvedExecuteRequest,
|
|
558
|
+
controllerName: string,
|
|
559
|
+
binding: "bound" | "drifted" | "unverified",
|
|
560
|
+
): ExecuteEnvelope {
|
|
561
|
+
const device = member.device;
|
|
562
|
+
if (!outcome.selected) {
|
|
563
|
+
return memberError(
|
|
564
|
+
member,
|
|
565
|
+
resolved,
|
|
566
|
+
controllerName,
|
|
567
|
+
notDispatched(
|
|
568
|
+
"cmr/membership-changed",
|
|
569
|
+
`CMR device ${device.identity} no longer matched the labels at dispatch, so the command was not sent to it.`,
|
|
570
|
+
{ id: device.id },
|
|
571
|
+
),
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
const row = outcome.row;
|
|
575
|
+
if (!outcome.sent && !(row && FINAL_STATUSES.has(row.status))) {
|
|
576
|
+
return memberError(
|
|
577
|
+
member,
|
|
578
|
+
resolved,
|
|
579
|
+
controllerName,
|
|
580
|
+
outcome.error ?? cancelledError(),
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
// CMR itself declined to send: nothing ran, whichever client this was.
|
|
584
|
+
if (row?.status === "skipped") {
|
|
585
|
+
return memberError(
|
|
586
|
+
member,
|
|
587
|
+
resolved,
|
|
588
|
+
controllerName,
|
|
589
|
+
disconnectedError(device, row),
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
// A sent call whose member cannot be re-bound after dispatch (its `ids`
|
|
593
|
+
// changed, or S3 was not read) has no attributable result.
|
|
594
|
+
if (binding !== "bound") {
|
|
595
|
+
return memberError(
|
|
596
|
+
member,
|
|
597
|
+
resolved,
|
|
598
|
+
controllerName,
|
|
599
|
+
unknownOutcome(
|
|
600
|
+
device,
|
|
601
|
+
undefined,
|
|
602
|
+
row,
|
|
603
|
+
binding === "drifted"
|
|
604
|
+
? "its `ids` changed during the call"
|
|
605
|
+
: "the controller inventory was not re-read (cancelled or unreadable) to confirm which client answered",
|
|
606
|
+
),
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
if (!row || !FINAL_STATUSES.has(row.status)) {
|
|
610
|
+
return memberError(
|
|
611
|
+
member,
|
|
612
|
+
resolved,
|
|
613
|
+
controllerName,
|
|
614
|
+
unknownOutcome(device, outcome.error, row),
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
if (row.status === "failed") {
|
|
618
|
+
if (TIMEOUT_OUTPUT.test(row.output.trim())) {
|
|
619
|
+
return memberError(
|
|
620
|
+
member,
|
|
621
|
+
resolved,
|
|
622
|
+
controllerName,
|
|
623
|
+
unknownOutcome(device, undefined, row),
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
// The diagnostic follows whatever the script printed before it.
|
|
627
|
+
const denied = POLICY_DENIED.exec(row.output.replaceAll("\r\n", "\n"));
|
|
628
|
+
if (denied) {
|
|
629
|
+
return memberError(
|
|
630
|
+
member,
|
|
631
|
+
resolved,
|
|
632
|
+
controllerName,
|
|
633
|
+
new CentrsError({
|
|
634
|
+
code: "cmr/policy-denied",
|
|
635
|
+
summary: `${device.identity} refused a statement: CMR runs scripts with only the read, write, test and reboot policies (\`${denied[0].trim()}\`). Any statements before it already ran.`,
|
|
636
|
+
remediation:
|
|
637
|
+
"A statement that needs another policy (`/user`, `/tool/sniffer`) cannot run through CMR. Check what the earlier statements changed (the output is in `context.output`), then run only the refused statement and the ones after it directly on the device, e.g. `centrs execute <device> -- '<command>'`.",
|
|
638
|
+
context: {
|
|
639
|
+
id: device.id,
|
|
640
|
+
identity: device.identity,
|
|
641
|
+
output: row.output,
|
|
642
|
+
},
|
|
643
|
+
}),
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
return memberError(
|
|
647
|
+
member,
|
|
648
|
+
resolved,
|
|
649
|
+
controllerName,
|
|
650
|
+
mapRouterOsError(row.output.trim(), {
|
|
651
|
+
transport:
|
|
652
|
+
resolved.via.value === "rest-api" ? "rest-api" : "native-api",
|
|
653
|
+
context: { cmr: { id: device.id, identity: device.identity } },
|
|
654
|
+
}),
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
const warnings: Warning[] = [];
|
|
658
|
+
const clipped = TRUNCATION_MARKER.exec(row.output);
|
|
659
|
+
if (clipped) {
|
|
660
|
+
warnings.push({
|
|
661
|
+
code: "cmr/output-truncated",
|
|
662
|
+
message: `CMR clipped the output from ${device.identity}: ${clipped[1]} more bytes were not returned.`,
|
|
663
|
+
context: { id: device.id, missingBytes: Number(clipped[1]) },
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
ok: true,
|
|
668
|
+
data: { ret: row.output },
|
|
669
|
+
warnings,
|
|
670
|
+
tips: [],
|
|
671
|
+
meta: {
|
|
672
|
+
...memberMeta(member, resolved, controllerName),
|
|
673
|
+
...(clipped
|
|
674
|
+
? {
|
|
675
|
+
truncated: {
|
|
676
|
+
truncated: true,
|
|
677
|
+
reason: "CMR run-script output cap (~60 KiB)",
|
|
678
|
+
serializedBytes: row.output.length + Number(clipped[1]),
|
|
679
|
+
},
|
|
680
|
+
}
|
|
681
|
+
: {}),
|
|
682
|
+
},
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* `--max-results` per member, as plain execute fan-out applies it per target:
|
|
688
|
+
* the member's rendered envelope in the requested format, in UTF-8 bytes. The
|
|
689
|
+
* command already ran, so the error says so rather than implying it did not.
|
|
690
|
+
*/
|
|
691
|
+
function applyMemberBudget(
|
|
692
|
+
envelope: ExecuteEnvelope,
|
|
693
|
+
member: Member,
|
|
694
|
+
resolved: ResolvedExecuteRequest,
|
|
695
|
+
controllerName: string,
|
|
696
|
+
): ExecuteEnvelope {
|
|
697
|
+
const budget = resolved.maxResultsBytes?.value;
|
|
698
|
+
if (!budget || !envelope.ok) return envelope;
|
|
699
|
+
const format = resolved.format.value;
|
|
700
|
+
const requiredBytes = new TextEncoder().encode(
|
|
701
|
+
renderExecuteEnvelope(envelope, format, {
|
|
702
|
+
verbose: format === "text" && resolved.verbose,
|
|
703
|
+
}),
|
|
704
|
+
).length;
|
|
705
|
+
if (requiredBytes <= budget) return envelope;
|
|
706
|
+
const device = member.device;
|
|
707
|
+
return memberError(
|
|
708
|
+
member,
|
|
709
|
+
resolved,
|
|
710
|
+
controllerName,
|
|
711
|
+
new CentrsError({
|
|
712
|
+
code: "input/max-results-exceeded",
|
|
713
|
+
summary: `The command ran on ${device.identity}, but its result (${requiredBytes} bytes) exceeded the requested ${budget}-byte budget, so it is not returned.`,
|
|
714
|
+
remediation:
|
|
715
|
+
"Increase `--max-results` or narrow the command's output. The command already ran on this client; check its state before re-running anything that is not idempotent.",
|
|
716
|
+
context: {
|
|
717
|
+
id: device.id,
|
|
718
|
+
identity: device.identity,
|
|
719
|
+
requiredBytes,
|
|
720
|
+
maxResultsBytes: budget,
|
|
721
|
+
command: resolved.command,
|
|
722
|
+
dispatched: true,
|
|
723
|
+
},
|
|
724
|
+
}),
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ---------------------------------------------------------------------------
|
|
729
|
+
// Errors
|
|
730
|
+
// ---------------------------------------------------------------------------
|
|
731
|
+
|
|
732
|
+
function notDispatched(
|
|
733
|
+
code: "cmr/membership-changed" | "cmr/validation-incomplete",
|
|
734
|
+
summary: string,
|
|
735
|
+
context: Record<string, unknown>,
|
|
736
|
+
): CentrsError {
|
|
737
|
+
return new CentrsError({
|
|
738
|
+
code,
|
|
739
|
+
summary,
|
|
740
|
+
remediation:
|
|
741
|
+
code === "cmr/membership-changed"
|
|
742
|
+
? "Re-run to validate the current selection; a member is only run with the validation it passed."
|
|
743
|
+
: "The command was not sent to this client. Check the controller and client are responsive, keep the command under ~60 KiB of `:parse` output, and retry.",
|
|
744
|
+
context,
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function disconnectedError(device: CmrDevice, row: CmrScriptRow): CentrsError {
|
|
749
|
+
return new CentrsError({
|
|
750
|
+
code: "cmr/client-disconnected",
|
|
751
|
+
summary: `CMR skipped ${device.identity}: ${row.output.trim() || "skipped"}. The command was not sent to it.`,
|
|
752
|
+
remediation:
|
|
753
|
+
"Check the client's `/cmr/client` status (`paired,connected`) and its route to the controller, then retry.",
|
|
754
|
+
context: { id: device.id, identity: device.identity, output: row.output },
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function unknownOutcome(
|
|
759
|
+
device: CmrDevice,
|
|
760
|
+
cause: CentrsError | undefined,
|
|
761
|
+
row: CmrScriptRow | undefined,
|
|
762
|
+
why?: string,
|
|
763
|
+
): CentrsError {
|
|
764
|
+
const reason =
|
|
765
|
+
why ??
|
|
766
|
+
(row && row.status === "failed"
|
|
767
|
+
? `CMR reported \`${row.output.trim()}\``
|
|
768
|
+
: cause
|
|
769
|
+
? `the call ended with ${cause.code}`
|
|
770
|
+
: "the call ended without a final status");
|
|
771
|
+
return new CentrsError({
|
|
772
|
+
code: "cmr/outcome-unknown",
|
|
773
|
+
summary: `The command was sent to ${device.identity}, but its outcome is unknown: ${reason}. It may still be running.`,
|
|
774
|
+
remediation:
|
|
775
|
+
"CMR timeouts and cancellation do not stop a client script. Check the client's state before re-running anything that is not idempotent; centrs never retries it.",
|
|
776
|
+
context: {
|
|
777
|
+
id: device.id,
|
|
778
|
+
identity: device.identity,
|
|
779
|
+
...(row ? { lastStatus: row.status, output: row.output } : {}),
|
|
780
|
+
...(cause ? { cause: cause.code } : {}),
|
|
781
|
+
},
|
|
782
|
+
...(cause ? { cause } : {}),
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function cancelledError(cause?: unknown): CentrsError {
|
|
787
|
+
return new CentrsError({
|
|
788
|
+
code: "cmr/cancelled",
|
|
789
|
+
summary:
|
|
790
|
+
"The operation was cancelled before the command was sent to this CMR client.",
|
|
791
|
+
remediation: "Nothing ran on this client. Re-run when ready.",
|
|
792
|
+
...(cause ? { cause } : {}),
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function internalUndecided(): CentrsError {
|
|
797
|
+
return new CentrsError({
|
|
798
|
+
code: "internal/unhandled",
|
|
799
|
+
summary: "A CMR member finished without a recorded result.",
|
|
800
|
+
remediation:
|
|
801
|
+
"This is a centrs bug; report it with the command and labels used.",
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function isNoDevicesSelected(error: unknown): boolean {
|
|
806
|
+
if (!(error instanceof CentrsError)) return false;
|
|
807
|
+
const detail = error.context?.["detail"];
|
|
808
|
+
return NO_DEVICES_SELECTED.test(
|
|
809
|
+
`${error.summary} ${typeof detail === "string" ? detail : ""}`,
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* The per-member check before its call: `undefined` when the row still carries
|
|
815
|
+
* the `.id` + `ids` the member was validated on, otherwise why it is not sent.
|
|
816
|
+
* The original member set is fixed; a replacement row is never adopted, even
|
|
817
|
+
* when it matches the labels. A host-side read is not atomic with the call:
|
|
818
|
+
* a re-key inside that gap is still caught, after the fact, by S3.
|
|
819
|
+
*/
|
|
820
|
+
async function rebindBeforeSend(
|
|
821
|
+
controller: CmrController,
|
|
822
|
+
member: Member,
|
|
823
|
+
signal: AbortSignal,
|
|
824
|
+
): Promise<CentrsError | undefined> {
|
|
825
|
+
const device = member.device;
|
|
826
|
+
let current: CmrDevice[];
|
|
827
|
+
try {
|
|
828
|
+
current = await readInventoryBeforeDispatch(controller, signal);
|
|
829
|
+
} catch (error) {
|
|
830
|
+
const cause = asCmrError(error);
|
|
831
|
+
return new CentrsError({
|
|
832
|
+
code: cause.code,
|
|
833
|
+
summary: `The CMR inventory could not be re-read before sending to ${device.identity}, so the command was not sent to it: ${cause.summary}`,
|
|
834
|
+
remediation:
|
|
835
|
+
cause.remediation ??
|
|
836
|
+
"Check the controller is reachable and responsive, then re-run.",
|
|
837
|
+
context: { id: device.id, identity: device.identity, dispatched: false },
|
|
838
|
+
cause,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
const now = current.find((d) => d.id === device.id);
|
|
842
|
+
if (now && now.ids === device.ids) return undefined;
|
|
843
|
+
return notDispatched(
|
|
844
|
+
"cmr/membership-changed",
|
|
845
|
+
`CMR device ${device.id} (${device.identity}) changed or left the inventory before its command was sent.`,
|
|
846
|
+
{ before: device, after: now ?? null },
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* S1/S2: nothing has been sent yet, so a caller abort is `cmr/cancelled`
|
|
852
|
+
* whether the read resolved or the adapter rejected because of it.
|
|
853
|
+
*/
|
|
854
|
+
async function readInventoryBeforeDispatch(
|
|
855
|
+
controller: CmrController,
|
|
856
|
+
signal: AbortSignal,
|
|
857
|
+
): Promise<CmrDevice[]> {
|
|
858
|
+
if (signal.aborted) throw cancelledError();
|
|
859
|
+
let devices: CmrDevice[];
|
|
860
|
+
try {
|
|
861
|
+
devices = await readInventory(controller);
|
|
862
|
+
} catch (error) {
|
|
863
|
+
if (signal.aborted) throw cancelledError(error);
|
|
864
|
+
throw error;
|
|
865
|
+
}
|
|
866
|
+
if (signal.aborted) throw cancelledError();
|
|
867
|
+
return devices;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function readInventory(controller: CmrController): Promise<CmrDevice[]> {
|
|
871
|
+
try {
|
|
872
|
+
return await controller.devices();
|
|
873
|
+
} catch (error) {
|
|
874
|
+
const detail =
|
|
875
|
+
error instanceof CentrsError ? error.context?.["detail"] : undefined;
|
|
876
|
+
if (typeof detail === "string" && MISSING_MENU.test(detail)) {
|
|
877
|
+
throw await noCmrError(controller, detail, error);
|
|
878
|
+
}
|
|
879
|
+
throw error;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** CMR first shipped in 7.26beta1 (manual; the quickchr CMR lab). */
|
|
884
|
+
const CMR_CONTROLLER: CapabilityRequirement = {
|
|
885
|
+
capability: "cmr-controller",
|
|
886
|
+
summary: "be a CMR controller (`/cmr` first shipped in 7.26beta1, a beta)",
|
|
887
|
+
supported: [{ min: "7.26beta1" }],
|
|
888
|
+
remediation:
|
|
889
|
+
"Upgrade the controller to a RouterOS build with CMR (7.26beta1 or later), install the optional `cmr` package, and run `/cmr set enabled=yes`.",
|
|
890
|
+
};
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* A missing `/cmr/device` has three causes, told apart only on this failure
|
|
894
|
+
* path: RouterOS older than CMR (`routeros/version-unsupported`), or a 7.26+
|
|
895
|
+
* router without the `cmr` package, possibly a CMR client.
|
|
896
|
+
*/
|
|
897
|
+
async function noCmrError(
|
|
898
|
+
controller: CmrController,
|
|
899
|
+
detail: string,
|
|
900
|
+
cause: unknown,
|
|
901
|
+
): Promise<CentrsError> {
|
|
902
|
+
const found = (await controller.absence?.().catch(() => undefined)) ?? {};
|
|
903
|
+
if (found.version !== undefined) {
|
|
904
|
+
try {
|
|
905
|
+
assertCapability(CMR_CONTROLLER, {
|
|
906
|
+
version: found.version,
|
|
907
|
+
source: "live",
|
|
908
|
+
});
|
|
909
|
+
} catch (error) {
|
|
910
|
+
return error as CentrsError;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
const client = found.controllerIdentity
|
|
914
|
+
? ` It is a CMR client of ${found.controllerIdentity}${found.controllerAddress ? ` (${found.controllerAddress})` : ""}; point \`--cmr\` at that controller.`
|
|
915
|
+
: "";
|
|
916
|
+
return new CentrsError({
|
|
917
|
+
code: "cmr/not-a-controller",
|
|
918
|
+
summary:
|
|
919
|
+
found.version !== undefined
|
|
920
|
+
? `RouterOS ${found.version} supports CMR, but the target has no \`/cmr/device\`: the optional \`cmr\` package is not installed.${client}`
|
|
921
|
+
: "The target did not answer `/cmr/device`; it is not a CMR controller.",
|
|
922
|
+
remediation:
|
|
923
|
+
"Point `--cmr` at the router running the optional `cmr` package (RouterOS 7.26beta1+) with `/cmr set enabled=yes`, or install that package on this router.",
|
|
924
|
+
context: {
|
|
925
|
+
detail,
|
|
926
|
+
...(found.version !== undefined ? { version: found.version } : {}),
|
|
927
|
+
...(found.controllerIdentity
|
|
928
|
+
? {
|
|
929
|
+
controllerIdentity: found.controllerIdentity,
|
|
930
|
+
controllerAddress: found.controllerAddress,
|
|
931
|
+
}
|
|
932
|
+
: {}),
|
|
933
|
+
},
|
|
934
|
+
cause,
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// ---------------------------------------------------------------------------
|
|
939
|
+
// Envelope helpers
|
|
940
|
+
// ---------------------------------------------------------------------------
|
|
941
|
+
|
|
942
|
+
function memberError(
|
|
943
|
+
member: Member,
|
|
944
|
+
resolved: ResolvedExecuteRequest,
|
|
945
|
+
controllerName: string,
|
|
946
|
+
error: CentrsError,
|
|
947
|
+
): ExecuteEnvelope {
|
|
948
|
+
return {
|
|
949
|
+
ok: false,
|
|
950
|
+
error: serializeCentrsError(error),
|
|
951
|
+
warnings: [],
|
|
952
|
+
tips: [],
|
|
953
|
+
meta: memberMeta(member, resolved, controllerName),
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function memberMeta(
|
|
958
|
+
member: Member,
|
|
959
|
+
resolved: ResolvedExecuteRequest,
|
|
960
|
+
controllerName: string,
|
|
961
|
+
): ExecuteEnvelope["meta"] {
|
|
962
|
+
const device = member.device;
|
|
963
|
+
const target: EnvelopeTargetMeta = {
|
|
964
|
+
input: device.identity,
|
|
965
|
+
identity: device.identity,
|
|
966
|
+
cmr: {
|
|
967
|
+
controller: controllerName,
|
|
968
|
+
id: device.id,
|
|
969
|
+
deviceKey: device.ids.split("/")[0] || undefined,
|
|
970
|
+
address: device.address,
|
|
971
|
+
},
|
|
972
|
+
};
|
|
973
|
+
return {
|
|
974
|
+
target,
|
|
975
|
+
via: resolved.via.value,
|
|
976
|
+
settings: settingsMeta(resolved),
|
|
977
|
+
validation: member.validation,
|
|
978
|
+
operation: {
|
|
979
|
+
kind: "execute",
|
|
980
|
+
objectCount: 1,
|
|
981
|
+
request: requestSummary(resolved),
|
|
982
|
+
auth: {
|
|
983
|
+
username: resolved.auth.username,
|
|
984
|
+
passwordProvided: resolved.auth.passwordProvided,
|
|
985
|
+
},
|
|
986
|
+
},
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function devicePassed(
|
|
991
|
+
resolved: ResolvedExecuteRequest,
|
|
992
|
+
offline: OfflineGateResult | undefined,
|
|
993
|
+
): EnvelopeValidationMeta {
|
|
994
|
+
return {
|
|
995
|
+
enabled: true,
|
|
996
|
+
source: CMR_VALIDATION_SOURCE,
|
|
997
|
+
result: "passed",
|
|
998
|
+
syntax: true,
|
|
999
|
+
semantic:
|
|
1000
|
+
resolved.canonical.mode === "structured" ? true : "not-applicable",
|
|
1001
|
+
stages: passedStages(
|
|
1002
|
+
offline ?? { verdict: "pass", warnings: [] },
|
|
1003
|
+
CMR_VALIDATION_SOURCE,
|
|
1004
|
+
),
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function deviceRejected(
|
|
1009
|
+
resolved: ResolvedExecuteRequest,
|
|
1010
|
+
offline: OfflineGateResult | undefined,
|
|
1011
|
+
error: CentrsError,
|
|
1012
|
+
): EnvelopeValidationMeta {
|
|
1013
|
+
const structured = resolved.canonical.mode === "structured";
|
|
1014
|
+
const nameRejected =
|
|
1015
|
+
structured && error.code === "validation/unknown-attribute";
|
|
1016
|
+
return {
|
|
1017
|
+
enabled: true,
|
|
1018
|
+
source: CMR_VALIDATION_SOURCE,
|
|
1019
|
+
result: "failed",
|
|
1020
|
+
...(nameRejected
|
|
1021
|
+
? { syntax: true, semantic: false }
|
|
1022
|
+
: {
|
|
1023
|
+
syntax: false,
|
|
1024
|
+
...(structured ? {} : { semantic: "not-applicable" as const }),
|
|
1025
|
+
}),
|
|
1026
|
+
stages: [
|
|
1027
|
+
offlineStagePassed(offline?.verdict),
|
|
1028
|
+
{ stage: "device", source: CMR_VALIDATION_SOURCE, result: "failed" },
|
|
1029
|
+
],
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function deviceNotReached(
|
|
1034
|
+
resolved: ResolvedExecuteRequest,
|
|
1035
|
+
offline: OfflineGateResult | undefined,
|
|
1036
|
+
code: string,
|
|
1037
|
+
): EnvelopeValidationMeta {
|
|
1038
|
+
if (!resolved.validate.value) return disabledValidationMeta();
|
|
1039
|
+
return {
|
|
1040
|
+
enabled: true,
|
|
1041
|
+
source: CMR_VALIDATION_SOURCE,
|
|
1042
|
+
stages: [
|
|
1043
|
+
offlineStagePassed(offline?.verdict),
|
|
1044
|
+
{
|
|
1045
|
+
stage: "device",
|
|
1046
|
+
source: CMR_VALIDATION_SOURCE,
|
|
1047
|
+
result: "skipped",
|
|
1048
|
+
reason: notReachedReason(code),
|
|
1049
|
+
},
|
|
1050
|
+
],
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function controllerTargetMeta(
|
|
1055
|
+
resolved: ResolvedExecuteRequest,
|
|
1056
|
+
): EnvelopeTargetMeta {
|
|
1057
|
+
const target = resolved.target;
|
|
1058
|
+
return {
|
|
1059
|
+
input: target.input,
|
|
1060
|
+
host: target.host,
|
|
1061
|
+
port: target.port,
|
|
1062
|
+
baseUrl: target.baseUrl,
|
|
1063
|
+
identity: target.identity,
|
|
1064
|
+
recordIndex: target.recordIndex,
|
|
1065
|
+
source: toCoreSource(target.source),
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function settingsMeta(resolved: ResolvedExecuteRequest): CommonSettingsMeta {
|
|
1070
|
+
return {
|
|
1071
|
+
via: toCoreSource(resolved.via.source),
|
|
1072
|
+
host: toCoreSource(resolved.target.hostSource),
|
|
1073
|
+
port: toCoreSource(resolved.target.portSource),
|
|
1074
|
+
timeoutMs: toCoreSource(resolved.timeoutMs.source),
|
|
1075
|
+
format: toCoreSource(resolved.format.source),
|
|
1076
|
+
validate: toCoreSource(resolved.validate.source),
|
|
1077
|
+
username: resolved.auth.usernameSource
|
|
1078
|
+
? toCoreSource(resolved.auth.usernameSource)
|
|
1079
|
+
: undefined,
|
|
1080
|
+
password: resolved.auth.passwordSource
|
|
1081
|
+
? toCoreSource(resolved.auth.passwordSource)
|
|
1082
|
+
: undefined,
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function requestSummary(
|
|
1087
|
+
resolved: ResolvedExecuteRequest,
|
|
1088
|
+
): ExecuteRequestSummary {
|
|
1089
|
+
return {
|
|
1090
|
+
command: resolved.command,
|
|
1091
|
+
canonical: resolved.canonical,
|
|
1092
|
+
write: isWriteShaped(resolved.canonical),
|
|
1093
|
+
yes: resolved.yes,
|
|
1094
|
+
validate: resolved.validate.value,
|
|
1095
|
+
verbose: resolved.verbose,
|
|
1096
|
+
timeoutMs: resolved.timeoutMs.value,
|
|
1097
|
+
format: resolved.format.value,
|
|
1098
|
+
maxResultsBytes: resolved.maxResultsBytes?.value,
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
async function confirmCmrWrite(
|
|
1103
|
+
request: ExecuteCmrRequest,
|
|
1104
|
+
signal: AbortSignal,
|
|
1105
|
+
resolved: ResolvedExecuteRequest,
|
|
1106
|
+
controllerName: string,
|
|
1107
|
+
runnable: number,
|
|
1108
|
+
excluded: number,
|
|
1109
|
+
): Promise<void> {
|
|
1110
|
+
if (resolved.yes) return;
|
|
1111
|
+
const scope = `${runnable} CMR client(s) via controller ${controllerName}${excluded > 0 ? ` (${excluded} excluded)` : ""}`;
|
|
1112
|
+
if (request.stdinIsTty ?? process.stdin.isTTY) {
|
|
1113
|
+
// The prompt reads stdin and cannot see `signal`, so race it: a cancel at
|
|
1114
|
+
// the prompt returns here and the dispatch loop reports every member
|
|
1115
|
+
// `cmr/cancelled` without sending anything.
|
|
1116
|
+
const cancelled = new Promise<false>((resolve) => {
|
|
1117
|
+
if (signal.aborted) resolve(false);
|
|
1118
|
+
signal.addEventListener("abort", () => resolve(false), { once: true });
|
|
1119
|
+
});
|
|
1120
|
+
const confirmed = await Promise.race([
|
|
1121
|
+
(request.confirm ?? promptForWriteConfirmation)(
|
|
1122
|
+
`Run write-shaped \`${resolved.command}\` on ${scope}? Type yes to continue: `,
|
|
1123
|
+
),
|
|
1124
|
+
cancelled,
|
|
1125
|
+
]);
|
|
1126
|
+
if (confirmed || signal.aborted) return;
|
|
1127
|
+
}
|
|
1128
|
+
throw new CentrsError({
|
|
1129
|
+
code: "usage/confirmation-required",
|
|
1130
|
+
summary: `A write-shaped \`${resolved.command}\` would run on ${scope} and needs explicit confirmation.`,
|
|
1131
|
+
remediation:
|
|
1132
|
+
"Pass `--yes` after reviewing the selection, or answer `yes` at the TTY prompt. Clients that failed validation are never sent the command.",
|
|
1133
|
+
context: {
|
|
1134
|
+
command: resolved.command,
|
|
1135
|
+
routers: runnable,
|
|
1136
|
+
excluded,
|
|
1137
|
+
controller: controllerName,
|
|
1138
|
+
},
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function defaultNonce(): string {
|
|
1143
|
+
return `centrs-cmr-${crypto.randomUUID().slice(0, 8)}`;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// ---------------------------------------------------------------------------
|
|
1147
|
+
// Production controller: one protocol adapter to the controller
|
|
1148
|
+
// ---------------------------------------------------------------------------
|
|
1149
|
+
|
|
1150
|
+
function adapterController(
|
|
1151
|
+
resolved: ResolvedExecuteRequest,
|
|
1152
|
+
signal: AbortSignal,
|
|
1153
|
+
): CmrController {
|
|
1154
|
+
const via = resolved.via.value;
|
|
1155
|
+
const config = (timeoutMs: number, signal?: AbortSignal) => ({
|
|
1156
|
+
protocol: via,
|
|
1157
|
+
host: resolved.target.host,
|
|
1158
|
+
port: resolved.target.port,
|
|
1159
|
+
tls: resolved.target.tls,
|
|
1160
|
+
baseUrl: resolved.target.baseUrl,
|
|
1161
|
+
username: resolved.auth.username,
|
|
1162
|
+
password: resolved.auth.password,
|
|
1163
|
+
timeoutMs,
|
|
1164
|
+
insecure: resolved.insecure.value,
|
|
1165
|
+
signal,
|
|
1166
|
+
});
|
|
1167
|
+
// Native: one session for every call (concurrent tagged commands).
|
|
1168
|
+
// REST: an adapter per call, so each carries its own deadline signal.
|
|
1169
|
+
const shared: ProtocolAdapter = createProtocolAdapter(
|
|
1170
|
+
config(resolved.timeoutMs.value, signal),
|
|
1171
|
+
);
|
|
1172
|
+
return {
|
|
1173
|
+
async devices() {
|
|
1174
|
+
const result = await shared.apiRequest({
|
|
1175
|
+
verb: "print",
|
|
1176
|
+
path: "/cmr/device",
|
|
1177
|
+
});
|
|
1178
|
+
const rows = Array.isArray(result.data)
|
|
1179
|
+
? (result.data as Record<string, string>[])
|
|
1180
|
+
: [];
|
|
1181
|
+
return rows.map((row) => ({
|
|
1182
|
+
id: row[".id"] ?? "",
|
|
1183
|
+
ids: row["ids"] ?? "",
|
|
1184
|
+
identity: row["identity"] ?? "",
|
|
1185
|
+
address: row["address"] || undefined,
|
|
1186
|
+
}));
|
|
1187
|
+
},
|
|
1188
|
+
runScript(call, signal) {
|
|
1189
|
+
return via === "native-api"
|
|
1190
|
+
? nativeRunScript(shared, call, signal)
|
|
1191
|
+
: restRunScript(
|
|
1192
|
+
(ms, s) => createProtocolAdapter(config(ms, s)),
|
|
1193
|
+
call,
|
|
1194
|
+
signal,
|
|
1195
|
+
);
|
|
1196
|
+
},
|
|
1197
|
+
async absence() {
|
|
1198
|
+
const version = await probeDeviceVersion(shared).catch(() => undefined);
|
|
1199
|
+
const client = (await shared
|
|
1200
|
+
.getSingleton("/cmr/client")
|
|
1201
|
+
.catch(() => undefined)) as Record<string, string> | undefined;
|
|
1202
|
+
return {
|
|
1203
|
+
version: version?.version,
|
|
1204
|
+
controllerIdentity: client?.["controller-identity"] || undefined,
|
|
1205
|
+
controllerAddress: client?.["controller-address"] || undefined,
|
|
1206
|
+
};
|
|
1207
|
+
},
|
|
1208
|
+
close: () => shared.close(),
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function runScriptAttributes(call: CmrScriptCall): Record<string, string> {
|
|
1213
|
+
return {
|
|
1214
|
+
numbers: call.numbers,
|
|
1215
|
+
labels: call.labels,
|
|
1216
|
+
script: call.script,
|
|
1217
|
+
timeout: `${Math.max(1, Math.ceil(call.timeoutMs / 1000))}s`,
|
|
1218
|
+
...RUN_SCRIPT_PINNED,
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function toRow(attributes: Record<string, string>): CmrScriptRow {
|
|
1223
|
+
return {
|
|
1224
|
+
device: attributes["device"] ?? "",
|
|
1225
|
+
status: attributes["status"] ?? "",
|
|
1226
|
+
output: attributes["output"] ?? "",
|
|
1227
|
+
section: Number(attributes[".section"] ?? 0),
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function newer(
|
|
1232
|
+
current: CmrScriptRow | undefined,
|
|
1233
|
+
next: CmrScriptRow,
|
|
1234
|
+
): CmrScriptRow {
|
|
1235
|
+
return current && current.section > next.section ? current : next;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function deadlineSignal(signal: AbortSignal, call: CmrScriptCall): AbortSignal {
|
|
1239
|
+
return AbortSignal.any([
|
|
1240
|
+
signal,
|
|
1241
|
+
AbortSignal.timeout(call.timeoutMs + CMR_CALL_GRACE_MS),
|
|
1242
|
+
]);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
async function nativeRunScript(
|
|
1246
|
+
adapter: ProtocolAdapter,
|
|
1247
|
+
call: CmrScriptCall,
|
|
1248
|
+
outer: AbortSignal,
|
|
1249
|
+
): Promise<CmrScriptOutcome> {
|
|
1250
|
+
const signal = deadlineSignal(outer, call);
|
|
1251
|
+
let sent = false;
|
|
1252
|
+
let row: CmrScriptRow | undefined;
|
|
1253
|
+
try {
|
|
1254
|
+
for await (const reply of adapter.stream(
|
|
1255
|
+
{
|
|
1256
|
+
verb: "run",
|
|
1257
|
+
path: CMR_RUN_SCRIPT_PATH,
|
|
1258
|
+
attributes: runScriptAttributes(call),
|
|
1259
|
+
},
|
|
1260
|
+
{
|
|
1261
|
+
signal,
|
|
1262
|
+
cancelGraceMs: CMR_CALL_GRACE_MS,
|
|
1263
|
+
onListening: () => {
|
|
1264
|
+
sent = true;
|
|
1265
|
+
},
|
|
1266
|
+
},
|
|
1267
|
+
)) {
|
|
1268
|
+
if (reply.type === "re") row = newer(row, toRow(reply.attributes));
|
|
1269
|
+
}
|
|
1270
|
+
} catch (error) {
|
|
1271
|
+
if (isNoDevicesSelected(error)) return { selected: false, sent: false };
|
|
1272
|
+
return { selected: true, sent, row, error: asCmrError(error) };
|
|
1273
|
+
}
|
|
1274
|
+
if (signal.aborted && !(row && FINAL_STATUSES.has(row.status))) {
|
|
1275
|
+
return { selected: true, sent, row, error: abortError(outer, call) };
|
|
1276
|
+
}
|
|
1277
|
+
return { selected: true, sent, row };
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
async function restRunScript(
|
|
1281
|
+
adapterFor: (timeoutMs: number, signal: AbortSignal) => ProtocolAdapter,
|
|
1282
|
+
call: CmrScriptCall,
|
|
1283
|
+
outer: AbortSignal,
|
|
1284
|
+
): Promise<CmrScriptOutcome> {
|
|
1285
|
+
if (outer.aborted)
|
|
1286
|
+
return { selected: true, sent: false, error: cancelledError() };
|
|
1287
|
+
const signal = deadlineSignal(outer, call);
|
|
1288
|
+
const adapter = adapterFor(call.timeoutMs + CMR_CALL_GRACE_MS, signal);
|
|
1289
|
+
try {
|
|
1290
|
+
const result = await adapter.apiRequest({
|
|
1291
|
+
verb: "run",
|
|
1292
|
+
path: CMR_RUN_SCRIPT_PATH,
|
|
1293
|
+
attributes: runScriptAttributes(call),
|
|
1294
|
+
});
|
|
1295
|
+
let row: CmrScriptRow | undefined;
|
|
1296
|
+
for (const attributes of Array.isArray(result.data)
|
|
1297
|
+
? (result.data as Record<string, string>[])
|
|
1298
|
+
: []) {
|
|
1299
|
+
row = newer(row, toRow(attributes));
|
|
1300
|
+
}
|
|
1301
|
+
return { selected: true, sent: true, row };
|
|
1302
|
+
} catch (error) {
|
|
1303
|
+
if (isNoDevicesSelected(error)) return { selected: false, sent: false };
|
|
1304
|
+
const mapped = signal.aborted ? abortError(outer, call) : asCmrError(error);
|
|
1305
|
+
// REST cannot say whether a failed request reached RouterOS, except when
|
|
1306
|
+
// the connection itself never opened.
|
|
1307
|
+
const neverOpened = NOT_SENT_TRANSPORT_CODES.includes(mapped.code);
|
|
1308
|
+
return { selected: true, sent: !neverOpened, error: mapped };
|
|
1309
|
+
} finally {
|
|
1310
|
+
await adapter.close();
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function abortError(outer: AbortSignal, call: CmrScriptCall): CentrsError {
|
|
1315
|
+
if (outer.aborted) {
|
|
1316
|
+
return new CentrsError({
|
|
1317
|
+
code: "cmr/cancelled",
|
|
1318
|
+
summary: "The CMR call was cancelled.",
|
|
1319
|
+
remediation:
|
|
1320
|
+
"Cancellation does not stop a client script that already started.",
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
return new CentrsError({
|
|
1324
|
+
code: "transport/timeout",
|
|
1325
|
+
summary: `The controller gave no final status within ${call.timeoutMs + CMR_CALL_GRACE_MS} ms.`,
|
|
1326
|
+
remediation: "Raise `--timeout` (CMR caps one call at ~60 s).",
|
|
1327
|
+
context: { timeoutMs: call.timeoutMs, graceMs: CMR_CALL_GRACE_MS },
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function asCmrError(error: unknown): CentrsError {
|
|
1332
|
+
return error instanceof CentrsError
|
|
1333
|
+
? error
|
|
1334
|
+
: new CentrsError({
|
|
1335
|
+
code: "internal/unhandled",
|
|
1336
|
+
summary: "A CMR controller call failed unexpectedly.",
|
|
1337
|
+
remediation:
|
|
1338
|
+
"Re-run with `--format json` to capture the structured cause.",
|
|
1339
|
+
cause: error,
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// ---------------------------------------------------------------------------
|
|
1344
|
+
// Rendering
|
|
1345
|
+
// ---------------------------------------------------------------------------
|
|
1346
|
+
|
|
1347
|
+
/** Outer error envelope for a CMR run that failed before any member result. */
|
|
1348
|
+
export function buildExecuteCmrErrorEnvelope(
|
|
1349
|
+
request: Pick<ExecuteRequest, "targetInput" | "via">,
|
|
1350
|
+
error: unknown,
|
|
1351
|
+
): ExecuteCmrErrorEnvelope {
|
|
1352
|
+
const centrsError = asCmrError(error);
|
|
1353
|
+
const via =
|
|
1354
|
+
request.via === "rest-api" || request.via === "native-api"
|
|
1355
|
+
? (request.via as RouterOsProtocol)
|
|
1356
|
+
: null;
|
|
1357
|
+
return {
|
|
1358
|
+
ok: false,
|
|
1359
|
+
error: serializeCentrsError(centrsError),
|
|
1360
|
+
warnings: [],
|
|
1361
|
+
tips: [],
|
|
1362
|
+
meta: { target: { input: request.targetInput }, via, settings: {} },
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/** Text: a summary line, then each member's status and (indented) output. */
|
|
1367
|
+
export function renderExecuteCmrEnvelope(
|
|
1368
|
+
envelope: ExecuteCmrEnvelope | ExecuteCmrErrorEnvelope,
|
|
1369
|
+
format: "json" | "yaml" | "text",
|
|
1370
|
+
options: { verbose?: boolean } = {},
|
|
1371
|
+
): string {
|
|
1372
|
+
if (format === "json") return JSON.stringify(envelope, null, 2);
|
|
1373
|
+
if (format === "yaml") return toYaml(envelope);
|
|
1374
|
+
if (!envelope.ok) return formatCentrsErrorText(envelope.error, options);
|
|
1375
|
+
const operation = envelope.meta.operation;
|
|
1376
|
+
const { total, ok, failed } = envelope.data.summary;
|
|
1377
|
+
const controller =
|
|
1378
|
+
envelope.meta.target.identity ??
|
|
1379
|
+
envelope.meta.target.input ??
|
|
1380
|
+
envelope.meta.target.host ??
|
|
1381
|
+
"?";
|
|
1382
|
+
const lines = [
|
|
1383
|
+
terminalSafeLine(
|
|
1384
|
+
`execute --cmr ${operation?.cmr.labels ?? "?"} via ${controller}: ${ok}/${total} ok, ${failed} failed`,
|
|
1385
|
+
),
|
|
1386
|
+
];
|
|
1387
|
+
for (const target of envelope.data.targets) {
|
|
1388
|
+
const id = target.meta.target.cmr?.id ?? "-";
|
|
1389
|
+
const label = target.meta.target.identity ?? "(unknown)";
|
|
1390
|
+
if (target.ok) {
|
|
1391
|
+
lines.push(terminalSafeLine(` [${id}] ok ${label}`));
|
|
1392
|
+
const ret = (target.data as { ret?: string }).ret ?? "";
|
|
1393
|
+
for (const line of ret
|
|
1394
|
+
.replaceAll("\r\n", "\n")
|
|
1395
|
+
.replace(/\n+$/, "")
|
|
1396
|
+
.split("\n")) {
|
|
1397
|
+
if (line.length > 0) lines.push(terminalSafeLine(` ${line}`));
|
|
1398
|
+
}
|
|
1399
|
+
} else {
|
|
1400
|
+
lines.push(
|
|
1401
|
+
terminalSafeLine(
|
|
1402
|
+
` [${id}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
|
|
1403
|
+
),
|
|
1404
|
+
);
|
|
1405
|
+
if (options.verbose && target.error.remediation) {
|
|
1406
|
+
lines.push(
|
|
1407
|
+
terminalSafeLine(` Fix: ${target.error.remediation}`),
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
for (const warning of target.warnings) {
|
|
1412
|
+
lines.push(
|
|
1413
|
+
terminalSafeLine(
|
|
1414
|
+
` warning [${warning.code}]: ${warning.message}`,
|
|
1415
|
+
),
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
for (const warning of envelope.warnings) {
|
|
1420
|
+
lines.push(
|
|
1421
|
+
terminalSafeLine(`warning [${warning.code}]: ${warning.message}`),
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
return lines.join("\n");
|
|
1425
|
+
}
|