@tikoci/centrs 0.1.8 → 0.1.9-next.22
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 +216 -31
- package/GLOSSARY.txt +8 -0
- package/README.md +16 -8
- package/SECURITY.md +11 -3
- package/commands/api/README.md +44 -15
- package/commands/api/examples.md +44 -6
- package/commands/execute/README.md +218 -7
- package/commands/execute/examples.md +278 -1
- package/commands/explain/README.md +2 -1
- package/commands/mcp/README.md +11 -0
- package/commands/mcp/examples.md +6 -0
- package/commands/retrieve/README.md +32 -14
- package/commands/retrieve/examples.md +10 -6
- package/commands/transfer/examples.md +9 -0
- package/docs/CLI.md +10 -8
- package/docs/CONSTITUTION.md +54 -3
- package/docs/MATRIX.md +20 -3
- package/docs/errors/cdb/empty-group.md +19 -3
- package/docs/errors/cdb/empty-selection.md +9 -3
- package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
- 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 +33 -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/rule-selects-every-device.md +32 -0
- package/docs/errors/cmr/selection-includes-controller.md +27 -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/flag-after-separator.md +34 -0
- package/docs/errors/usage/missing-labels.md +19 -0
- package/package.json +7 -5
- package/src/api-fanout.ts +18 -2
- package/src/api.ts +230 -71
- package/src/btest.ts +7 -2
- package/src/cli/api.ts +30 -16
- package/src/cli/common.ts +180 -1
- package/src/cli/devices.ts +0 -2
- package/src/cli/discover.ts +0 -1
- package/src/cli/execute.ts +176 -21
- package/src/cli/explain.ts +2 -0
- package/src/cli/mcp.ts +0 -1
- package/src/cli/retrieve.ts +40 -18
- package/src/cli/settings.ts +0 -3
- package/src/cli/terminal.ts +0 -2
- package/src/cli/transfer.ts +10 -8
- package/src/cli/version.ts +87 -0
- package/src/cli.ts +5 -3
- package/src/core/cmr-fleet.ts +486 -0
- package/src/core/cmr.ts +182 -0
- package/src/core/envelope.ts +46 -1
- package/src/core/error-catalog.ts +95 -2
- package/src/core/execute-command.ts +144 -19
- package/src/core/fanout.ts +207 -14
- package/src/errors.ts +3 -1
- package/src/execute-cmr.ts +1442 -0
- package/src/execute-fanout.ts +15 -1
- package/src/execute.ts +173 -40
- package/src/index.ts +35 -1
- package/src/mcp/safety.ts +11 -0
- package/src/mcp/tools.ts +4 -1
- package/src/protocols/adapter.ts +54 -12
- package/src/resolver/cdb.ts +13 -1
- package/src/resolver/index.ts +4 -0
- package/src/resolver/selection.ts +37 -1
- package/src/resolver/target.ts +96 -8
- package/src/retrieve-fanout.ts +4 -1
- package/src/retrieve-follow.ts +29 -33
- package/src/retrieve-sample.ts +14 -8
- package/src/retrieve-stream.ts +38 -6
- package/src/retrieve.ts +73 -59
- package/src/transfer-fanout.ts +8 -1
- package/src/transfer.ts +15 -19
package/src/core/fanout.ts
CHANGED
|
@@ -20,7 +20,10 @@ import type {
|
|
|
20
20
|
CommonSettingsMeta,
|
|
21
21
|
EnvelopeTargetMeta,
|
|
22
22
|
FanoutData,
|
|
23
|
+
FanoutMemberOperation,
|
|
23
24
|
FanoutSummary,
|
|
25
|
+
SettingSource,
|
|
26
|
+
ValidationTrace,
|
|
24
27
|
Warning,
|
|
25
28
|
} from "./envelope.ts";
|
|
26
29
|
|
|
@@ -81,6 +84,59 @@ export function isRetryableFanoutError(error: unknown): boolean {
|
|
|
81
84
|
return (FANOUT_RETRYABLE_CODES as readonly string[]).includes(error.code);
|
|
82
85
|
}
|
|
83
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Transport failures that prove the request never reached RouterOS (or reached
|
|
89
|
+
* it and was refused before running): the local client, connection, name,
|
|
90
|
+
* route, trust or login failed. Any other transport failure after a send is
|
|
91
|
+
* ambiguous.
|
|
92
|
+
*/
|
|
93
|
+
export const NOT_SENT_TRANSPORT_CODES: readonly string[] = [
|
|
94
|
+
"transport/auth-failed",
|
|
95
|
+
"transport/connection-refused",
|
|
96
|
+
"transport/dns",
|
|
97
|
+
"transport/host-key-mismatch",
|
|
98
|
+
"transport/insecure-trust",
|
|
99
|
+
"transport/local-tool-missing",
|
|
100
|
+
"transport/provider-trust",
|
|
101
|
+
"transport/tls-certificate",
|
|
102
|
+
"transport/unreachable",
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* #448: a write whose request was already sent when a transport failure ended
|
|
107
|
+
* it may have run on the router. It is never retried, and its error envelope
|
|
108
|
+
* says the outcome is unknown rather than implying nothing happened.
|
|
109
|
+
*/
|
|
110
|
+
export function sentWriteWarnings(
|
|
111
|
+
write: boolean,
|
|
112
|
+
error: unknown,
|
|
113
|
+
trace: ValidationTrace | undefined,
|
|
114
|
+
): Warning[] {
|
|
115
|
+
if (!write || !trace?.dispatched) return [];
|
|
116
|
+
if (
|
|
117
|
+
!(error instanceof CentrsError) ||
|
|
118
|
+
!error.code.startsWith("transport/") ||
|
|
119
|
+
NOT_SENT_TRANSPORT_CODES.includes(error.code)
|
|
120
|
+
) {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
return [
|
|
124
|
+
{
|
|
125
|
+
code: "transport/write-outcome-unknown",
|
|
126
|
+
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.`,
|
|
127
|
+
context: { cause: error.code },
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A fan-out retry veto: never replay a write whose request was sent (#448). */
|
|
133
|
+
export function mayRetryUnsentWrite(
|
|
134
|
+
write: boolean,
|
|
135
|
+
trace: ValidationTrace | undefined,
|
|
136
|
+
): boolean {
|
|
137
|
+
return !write || trace?.dispatched !== true;
|
|
138
|
+
}
|
|
139
|
+
|
|
84
140
|
/** Count inner `ok`/`failed` across a completed target set. */
|
|
85
141
|
export function summarizeFanout(
|
|
86
142
|
targets: readonly { ok: boolean }[],
|
|
@@ -128,9 +184,11 @@ export async function runBoundedPool<I, O>(
|
|
|
128
184
|
|
|
129
185
|
/**
|
|
130
186
|
* Run `run`, retrying only {@link isRetryableFanoutError} failures up to
|
|
131
|
-
* `maxRetries` times with jittered backoff.
|
|
132
|
-
*
|
|
133
|
-
*
|
|
187
|
+
* `maxRetries` times with jittered backoff. `mayRetry` vetoes a retry the
|
|
188
|
+
* allowlist would allow, e.g. a write that was already sent (#448). A
|
|
189
|
+
* non-retryable error, or the final exhausted attempt, is converted to a value
|
|
190
|
+
* via `recover` (so a fan-out target yields an inner error envelope instead of
|
|
191
|
+
* throwing out of the batch).
|
|
134
192
|
*/
|
|
135
193
|
export async function runWithRetry<T>(
|
|
136
194
|
run: () => Promise<T>,
|
|
@@ -138,6 +196,7 @@ export async function runWithRetry<T>(
|
|
|
138
196
|
options: {
|
|
139
197
|
sleep?: (ms: number) => Promise<void>;
|
|
140
198
|
maxRetries?: number;
|
|
199
|
+
mayRetry?: (error: unknown) => boolean;
|
|
141
200
|
} = {},
|
|
142
201
|
): Promise<T> {
|
|
143
202
|
const sleep = options.sleep ?? defaultFanoutSleep;
|
|
@@ -147,7 +206,11 @@ export async function runWithRetry<T>(
|
|
|
147
206
|
try {
|
|
148
207
|
return await run();
|
|
149
208
|
} catch (error) {
|
|
150
|
-
if (
|
|
209
|
+
if (
|
|
210
|
+
attempt < maxRetries &&
|
|
211
|
+
isRetryableFanoutError(error) &&
|
|
212
|
+
(options.mayRetry?.(error) ?? true)
|
|
213
|
+
) {
|
|
151
214
|
attempt += 1;
|
|
152
215
|
await sleep(fanoutBackoffMs(attempt));
|
|
153
216
|
continue;
|
|
@@ -173,9 +236,10 @@ export function defaultFanoutSleep(ms: number): Promise<void> {
|
|
|
173
236
|
* per-target plan, and the inner envelope type. Mirrors the original
|
|
174
237
|
* `retrieveGroup` worker exactly: resolve each member (a resolve failure becomes
|
|
175
238
|
* 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
|
|
239
|
+
* request through {@link runWithRetry} (a retryable transient drop is retried
|
|
240
|
+
* unless `mayRetry` vetoes it, everything else recovers to an inner error
|
|
241
|
+
* envelope). Results are returned in input (record-index) order via
|
|
242
|
+
* {@link runBoundedPool}.
|
|
179
243
|
*/
|
|
180
244
|
export async function runFanout<Member, Resolved, Env>(opts: {
|
|
181
245
|
members: readonly Member[];
|
|
@@ -184,6 +248,8 @@ export async function runFanout<Member, Resolved, Env>(opts: {
|
|
|
184
248
|
onResolveError: (member: Member, error: unknown) => Env;
|
|
185
249
|
execute: (resolved: Resolved, member: Member) => Promise<Env>;
|
|
186
250
|
onExecuteError: (resolved: Resolved, member: Member, error: unknown) => Env;
|
|
251
|
+
/** Veto a retry for this target, e.g. a write that was already sent. */
|
|
252
|
+
mayRetry?: (resolved: Resolved, error: unknown) => boolean;
|
|
187
253
|
sleep?: (ms: number) => Promise<void>;
|
|
188
254
|
}): Promise<Env[]> {
|
|
189
255
|
return runBoundedPool(opts.members, opts.concurrency, async (member) => {
|
|
@@ -196,7 +262,12 @@ export async function runFanout<Member, Resolved, Env>(opts: {
|
|
|
196
262
|
return runWithRetry<Env>(
|
|
197
263
|
() => opts.execute(resolved, member),
|
|
198
264
|
(error) => opts.onExecuteError(resolved, member, error),
|
|
199
|
-
{
|
|
265
|
+
{
|
|
266
|
+
sleep: opts.sleep,
|
|
267
|
+
mayRetry: opts.mayRetry
|
|
268
|
+
? (error) => opts.mayRetry?.(resolved, error) ?? true
|
|
269
|
+
: undefined,
|
|
270
|
+
},
|
|
200
271
|
);
|
|
201
272
|
});
|
|
202
273
|
}
|
|
@@ -241,29 +312,151 @@ export function commonVia(
|
|
|
241
312
|
return via ?? null;
|
|
242
313
|
}
|
|
243
314
|
|
|
315
|
+
/** The part of an outer fan-out `operation` its targets may share. */
|
|
316
|
+
interface SharedOperation {
|
|
317
|
+
request?: unknown;
|
|
318
|
+
auth?: unknown;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Write shared meta once (#461): the outer envelope keeps the run's settings,
|
|
323
|
+
* request and auth, and each target keeps only what differs from them. A
|
|
324
|
+
* settings key the outer envelope lacks, and the `auth`, move out when every
|
|
325
|
+
* target that reached an operation agrees on them. A target's `settings`
|
|
326
|
+
* keeps the keys whose source differs from the outer key's; its
|
|
327
|
+
* `request`/`auth` are dropped when equal to the outer ones.
|
|
328
|
+
*/
|
|
329
|
+
export function compactFanoutTargets<Data, Op, OuterOp extends object>(
|
|
330
|
+
settings: CommonSettingsMeta,
|
|
331
|
+
operation: OuterOp,
|
|
332
|
+
targets: readonly CentrsEnvelope<Data, Op>[],
|
|
333
|
+
): {
|
|
334
|
+
settings: CommonSettingsMeta;
|
|
335
|
+
operation: OuterOp;
|
|
336
|
+
targets: CentrsEnvelope<Data, FanoutMemberOperation<Op>>[];
|
|
337
|
+
} {
|
|
338
|
+
// A target that failed before it had an operation (a resolve failure)
|
|
339
|
+
// has no settings or auth to agree or disagree with.
|
|
340
|
+
const reached = targets.flatMap((target) => {
|
|
341
|
+
const inner = target.meta.operation as SharedOperation | undefined;
|
|
342
|
+
return inner ? [{ settings: target.meta.settings, auth: inner.auth }] : [];
|
|
343
|
+
});
|
|
344
|
+
const agreed = (value: (member: (typeof reached)[number]) => unknown) => {
|
|
345
|
+
const first = reached[0] && value(reached[0]);
|
|
346
|
+
return first !== undefined &&
|
|
347
|
+
reached.every((member) => Bun.deepEquals(value(member), first))
|
|
348
|
+
? first
|
|
349
|
+
: undefined;
|
|
350
|
+
};
|
|
351
|
+
const outerSettings: CommonSettingsMeta = { ...settings };
|
|
352
|
+
for (const key of Object.keys(reached[0]?.settings ?? {})) {
|
|
353
|
+
if (outerSettings[key] !== undefined) continue;
|
|
354
|
+
const source = agreed((member) => member.settings[key]);
|
|
355
|
+
if (source !== undefined) outerSettings[key] = source as SettingSource;
|
|
356
|
+
}
|
|
357
|
+
const outer = operation as SharedOperation;
|
|
358
|
+
const auth = outer.auth === undefined ? agreed((m) => m.auth) : undefined;
|
|
359
|
+
const shared: SharedOperation =
|
|
360
|
+
auth === undefined ? outer : { ...outer, auth };
|
|
361
|
+
return {
|
|
362
|
+
settings: outerSettings,
|
|
363
|
+
operation: shared as OuterOp,
|
|
364
|
+
targets: targets.map((target) => {
|
|
365
|
+
const own: CommonSettingsMeta = {};
|
|
366
|
+
// An `undefined` source is never written: JSON drops it, so a direct
|
|
367
|
+
// and a serialized reader would disagree on what the target inherits.
|
|
368
|
+
for (const [key, source] of Object.entries(target.meta.settings)) {
|
|
369
|
+
if (source !== undefined && !Bun.deepEquals(source, outerSettings[key]))
|
|
370
|
+
own[key] = source;
|
|
371
|
+
}
|
|
372
|
+
const meta = { ...target.meta, settings: own };
|
|
373
|
+
const inner = target.meta.operation as
|
|
374
|
+
| (Record<string, unknown> & SharedOperation)
|
|
375
|
+
| undefined;
|
|
376
|
+
if (inner) {
|
|
377
|
+
const { request, auth, ...rest } = inner;
|
|
378
|
+
// `auth` is shared only when every reached target has the same
|
|
379
|
+
// defined one, and every inner operation type requires `request`,
|
|
380
|
+
// so an omitted field always means "equal to the outer one".
|
|
381
|
+
const differs = (value: unknown, outer: unknown) =>
|
|
382
|
+
value !== undefined && !Bun.deepEquals(value, outer);
|
|
383
|
+
meta.operation = {
|
|
384
|
+
...rest,
|
|
385
|
+
...(differs(request, shared.request) ? { request } : {}),
|
|
386
|
+
...(differs(auth, shared.auth) ? { auth } : {}),
|
|
387
|
+
} as Op;
|
|
388
|
+
}
|
|
389
|
+
return { ...target, meta } as CentrsEnvelope<
|
|
390
|
+
Data,
|
|
391
|
+
FanoutMemberOperation<Op>
|
|
392
|
+
>;
|
|
393
|
+
}),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The inverse of {@link compactFanoutTargets}: a target's full meta, read as
|
|
399
|
+
* `target ?? outer` (per key for settings). A target that failed before it
|
|
400
|
+
* had an operation gets no operation.
|
|
401
|
+
*/
|
|
402
|
+
export function expandFanoutTarget<Data, Op>(
|
|
403
|
+
outer: { meta: { settings: CommonSettingsMeta; operation?: unknown } },
|
|
404
|
+
target: CentrsEnvelope<Data, FanoutMemberOperation<Op>>,
|
|
405
|
+
): CentrsEnvelope<Data, Op> {
|
|
406
|
+
const shared = (outer.meta.operation ?? {}) as SharedOperation;
|
|
407
|
+
const inner = target.meta.operation as
|
|
408
|
+
| (Record<string, unknown> & SharedOperation)
|
|
409
|
+
| undefined;
|
|
410
|
+
const settings: CommonSettingsMeta = { ...outer.meta.settings };
|
|
411
|
+
for (const [key, source] of Object.entries(target.meta.settings)) {
|
|
412
|
+
if (source !== undefined) settings[key] = source;
|
|
413
|
+
}
|
|
414
|
+
const operation = inner && {
|
|
415
|
+
...inner,
|
|
416
|
+
...(inner.request === undefined && shared.request !== undefined
|
|
417
|
+
? { request: shared.request }
|
|
418
|
+
: {}),
|
|
419
|
+
...(inner.auth === undefined && shared.auth !== undefined
|
|
420
|
+
? { auth: shared.auth }
|
|
421
|
+
: {}),
|
|
422
|
+
};
|
|
423
|
+
return {
|
|
424
|
+
...target,
|
|
425
|
+
meta: { ...target.meta, settings, ...(operation ? { operation } : {}) },
|
|
426
|
+
} as CentrsEnvelope<Data, Op>;
|
|
427
|
+
}
|
|
428
|
+
|
|
244
429
|
/**
|
|
245
430
|
* Assemble the locked fan-out success envelope: `data = { summary, targets }`,
|
|
246
431
|
* outer `ok: true` = orchestration succeeded. The command owns its outer
|
|
247
|
-
* `operation` meta shape and passes it in
|
|
432
|
+
* `operation` meta shape and passes it in; shared meta is hoisted out of the
|
|
433
|
+
* targets by {@link compactFanoutTargets}. `target` is the outer target, set
|
|
434
|
+
* only when the fan-out runs through one router (a CMR controller).
|
|
248
435
|
*/
|
|
249
|
-
export function buildFanoutEnvelope<Data, Op, OuterOp>(input: {
|
|
436
|
+
export function buildFanoutEnvelope<Data, Op, OuterOp extends object>(input: {
|
|
250
437
|
summary: FanoutSummary;
|
|
251
438
|
targets: readonly CentrsEnvelope<Data, Op>[];
|
|
252
439
|
warnings: readonly Warning[];
|
|
253
440
|
settings: CommonSettingsMeta;
|
|
254
441
|
via: RouterOsProtocol | null;
|
|
255
442
|
operation: OuterOp;
|
|
443
|
+
target?: EnvelopeTargetMeta;
|
|
256
444
|
}): CentrsSuccessEnvelope<FanoutData<Data, Op>, OuterOp> {
|
|
445
|
+
const { settings, operation, targets } = compactFanoutTargets(
|
|
446
|
+
input.settings,
|
|
447
|
+
input.operation,
|
|
448
|
+
input.targets,
|
|
449
|
+
);
|
|
257
450
|
return {
|
|
258
451
|
ok: true,
|
|
259
|
-
data: { summary: input.summary, targets
|
|
452
|
+
data: { summary: input.summary, targets },
|
|
260
453
|
warnings: input.warnings,
|
|
261
454
|
tips: [],
|
|
262
455
|
meta: {
|
|
263
|
-
target: {},
|
|
456
|
+
target: input.target ?? {},
|
|
264
457
|
via: input.via,
|
|
265
|
-
settings
|
|
266
|
-
operation
|
|
458
|
+
settings,
|
|
459
|
+
operation,
|
|
267
460
|
},
|
|
268
461
|
};
|
|
269
462
|
}
|
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}`
|
|
@@ -264,11 +265,12 @@ export function serializeUnknownError(error: unknown): unknown {
|
|
|
264
265
|
return serializeCentrsError(error);
|
|
265
266
|
}
|
|
266
267
|
|
|
268
|
+
// No `stack`: it is raw exception text with local install paths, and the
|
|
269
|
+
// constitution summarizes those rather than embedding them (#476).
|
|
267
270
|
if (error instanceof Error) {
|
|
268
271
|
return {
|
|
269
272
|
name: error.name,
|
|
270
273
|
message: error.message,
|
|
271
|
-
stack: error.stack,
|
|
272
274
|
code: extractErrorCode(error),
|
|
273
275
|
};
|
|
274
276
|
}
|