@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
|
@@ -262,6 +262,68 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
262
262
|
summary: "A received MNDP datagram could not be decoded.",
|
|
263
263
|
},
|
|
264
264
|
|
|
265
|
+
// cmr/* — `execute --cmr` (#450)
|
|
266
|
+
{
|
|
267
|
+
code: "cmr/cancelled",
|
|
268
|
+
summary:
|
|
269
|
+
"The operation was cancelled before the command was sent to a CMR client.",
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
code: "cmr/client-script-not-validated",
|
|
273
|
+
summary:
|
|
274
|
+
"Warning: a caller-addressed `/cmr/device/run-script` was validated as a controller request; its script was not validated on the clients.",
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
code: "cmr/client-disconnected",
|
|
278
|
+
summary:
|
|
279
|
+
"CMR skipped a client that is not connected to its controller; the command was not sent.",
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
code: "cmr/empty-selection",
|
|
283
|
+
summary:
|
|
284
|
+
"Warning: the `--labels` expression matched no CMR device on the controller.",
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
code: "cmr/membership-changed",
|
|
288
|
+
summary:
|
|
289
|
+
"A CMR client left the label selection or changed its key between validation and dispatch; the command was not sent.",
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
code: "cmr/not-a-controller",
|
|
293
|
+
summary:
|
|
294
|
+
"The target does not answer `/cmr/device`; it is not a CMR controller.",
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
code: "cmr/console-only",
|
|
298
|
+
summary:
|
|
299
|
+
"The CMR command is an interactive console view that breaks an API session, so centrs refused it before running it.",
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
code: "cmr/outcome-unknown",
|
|
303
|
+
summary:
|
|
304
|
+
"The command was sent to a CMR client but no final status came back; it may still be running.",
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
code: "cmr/output-truncated",
|
|
308
|
+
summary:
|
|
309
|
+
"Warning: CMR clipped a client's command output at its ~60 KiB cap.",
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
code: "cmr/policy-denied",
|
|
313
|
+
summary:
|
|
314
|
+
"A CMR client refused a statement because CMR scripts run without the policy it needs; earlier statements already ran.",
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
code: "cmr/selection-incomplete",
|
|
318
|
+
summary:
|
|
319
|
+
"The controller did not answer whether every device matches the labels, so nothing was run.",
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
code: "cmr/validation-incomplete",
|
|
323
|
+
summary:
|
|
324
|
+
"A CMR client's validation result was incomplete or clipped, so the command was not sent to it.",
|
|
325
|
+
},
|
|
326
|
+
|
|
265
327
|
// quickchr/*
|
|
266
328
|
{
|
|
267
329
|
code: "quickchr/package-unavailable",
|
|
@@ -533,6 +595,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
533
595
|
code: "transport/unsupported-operation",
|
|
534
596
|
summary: "The chosen transport does not support this operation.",
|
|
535
597
|
},
|
|
598
|
+
{
|
|
599
|
+
code: "transport/write-outcome-unknown",
|
|
600
|
+
summary:
|
|
601
|
+
"Warning: a write failed after it was sent, so it may have run on the router; centrs did not retry it.",
|
|
602
|
+
},
|
|
536
603
|
|
|
537
604
|
// usage/*
|
|
538
605
|
{
|
|
@@ -567,6 +634,10 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
567
634
|
code: "usage/invalid-method",
|
|
568
635
|
summary: "The -X/--method value is not a supported HTTP method.",
|
|
569
636
|
},
|
|
637
|
+
{
|
|
638
|
+
code: "usage/missing-labels",
|
|
639
|
+
summary: "`--cmr` was given without a `--labels` expression.",
|
|
640
|
+
},
|
|
570
641
|
{
|
|
571
642
|
code: "usage/missing-group",
|
|
572
643
|
summary: "The operation requires a non-empty --group value.",
|
package/src/core/fanout.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
EnvelopeTargetMeta,
|
|
22
22
|
FanoutData,
|
|
23
23
|
FanoutSummary,
|
|
24
|
+
ValidationTrace,
|
|
24
25
|
Warning,
|
|
25
26
|
} from "./envelope.ts";
|
|
26
27
|
|
|
@@ -81,6 +82,59 @@ export function isRetryableFanoutError(error: unknown): boolean {
|
|
|
81
82
|
return (FANOUT_RETRYABLE_CODES as readonly string[]).includes(error.code);
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Transport failures that prove the request never reached RouterOS (or reached
|
|
87
|
+
* it and was refused before running): the local client, connection, name,
|
|
88
|
+
* route, trust or login failed. Any other transport failure after a send is
|
|
89
|
+
* ambiguous.
|
|
90
|
+
*/
|
|
91
|
+
export const NOT_SENT_TRANSPORT_CODES: readonly string[] = [
|
|
92
|
+
"transport/auth-failed",
|
|
93
|
+
"transport/connection-refused",
|
|
94
|
+
"transport/dns",
|
|
95
|
+
"transport/host-key-mismatch",
|
|
96
|
+
"transport/insecure-trust",
|
|
97
|
+
"transport/local-tool-missing",
|
|
98
|
+
"transport/provider-trust",
|
|
99
|
+
"transport/tls-certificate",
|
|
100
|
+
"transport/unreachable",
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* #448: a write whose request was already sent when a transport failure ended
|
|
105
|
+
* it may have run on the router. It is never retried, and its error envelope
|
|
106
|
+
* says the outcome is unknown rather than implying nothing happened.
|
|
107
|
+
*/
|
|
108
|
+
export function sentWriteWarnings(
|
|
109
|
+
write: boolean,
|
|
110
|
+
error: unknown,
|
|
111
|
+
trace: ValidationTrace | undefined,
|
|
112
|
+
): Warning[] {
|
|
113
|
+
if (!write || !trace?.dispatched) return [];
|
|
114
|
+
if (
|
|
115
|
+
!(error instanceof CentrsError) ||
|
|
116
|
+
!error.code.startsWith("transport/") ||
|
|
117
|
+
NOT_SENT_TRANSPORT_CODES.includes(error.code)
|
|
118
|
+
) {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
return [
|
|
122
|
+
{
|
|
123
|
+
code: "transport/write-outcome-unknown",
|
|
124
|
+
message: `The write was sent before it failed with ${error.code}, so it may have run on the router. centrs does not retry a write it has sent; check the router's state before running it again.`,
|
|
125
|
+
context: { cause: error.code },
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A fan-out retry veto: never replay a write whose request was sent (#448). */
|
|
131
|
+
export function mayRetryUnsentWrite(
|
|
132
|
+
write: boolean,
|
|
133
|
+
trace: ValidationTrace | undefined,
|
|
134
|
+
): boolean {
|
|
135
|
+
return !write || trace?.dispatched !== true;
|
|
136
|
+
}
|
|
137
|
+
|
|
84
138
|
/** Count inner `ok`/`failed` across a completed target set. */
|
|
85
139
|
export function summarizeFanout(
|
|
86
140
|
targets: readonly { ok: boolean }[],
|
|
@@ -128,9 +182,11 @@ export async function runBoundedPool<I, O>(
|
|
|
128
182
|
|
|
129
183
|
/**
|
|
130
184
|
* Run `run`, retrying only {@link isRetryableFanoutError} failures up to
|
|
131
|
-
* `maxRetries` times with jittered backoff.
|
|
132
|
-
*
|
|
133
|
-
*
|
|
185
|
+
* `maxRetries` times with jittered backoff. `mayRetry` vetoes a retry the
|
|
186
|
+
* allowlist would allow, e.g. a write that was already sent (#448). A
|
|
187
|
+
* non-retryable error, or the final exhausted attempt, is converted to a value
|
|
188
|
+
* via `recover` (so a fan-out target yields an inner error envelope instead of
|
|
189
|
+
* throwing out of the batch).
|
|
134
190
|
*/
|
|
135
191
|
export async function runWithRetry<T>(
|
|
136
192
|
run: () => Promise<T>,
|
|
@@ -138,6 +194,7 @@ export async function runWithRetry<T>(
|
|
|
138
194
|
options: {
|
|
139
195
|
sleep?: (ms: number) => Promise<void>;
|
|
140
196
|
maxRetries?: number;
|
|
197
|
+
mayRetry?: (error: unknown) => boolean;
|
|
141
198
|
} = {},
|
|
142
199
|
): Promise<T> {
|
|
143
200
|
const sleep = options.sleep ?? defaultFanoutSleep;
|
|
@@ -147,7 +204,11 @@ export async function runWithRetry<T>(
|
|
|
147
204
|
try {
|
|
148
205
|
return await run();
|
|
149
206
|
} catch (error) {
|
|
150
|
-
if (
|
|
207
|
+
if (
|
|
208
|
+
attempt < maxRetries &&
|
|
209
|
+
isRetryableFanoutError(error) &&
|
|
210
|
+
(options.mayRetry?.(error) ?? true)
|
|
211
|
+
) {
|
|
151
212
|
attempt += 1;
|
|
152
213
|
await sleep(fanoutBackoffMs(attempt));
|
|
153
214
|
continue;
|
|
@@ -173,9 +234,10 @@ export function defaultFanoutSleep(ms: number): Promise<void> {
|
|
|
173
234
|
* per-target plan, and the inner envelope type. Mirrors the original
|
|
174
235
|
* `retrieveGroup` worker exactly: resolve each member (a resolve failure becomes
|
|
175
236
|
* an inner error envelope, never a throw out of the batch); run the resolved
|
|
176
|
-
* request through {@link runWithRetry} (a retryable transient drop is retried
|
|
177
|
-
* everything else recovers to an inner error
|
|
178
|
-
* input (record-index) order via
|
|
237
|
+
* request through {@link runWithRetry} (a retryable transient drop is retried
|
|
238
|
+
* unless `mayRetry` vetoes it, everything else recovers to an inner error
|
|
239
|
+
* envelope). Results are returned in input (record-index) order via
|
|
240
|
+
* {@link runBoundedPool}.
|
|
179
241
|
*/
|
|
180
242
|
export async function runFanout<Member, Resolved, Env>(opts: {
|
|
181
243
|
members: readonly Member[];
|
|
@@ -184,6 +246,8 @@ export async function runFanout<Member, Resolved, Env>(opts: {
|
|
|
184
246
|
onResolveError: (member: Member, error: unknown) => Env;
|
|
185
247
|
execute: (resolved: Resolved, member: Member) => Promise<Env>;
|
|
186
248
|
onExecuteError: (resolved: Resolved, member: Member, error: unknown) => Env;
|
|
249
|
+
/** Veto a retry for this target, e.g. a write that was already sent. */
|
|
250
|
+
mayRetry?: (resolved: Resolved, error: unknown) => boolean;
|
|
187
251
|
sleep?: (ms: number) => Promise<void>;
|
|
188
252
|
}): Promise<Env[]> {
|
|
189
253
|
return runBoundedPool(opts.members, opts.concurrency, async (member) => {
|
|
@@ -196,7 +260,12 @@ export async function runFanout<Member, Resolved, Env>(opts: {
|
|
|
196
260
|
return runWithRetry<Env>(
|
|
197
261
|
() => opts.execute(resolved, member),
|
|
198
262
|
(error) => opts.onExecuteError(resolved, member, error),
|
|
199
|
-
{
|
|
263
|
+
{
|
|
264
|
+
sleep: opts.sleep,
|
|
265
|
+
mayRetry: opts.mayRetry
|
|
266
|
+
? (error) => opts.mayRetry?.(resolved, error) ?? true
|
|
267
|
+
: undefined,
|
|
268
|
+
},
|
|
200
269
|
);
|
|
201
270
|
});
|
|
202
271
|
}
|