@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +4 -0
  3. package/commands/api/README.md +5 -0
  4. package/commands/execute/README.md +117 -0
  5. package/commands/execute/examples.md +162 -0
  6. package/commands/retrieve/README.md +2 -1
  7. package/docs/CLI.md +2 -0
  8. package/docs/CONSTITUTION.md +29 -1
  9. package/docs/MATRIX.md +19 -3
  10. package/docs/errors/cmr/cancelled.md +15 -0
  11. package/docs/errors/cmr/client-disconnected.md +19 -0
  12. package/docs/errors/cmr/client-script-not-validated.md +22 -0
  13. package/docs/errors/cmr/console-only.md +29 -0
  14. package/docs/errors/cmr/empty-selection.md +19 -0
  15. package/docs/errors/cmr/membership-changed.md +20 -0
  16. package/docs/errors/cmr/not-a-controller.md +26 -0
  17. package/docs/errors/cmr/outcome-unknown.md +29 -0
  18. package/docs/errors/cmr/output-truncated.md +16 -0
  19. package/docs/errors/cmr/policy-denied.md +31 -0
  20. package/docs/errors/cmr/selection-incomplete.md +18 -0
  21. package/docs/errors/cmr/validation-incomplete.md +23 -0
  22. package/docs/errors/routeros/version-unsupported.md +4 -0
  23. package/docs/errors/transport/write-outcome-unknown.md +24 -0
  24. package/docs/errors/usage/missing-labels.md +19 -0
  25. package/package.json +1 -1
  26. package/src/api-fanout.ts +7 -1
  27. package/src/api.ts +25 -6
  28. package/src/cli/execute.ts +132 -1
  29. package/src/core/cmr.ts +104 -0
  30. package/src/core/envelope.ts +16 -0
  31. package/src/core/error-catalog.ts +71 -0
  32. package/src/core/fanout.ts +77 -8
  33. package/src/errors.ts +1 -0
  34. package/src/execute-cmr.ts +1425 -0
  35. package/src/execute-fanout.ts +4 -0
  36. package/src/execute.ts +68 -17
  37. package/src/index.ts +17 -0
  38. 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.",
@@ -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. A non-retryable error, or the final
132
- * exhausted attempt, is converted to a value via `recover` (so a fan-out target
133
- * yields an inner error envelope instead of throwing out of the batch).
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 (attempt < maxRetries && isRetryableFanoutError(error)) {
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 envelope). Results are returned in
178
- * input (record-index) order via {@link runBoundedPool}.
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
- { sleep: opts.sleep },
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
  }
package/src/errors.ts CHANGED
@@ -17,6 +17,7 @@ export type CentrsErrorCode =
17
17
  | `wait/${string}`
18
18
  | `auth/${string}`
19
19
  | `cdb/${string}`
20
+ | `cmr/${string}`
20
21
  | `discover/${string}`
21
22
  | `identity/${string}`
22
23
  | `input/${string}`