@tikoci/centrs 0.1.1 → 0.1.3
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 +107 -2
- package/GLOSSARY.txt +87 -0
- package/README.md +25 -9
- package/SECURITY.md +3 -3
- package/commands/AGENTS.md +3 -2
- package/commands/api/AGENTS.md +212 -0
- package/commands/api/README.md +177 -0
- package/commands/api/examples.md +428 -0
- package/commands/btest/README.md +56 -31
- package/commands/btest/examples.md +35 -10
- package/commands/check/README.md +371 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/execute/README.md +48 -7
- package/commands/execute/examples.md +90 -6
- package/commands/retrieve/README.md +37 -51
- package/commands/retrieve/examples.md +40 -9
- package/commands/settings/README.md +286 -22
- package/commands/settings/examples.md +408 -0
- package/commands/terminal/README.md +6 -0
- package/commands/transfer/README.md +32 -2
- package/commands/transfer/examples.md +66 -0
- package/docs/CONSTITUTION.md +40 -15
- package/docs/MATRIX.md +69 -142
- package/docs/errors/README.md +2 -1
- package/docs/errors/cdb/empty-selection.md +19 -0
- package/docs/errors/input/incomplete-gps.md +9 -0
- package/docs/errors/input/invalid-altitude.md +9 -0
- package/docs/errors/input/invalid-bbox.md +9 -0
- package/docs/errors/input/invalid-coordinate.md +9 -0
- package/docs/errors/input/invalid-radius.md +9 -0
- package/docs/errors/internal/settings-failed.md +9 -0
- package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
- package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
- package/docs/errors/settings/consequential-value.md +26 -0
- package/docs/errors/settings/invalid-boolean.md +21 -3
- package/docs/errors/settings/invalid-format.md +18 -3
- package/docs/errors/settings/invalid-integer.md +18 -3
- package/docs/errors/settings/invalid-timeout.md +20 -3
- package/docs/errors/settings/invalid-via.md +22 -3
- package/docs/errors/settings/reserved-key.md +29 -0
- package/docs/errors/settings/skip-env-file-active.md +25 -0
- package/docs/errors/settings/unknown-key.md +25 -0
- package/docs/errors/usage/fanout-not-supported.md +16 -0
- package/docs/errors/usage/invalid-method.md +23 -0
- package/package.json +9 -9
- package/src/api-fanout.ts +454 -0
- package/src/api.ts +1627 -0
- package/src/btest.ts +61 -1
- package/src/cli/api.ts +660 -0
- package/src/cli/btest.ts +20 -2
- package/src/cli/common.ts +75 -0
- package/src/cli/devices.ts +235 -6
- package/src/cli/discover.ts +3 -1
- package/src/cli/execute.ts +107 -6
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +135 -90
- package/src/cli/selection.ts +200 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +171 -47
- package/src/cli.ts +29 -0
- package/src/core/error-catalog.ts +69 -0
- package/src/core/fanout.ts +301 -0
- package/src/core/inspect.ts +170 -0
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/execute-fanout.ts +438 -0
- package/src/execute.ts +134 -70
- package/src/index.ts +166 -9
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +357 -9
- package/src/protocols/btest-session.ts +279 -46
- package/src/protocols/btest.ts +6 -2
- package/src/protocols/index.ts +11 -5
- package/src/protocols/native-api.ts +223 -30
- package/src/resolver/cdb.ts +10 -2
- package/src/resolver/comment-kv.ts +58 -4
- package/src/resolver/config-file.ts +136 -0
- package/src/resolver/facts.ts +78 -0
- package/src/resolver/geo.ts +453 -0
- package/src/resolver/index.ts +46 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/selection.ts +304 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +20 -2
- package/src/retrieve-fanout.ts +232 -268
- package/src/retrieve.ts +69 -94
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +527 -0
- package/src/transfer.ts +61 -11
- package/commands/stream/README.md +0 -70
package/src/retrieve-fanout.ts
CHANGED
|
@@ -1,28 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Multi-target fan-out for `retrieve`.
|
|
3
3
|
*
|
|
4
|
-
* A `--group
|
|
5
|
-
* through the single-target
|
|
4
|
+
* A target selection (`--group` / `--where` / `--all` / `--default` / multiple
|
|
5
|
+
* positionals) expands to N members and runs each through the single-target
|
|
6
|
+
* retrieve tail (`buildResolvedRetrieve` → `runResolvedRetrieve`), collecting a
|
|
6
7
|
* per-target inner `CentrsEnvelope` into the LOCKED `FanoutData` shape from
|
|
7
8
|
* `src/core/envelope.ts`. The outer envelope's `ok` means the orchestration
|
|
8
9
|
* produced a complete per-target result set; a per-target failure is an INNER
|
|
9
10
|
* `ok: false` envelope, never an outer failure and never a thrown error out of
|
|
10
11
|
* the batch.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
13
|
+
* Boundaries (see `commands/retrieve/README.md`, `docs/CONSTITUTION.md`), shared
|
|
14
|
+
* with `api-fanout.ts`:
|
|
15
|
+
* - The CDB is loaded + decrypted ONCE (`expandCdbSelection`); a cdb member is
|
|
16
|
+
* resolved with its pre-resolved record, so `resolveCdb`'s `__default__`
|
|
17
|
+
* synthetic fallback never collides ad-hoc literals.
|
|
18
|
+
* - A `--default` / `__default__` member is guarded from being dialed as the
|
|
19
|
+
* literal hostname `"__default__"` — it fails that one target deterministically
|
|
20
|
+
* with `target/unresolved`.
|
|
21
|
+
* - Each target is resolved + validated independently (validation is per-target
|
|
22
|
+
* via live `/console/inspect`; schemas differ by version).
|
|
16
23
|
* - Concurrency is a bounded worker pool; defaults are transport-aware
|
|
17
24
|
* (REST 8, native-api 4) and overridable with `--concurrency`.
|
|
18
|
-
* -
|
|
19
|
-
*
|
|
20
|
-
* - `data.targets[]` is ordered by CDB `recordIndex` regardless of completion
|
|
21
|
-
* order; `summary` counts are derived from inner `ok`.
|
|
25
|
+
* - `data.targets[]` is ordered by CDB `recordIndex` (literals appended in
|
|
26
|
+
* positional order); `summary` counts are derived from inner `ok`.
|
|
22
27
|
*
|
|
23
|
-
* Outer `ok: false` is reserved for
|
|
28
|
+
* Outer `ok: false` is reserved for fan-out failing BEFORE reliable per-target
|
|
24
29
|
* results exist (bad request shape, CDB decrypt failure); those throw out of
|
|
25
|
-
* {@link
|
|
30
|
+
* {@link retrieveFanout} and the CLI renders them with
|
|
26
31
|
* {@link buildRetrieveFanoutErrorEnvelope}.
|
|
27
32
|
*/
|
|
28
33
|
|
|
@@ -32,13 +37,25 @@ import type {
|
|
|
32
37
|
FanoutData,
|
|
33
38
|
FanoutSummary,
|
|
34
39
|
} from "./core/envelope.ts";
|
|
40
|
+
import {
|
|
41
|
+
buildFanoutEnvelope,
|
|
42
|
+
buildFanoutResolveFailure,
|
|
43
|
+
commonVia,
|
|
44
|
+
defaultFanoutSleep,
|
|
45
|
+
resolveFanoutConcurrency,
|
|
46
|
+
runFanout,
|
|
47
|
+
summarizeFanout,
|
|
48
|
+
} from "./core/fanout.ts";
|
|
35
49
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
36
50
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
37
51
|
import {
|
|
38
|
-
type
|
|
39
|
-
type
|
|
40
|
-
type
|
|
41
|
-
|
|
52
|
+
type CdbSelectionExpansion,
|
|
53
|
+
type CdbSelectionMember,
|
|
54
|
+
type CdbSelectionResolveInput,
|
|
55
|
+
expandCdbSelection,
|
|
56
|
+
isDefaultRecordTarget,
|
|
57
|
+
loadEnvFileDefaults,
|
|
58
|
+
type TargetSelection,
|
|
42
59
|
} from "./resolver/index.ts";
|
|
43
60
|
import {
|
|
44
61
|
buildResolvedRetrieve,
|
|
@@ -52,38 +69,27 @@ import {
|
|
|
52
69
|
type RetrieveSuccessEnvelope,
|
|
53
70
|
resolveMacForRetrieve,
|
|
54
71
|
resolveRetrieveGlobalContext,
|
|
72
|
+
resolveRetrieveRequest,
|
|
55
73
|
runResolvedRetrieve,
|
|
56
74
|
toYaml,
|
|
57
75
|
validateRetrieveRequestShape,
|
|
58
76
|
} from "./retrieve.ts";
|
|
59
77
|
|
|
60
|
-
/**
|
|
61
|
-
export
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Extra attempts after the first try (jittered backoff between them). */
|
|
70
|
-
export const RETRIEVE_FANOUT_MAX_RETRIES = 2;
|
|
71
|
-
|
|
72
|
-
/** Base backoff in milliseconds; doubled per attempt with up to +base jitter. */
|
|
73
|
-
export const RETRIEVE_FANOUT_BACKOFF_BASE_MS = 200;
|
|
74
|
-
|
|
75
|
-
/** RouterOS-style codes that are safe to retry under fanout. */
|
|
76
|
-
export const RETRIEVE_FANOUT_RETRYABLE_CODES = [
|
|
77
|
-
"transport/network",
|
|
78
|
-
"transport/connection-closed",
|
|
79
|
-
] as const;
|
|
78
|
+
/** Compact description of the selector that produced the fan-out. */
|
|
79
|
+
export interface RetrieveSelectionSummary {
|
|
80
|
+
groups: readonly string[];
|
|
81
|
+
where: readonly string[];
|
|
82
|
+
all: boolean;
|
|
83
|
+
default: boolean;
|
|
84
|
+
positionals: readonly string[];
|
|
85
|
+
}
|
|
80
86
|
|
|
81
87
|
export interface RetrieveFanoutOperationMeta {
|
|
82
88
|
kind: "fanout";
|
|
83
|
-
group: string;
|
|
84
89
|
concurrency: number;
|
|
85
90
|
summary: FanoutSummary;
|
|
86
91
|
request: RetrieveRequestSummary;
|
|
92
|
+
selection: RetrieveSelectionSummary;
|
|
87
93
|
}
|
|
88
94
|
|
|
89
95
|
export type RetrieveFanoutData = FanoutData<unknown, RetrieveOperationMeta>;
|
|
@@ -95,60 +101,109 @@ export type RetrieveFanoutErrorEnvelope =
|
|
|
95
101
|
CentrsErrorEnvelope<RetrieveFanoutOperationMeta>;
|
|
96
102
|
|
|
97
103
|
/** Test/override seams; production callers pass nothing. */
|
|
98
|
-
export interface
|
|
104
|
+
export interface RetrieveFanoutInternals {
|
|
99
105
|
/** Per-target executor (attempts; throws on failure). Defaults to the live tail. */
|
|
100
106
|
execute?: (
|
|
101
107
|
resolved: ResolvedRetrieveRequest,
|
|
102
|
-
recordIndex: number,
|
|
103
108
|
) => Promise<RetrieveSuccessEnvelope>;
|
|
104
|
-
/**
|
|
109
|
+
/** Selection expansion. Defaults to the real CDB-backed expansion. */
|
|
105
110
|
expand?: (
|
|
106
|
-
|
|
111
|
+
selection: TargetSelection,
|
|
112
|
+
input: CdbSelectionResolveInput,
|
|
107
113
|
env: Record<string, string | undefined>,
|
|
108
|
-
|
|
114
|
+
config?: Record<string, string | undefined>,
|
|
115
|
+
) => Promise<CdbSelectionExpansion>;
|
|
109
116
|
/** Backoff sleeper. Defaults to a real timer; tests pass a no-op. */
|
|
110
117
|
sleep?: (ms: number) => Promise<void>;
|
|
111
118
|
}
|
|
112
119
|
|
|
120
|
+
export interface RetrieveFanoutOptions {
|
|
121
|
+
concurrency?: number;
|
|
122
|
+
/** CLI: true (literal positionals allowed). MCP: false (CDB is the allowlist). */
|
|
123
|
+
allowAdhoc?: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function summarizeSelection(
|
|
127
|
+
selection: TargetSelection,
|
|
128
|
+
): RetrieveSelectionSummary {
|
|
129
|
+
return {
|
|
130
|
+
groups: selection.groups,
|
|
131
|
+
where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
|
|
132
|
+
all: selection.all,
|
|
133
|
+
default: selection.default,
|
|
134
|
+
positionals: selection.positionals,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function memberTargetMeta(member: CdbSelectionMember): {
|
|
139
|
+
input?: string;
|
|
140
|
+
identity?: string;
|
|
141
|
+
recordIndex?: number;
|
|
142
|
+
} {
|
|
143
|
+
if (member.kind === "cdb") {
|
|
144
|
+
return {
|
|
145
|
+
input: member.resolution.identity,
|
|
146
|
+
identity: member.resolution.identity,
|
|
147
|
+
recordIndex: member.recordIndex,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return { input: member.input };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Ad-hoc literal targets have no CDB record. `resolveRetrieveRequest` may borrow
|
|
155
|
+
* the `__default__` record's index when filling fallback creds, so the inner
|
|
156
|
+
* envelope would carry that borrowed `recordIndex` — making distinct literals
|
|
157
|
+
* collide on one index. Drop it for literal members and stamp the caller's `input`
|
|
158
|
+
* so the target stays identifiable in renderers (mirrors `api-fanout.ts`).
|
|
159
|
+
*/
|
|
160
|
+
function relabelAdhocMember(
|
|
161
|
+
envelope: RetrieveEnvelope,
|
|
162
|
+
member: CdbSelectionMember,
|
|
163
|
+
): RetrieveEnvelope {
|
|
164
|
+
if (member.kind !== "literal") {
|
|
165
|
+
return envelope;
|
|
166
|
+
}
|
|
167
|
+
const { recordIndex: _borrowed, ...target } = envelope.meta.target;
|
|
168
|
+
return {
|
|
169
|
+
...envelope,
|
|
170
|
+
meta: { ...envelope.meta, target: { ...target, input: member.input } },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
113
174
|
/**
|
|
114
|
-
* Run a `retrieve`
|
|
175
|
+
* Run a `retrieve` fan-out. Always returns a success envelope when the
|
|
115
176
|
* orchestration completes (even if every target failed); throws only for
|
|
116
177
|
* pre-flight failures (bad request shape, CDB decrypt) the caller renders as an
|
|
117
178
|
* outer error.
|
|
118
179
|
*/
|
|
119
|
-
export async function
|
|
180
|
+
export async function retrieveFanout(
|
|
120
181
|
request: RetrieveRequest,
|
|
182
|
+
selection: TargetSelection,
|
|
121
183
|
env: Record<string, string | undefined> = Bun.env,
|
|
122
|
-
internals:
|
|
184
|
+
internals: RetrieveFanoutInternals = {},
|
|
185
|
+
options: RetrieveFanoutOptions = {},
|
|
123
186
|
): Promise<RetrieveFanoutEnvelope> {
|
|
124
|
-
const group = request.group;
|
|
125
|
-
if (group === undefined || group.length === 0) {
|
|
126
|
-
throw new CentrsError({
|
|
127
|
-
code: "usage/missing-group",
|
|
128
|
-
summary: "retrieve group fanout requires a non-empty `--group` value.",
|
|
129
|
-
remediation:
|
|
130
|
-
"Pass `--group <name>` matching a CDB group, e.g. `centrs retrieve --group prod /system/resource`.",
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
187
|
const attributeSelections = validateRetrieveRequestShape(request);
|
|
188
|
+
const config = await loadEnvFileDefaults(env);
|
|
135
189
|
const global = resolveRetrieveGlobalContext(
|
|
136
190
|
request,
|
|
137
191
|
env,
|
|
138
192
|
attributeSelections,
|
|
193
|
+
config,
|
|
139
194
|
);
|
|
140
|
-
|
|
141
|
-
resolveFanoutConcurrency(request.concurrency, global.via);
|
|
142
|
-
}
|
|
195
|
+
const selectionSummary = summarizeSelection(selection);
|
|
143
196
|
|
|
144
|
-
const expand = internals.expand ??
|
|
197
|
+
const expand = internals.expand ?? expandCdbSelection;
|
|
145
198
|
const expansion = await expand(
|
|
199
|
+
selection,
|
|
146
200
|
{
|
|
147
|
-
group,
|
|
148
201
|
cdbFile: request.cdbFile,
|
|
149
202
|
cdbPassword: request.cdbPassword,
|
|
203
|
+
allowAdhoc: options.allowAdhoc ?? true,
|
|
150
204
|
},
|
|
151
205
|
env,
|
|
206
|
+
config,
|
|
152
207
|
);
|
|
153
208
|
|
|
154
209
|
const concurrencyVia = resolveFanoutConcurrencyProtocol(
|
|
@@ -158,13 +213,13 @@ export async function retrieveGroup(
|
|
|
158
213
|
expansion,
|
|
159
214
|
);
|
|
160
215
|
const concurrency = resolveFanoutConcurrency(
|
|
161
|
-
request.concurrency,
|
|
216
|
+
options.concurrency ?? request.concurrency,
|
|
162
217
|
concurrencyVia,
|
|
163
218
|
);
|
|
164
219
|
|
|
165
220
|
if (expansion.empty) {
|
|
166
|
-
return
|
|
167
|
-
|
|
221
|
+
return retrieveFanoutEnvelope({
|
|
222
|
+
selection: selectionSummary,
|
|
168
223
|
concurrency,
|
|
169
224
|
summary: { total: 0, ok: 0, failed: 0 },
|
|
170
225
|
requestSummary: global.summary,
|
|
@@ -176,38 +231,70 @@ export async function retrieveGroup(
|
|
|
176
231
|
}
|
|
177
232
|
|
|
178
233
|
const execute = internals.execute ?? runResolvedRetrieve;
|
|
179
|
-
const sleep = internals.sleep ??
|
|
180
|
-
|
|
181
|
-
const targets = await
|
|
182
|
-
|
|
234
|
+
const sleep = internals.sleep ?? defaultFanoutSleep;
|
|
235
|
+
|
|
236
|
+
const targets = await runFanout<
|
|
237
|
+
CdbSelectionMember,
|
|
238
|
+
ResolvedRetrieveRequest,
|
|
239
|
+
RetrieveEnvelope
|
|
240
|
+
>({
|
|
241
|
+
members: expansion.targets,
|
|
183
242
|
concurrency,
|
|
184
|
-
async (member)
|
|
185
|
-
|
|
186
|
-
|
|
243
|
+
resolve: async (member) => {
|
|
244
|
+
if (member.kind === "cdb") {
|
|
245
|
+
if (isDefaultRecordTarget(member.resolution.target)) {
|
|
246
|
+
throw new CentrsError({
|
|
247
|
+
code: "target/unresolved",
|
|
248
|
+
summary:
|
|
249
|
+
"The `__default__` record is a credential fallback, not a connectable router.",
|
|
250
|
+
remediation:
|
|
251
|
+
"Select real devices with `--group` / `--where` / `--all` or a positional; `--default` cannot be a RouterOS target.",
|
|
252
|
+
context: { target: member.resolution.target },
|
|
253
|
+
});
|
|
254
|
+
}
|
|
187
255
|
const macResolution = await resolveMacForRetrieve(
|
|
188
256
|
request,
|
|
189
257
|
env,
|
|
190
258
|
member.resolution,
|
|
259
|
+
config,
|
|
191
260
|
);
|
|
192
|
-
|
|
261
|
+
return buildResolvedRetrieve(
|
|
193
262
|
request,
|
|
194
263
|
env,
|
|
195
264
|
member.resolution,
|
|
196
265
|
attributeSelections,
|
|
197
266
|
macResolution,
|
|
267
|
+
config,
|
|
198
268
|
);
|
|
199
|
-
} catch (error) {
|
|
200
|
-
return buildResolveFailureEnvelope(member.resolution, error);
|
|
201
269
|
}
|
|
202
|
-
|
|
270
|
+
// Ad-hoc literal: full single-target resolution (CDB load → `__default__`
|
|
271
|
+
// fallback creds), mirroring single-target `retrieve`.
|
|
272
|
+
return resolveRetrieveRequest(
|
|
273
|
+
{ ...request, targetInput: member.input },
|
|
274
|
+
env,
|
|
275
|
+
);
|
|
203
276
|
},
|
|
204
|
-
|
|
277
|
+
onResolveError: (member, error) =>
|
|
278
|
+
buildFanoutResolveFailure<RetrieveOperationMeta>({
|
|
279
|
+
error,
|
|
280
|
+
target: memberTargetMeta(member),
|
|
281
|
+
warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
|
|
282
|
+
summary: "Failed to resolve a fanout target.",
|
|
283
|
+
}),
|
|
284
|
+
execute: async (resolved, member) =>
|
|
285
|
+
relabelAdhocMember(await execute(resolved), member),
|
|
286
|
+
onExecuteError: (resolved, member, error) =>
|
|
287
|
+
relabelAdhocMember(
|
|
288
|
+
buildRetrieveErrorEnvelopeFromResolved(resolved, error),
|
|
289
|
+
member,
|
|
290
|
+
),
|
|
291
|
+
sleep,
|
|
292
|
+
});
|
|
205
293
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
group,
|
|
294
|
+
return retrieveFanoutEnvelope({
|
|
295
|
+
selection: selectionSummary,
|
|
209
296
|
concurrency,
|
|
210
|
-
summary,
|
|
297
|
+
summary: summarizeFanout(targets),
|
|
211
298
|
requestSummary: global.summary,
|
|
212
299
|
settings: global.settings,
|
|
213
300
|
via: commonVia(targets),
|
|
@@ -216,6 +303,37 @@ export async function retrieveGroup(
|
|
|
216
303
|
});
|
|
217
304
|
}
|
|
218
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Back-compat shim for the `--group`-only fan-out: builds a single-group
|
|
308
|
+
* {@link TargetSelection} and delegates to {@link retrieveFanout}. Kept for the
|
|
309
|
+
* MCP `centrs_retrieve` tool, whose surface exposes only `group`.
|
|
310
|
+
*/
|
|
311
|
+
export async function retrieveGroup(
|
|
312
|
+
request: RetrieveRequest,
|
|
313
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
314
|
+
internals: RetrieveFanoutInternals = {},
|
|
315
|
+
): Promise<RetrieveFanoutEnvelope> {
|
|
316
|
+
const group = request.group;
|
|
317
|
+
if (group === undefined || group.length === 0) {
|
|
318
|
+
throw new CentrsError({
|
|
319
|
+
code: "usage/missing-group",
|
|
320
|
+
summary: "retrieve group fanout requires a non-empty `--group` value.",
|
|
321
|
+
remediation:
|
|
322
|
+
"Pass `--group <name>` matching a CDB group, e.g. `centrs retrieve --group prod /system/resource`.",
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
const selection: TargetSelection = {
|
|
326
|
+
positionals: [],
|
|
327
|
+
groups: [group],
|
|
328
|
+
all: false,
|
|
329
|
+
default: false,
|
|
330
|
+
where: [],
|
|
331
|
+
};
|
|
332
|
+
return retrieveFanout(request, selection, env, internals, {
|
|
333
|
+
concurrency: request.concurrency,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
219
337
|
/** Outer error envelope for a fanout that failed before per-target results. */
|
|
220
338
|
export function buildRetrieveFanoutErrorEnvelope(
|
|
221
339
|
request: RetrieveRequest,
|
|
@@ -226,7 +344,7 @@ export function buildRetrieveFanoutErrorEnvelope(
|
|
|
226
344
|
? error
|
|
227
345
|
: new CentrsError({
|
|
228
346
|
code: "internal/unhandled",
|
|
229
|
-
summary: "retrieve
|
|
347
|
+
summary: "retrieve fan-out failed with an unexpected error.",
|
|
230
348
|
remediation:
|
|
231
349
|
"Re-run with `--format json` to capture the structured error details for debugging.",
|
|
232
350
|
cause: error,
|
|
@@ -251,40 +369,24 @@ export function buildRetrieveFanoutErrorEnvelope(
|
|
|
251
369
|
};
|
|
252
370
|
}
|
|
253
371
|
|
|
254
|
-
/** Resolve and validate the worker-pool size. */
|
|
255
|
-
export function resolveFanoutConcurrency(
|
|
256
|
-
requested: number | undefined,
|
|
257
|
-
via: RouterOsProtocol,
|
|
258
|
-
): number {
|
|
259
|
-
if (requested !== undefined) {
|
|
260
|
-
if (!Number.isInteger(requested) || requested < 1) {
|
|
261
|
-
throw new CentrsError({
|
|
262
|
-
code: "usage/invalid-concurrency",
|
|
263
|
-
summary: `--concurrency must be an integer >= 1. Received: ${requested}`,
|
|
264
|
-
remediation: "Pass a positive integer, e.g. `--concurrency 4`.",
|
|
265
|
-
context: { concurrency: requested },
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
return requested;
|
|
269
|
-
}
|
|
270
|
-
if (via === "native-api") {
|
|
271
|
-
return RETRIEVE_FANOUT_CONCURRENCY_DEFAULTS["native-api"];
|
|
272
|
-
}
|
|
273
|
-
return RETRIEVE_FANOUT_CONCURRENCY_DEFAULTS["rest-api"];
|
|
274
|
-
}
|
|
275
|
-
|
|
276
372
|
function resolveFanoutConcurrencyProtocol(
|
|
277
373
|
request: RetrieveRequest,
|
|
278
374
|
env: Record<string, string | undefined>,
|
|
279
375
|
globalVia: RouterOsProtocol,
|
|
280
|
-
expansion:
|
|
376
|
+
expansion: CdbSelectionExpansion,
|
|
281
377
|
): RouterOsProtocol {
|
|
378
|
+
// Deliberately CLI/env only, not `config` — a `centrs.env` default is the
|
|
379
|
+
// weakest precedence tier and must not be treated as a hard pin the way an
|
|
380
|
+
// explicit `--via`/`CENTRS_VIA` is; doing so would defeat per-target CDB
|
|
381
|
+
// `via=` overrides for members that disagree with the global default.
|
|
282
382
|
if (request.via !== undefined || env["CENTRS_VIA"] !== undefined) {
|
|
283
383
|
return globalVia;
|
|
284
384
|
}
|
|
285
385
|
if (
|
|
286
386
|
expansion.targets.some(
|
|
287
|
-
(
|
|
387
|
+
(member) =>
|
|
388
|
+
member.kind === "cdb" &&
|
|
389
|
+
member.resolution.overrides.via?.value === "native-api",
|
|
288
390
|
)
|
|
289
391
|
) {
|
|
290
392
|
return "native-api";
|
|
@@ -292,124 +394,8 @@ function resolveFanoutConcurrencyProtocol(
|
|
|
292
394
|
return globalVia;
|
|
293
395
|
}
|
|
294
396
|
|
|
295
|
-
/**
|
|
296
|
-
* Classify a per-target failure as retryable. Only the locked allowlist
|
|
297
|
-
* retries: generic transport/network failures and dropped/closed connections
|
|
298
|
-
* (including REST 5xx mapped to `transport/connection-closed`). Router-side
|
|
299
|
-
* (`routeros/*`), validation, auth, cdb, target, input, usage, timeout, TLS,
|
|
300
|
-
* connection-refused, and DNS failures are deterministic and never retried.
|
|
301
|
-
*/
|
|
302
|
-
export function isRetryableFanoutError(error: unknown): boolean {
|
|
303
|
-
if (!(error instanceof CentrsError)) {
|
|
304
|
-
return false;
|
|
305
|
-
}
|
|
306
|
-
return (RETRIEVE_FANOUT_RETRYABLE_CODES as readonly string[]).includes(
|
|
307
|
-
error.code,
|
|
308
|
-
);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
export function summarizeFanout(
|
|
312
|
-
targets: readonly RetrieveEnvelope[],
|
|
313
|
-
): FanoutSummary {
|
|
314
|
-
let ok = 0;
|
|
315
|
-
let failed = 0;
|
|
316
|
-
for (const target of targets) {
|
|
317
|
-
if (target.ok) {
|
|
318
|
-
ok += 1;
|
|
319
|
-
} else {
|
|
320
|
-
failed += 1;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
return { total: targets.length, ok, failed };
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
async function runTargetWithRetry(
|
|
327
|
-
resolved: ResolvedRetrieveRequest,
|
|
328
|
-
recordIndex: number,
|
|
329
|
-
execute: (
|
|
330
|
-
resolved: ResolvedRetrieveRequest,
|
|
331
|
-
recordIndex: number,
|
|
332
|
-
) => Promise<RetrieveSuccessEnvelope>,
|
|
333
|
-
sleep: (ms: number) => Promise<void>,
|
|
334
|
-
): Promise<RetrieveEnvelope> {
|
|
335
|
-
let attempt = 0;
|
|
336
|
-
for (;;) {
|
|
337
|
-
try {
|
|
338
|
-
return await execute(resolved, recordIndex);
|
|
339
|
-
} catch (error) {
|
|
340
|
-
if (
|
|
341
|
-
attempt < RETRIEVE_FANOUT_MAX_RETRIES &&
|
|
342
|
-
isRetryableFanoutError(error)
|
|
343
|
-
) {
|
|
344
|
-
attempt += 1;
|
|
345
|
-
await sleep(backoffMs(attempt));
|
|
346
|
-
continue;
|
|
347
|
-
}
|
|
348
|
-
return buildRetrieveErrorEnvelopeFromResolved(resolved, error);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Ordered bounded worker pool. Runs `worker` over `items` with at most
|
|
355
|
-
* `concurrency` in flight, returning results in INPUT order regardless of
|
|
356
|
-
* completion order.
|
|
357
|
-
*/
|
|
358
|
-
export async function runBoundedPool<I, O>(
|
|
359
|
-
items: readonly I[],
|
|
360
|
-
concurrency: number,
|
|
361
|
-
worker: (item: I, index: number) => Promise<O>,
|
|
362
|
-
): Promise<O[]> {
|
|
363
|
-
const results = new Array<O>(items.length);
|
|
364
|
-
let next = 0;
|
|
365
|
-
const width = Math.max(1, Math.min(concurrency, items.length));
|
|
366
|
-
|
|
367
|
-
async function drain(): Promise<void> {
|
|
368
|
-
for (;;) {
|
|
369
|
-
const index = next;
|
|
370
|
-
next += 1;
|
|
371
|
-
if (index >= items.length) {
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
results[index] = await worker(items[index] as I, index);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
await Promise.all(Array.from({ length: width }, () => drain()));
|
|
379
|
-
return results;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function buildResolveFailureEnvelope(
|
|
383
|
-
resolution: CdbResolution,
|
|
384
|
-
error: unknown,
|
|
385
|
-
): RetrieveEnvelope {
|
|
386
|
-
const centrsError =
|
|
387
|
-
error instanceof CentrsError
|
|
388
|
-
? error
|
|
389
|
-
: new CentrsError({
|
|
390
|
-
code: "internal/unhandled",
|
|
391
|
-
summary: "Failed to resolve a fanout target.",
|
|
392
|
-
cause: error,
|
|
393
|
-
});
|
|
394
|
-
return {
|
|
395
|
-
ok: false,
|
|
396
|
-
error: serializeCentrsError(centrsError),
|
|
397
|
-
warnings: [...resolution.warnings],
|
|
398
|
-
tips: [],
|
|
399
|
-
meta: {
|
|
400
|
-
target: {
|
|
401
|
-
input: resolution.identity,
|
|
402
|
-
identity: resolution.identity,
|
|
403
|
-
recordIndex: resolution.recordIndex,
|
|
404
|
-
},
|
|
405
|
-
via: null,
|
|
406
|
-
settings: {},
|
|
407
|
-
},
|
|
408
|
-
};
|
|
409
|
-
}
|
|
410
|
-
|
|
411
397
|
interface FanoutEnvelopeInput {
|
|
412
|
-
|
|
398
|
+
selection: RetrieveSelectionSummary;
|
|
413
399
|
concurrency: number;
|
|
414
400
|
summary: FanoutSummary;
|
|
415
401
|
requestSummary: RetrieveRequestSummary;
|
|
@@ -419,54 +405,28 @@ interface FanoutEnvelopeInput {
|
|
|
419
405
|
targets: readonly RetrieveEnvelope[];
|
|
420
406
|
}
|
|
421
407
|
|
|
422
|
-
|
|
408
|
+
/** retrieve's outer fan-out success envelope (delegates to the shared core). */
|
|
409
|
+
function retrieveFanoutEnvelope(
|
|
423
410
|
input: FanoutEnvelopeInput,
|
|
424
411
|
): RetrieveFanoutEnvelope {
|
|
425
|
-
return
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
412
|
+
return buildFanoutEnvelope<
|
|
413
|
+
unknown,
|
|
414
|
+
RetrieveOperationMeta,
|
|
415
|
+
RetrieveFanoutOperationMeta
|
|
416
|
+
>({
|
|
417
|
+
summary: input.summary,
|
|
418
|
+
targets: input.targets,
|
|
431
419
|
warnings: input.warnings,
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
concurrency: input.concurrency,
|
|
441
|
-
summary: input.summary,
|
|
442
|
-
request: input.requestSummary,
|
|
443
|
-
},
|
|
420
|
+
settings: input.settings,
|
|
421
|
+
via: input.via,
|
|
422
|
+
operation: {
|
|
423
|
+
kind: "fanout",
|
|
424
|
+
concurrency: input.concurrency,
|
|
425
|
+
summary: input.summary,
|
|
426
|
+
request: input.requestSummary,
|
|
427
|
+
selection: input.selection,
|
|
444
428
|
},
|
|
445
|
-
};
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
function commonVia(
|
|
449
|
-
targets: readonly RetrieveEnvelope[],
|
|
450
|
-
): RouterOsProtocol | null {
|
|
451
|
-
let via: RouterOsProtocol | null | undefined;
|
|
452
|
-
for (const target of targets) {
|
|
453
|
-
const candidate = target.meta.via;
|
|
454
|
-
if (via === undefined) {
|
|
455
|
-
via = candidate;
|
|
456
|
-
} else if (via !== candidate) {
|
|
457
|
-
return null;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
return via ?? null;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
function backoffMs(attempt: number): number {
|
|
464
|
-
const base = RETRIEVE_FANOUT_BACKOFF_BASE_MS * 2 ** (attempt - 1);
|
|
465
|
-
return base + Math.floor(Math.random() * RETRIEVE_FANOUT_BACKOFF_BASE_MS);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
function defaultSleep(ms: number): Promise<void> {
|
|
469
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
429
|
+
});
|
|
470
430
|
}
|
|
471
431
|
|
|
472
432
|
/**
|
|
@@ -504,12 +464,16 @@ function renderFanoutText(
|
|
|
504
464
|
const operation = envelope.meta.operation;
|
|
505
465
|
const { total, ok, failed } = envelope.data.summary;
|
|
506
466
|
const lines: string[] = [
|
|
507
|
-
`
|
|
467
|
+
`retrieve ${operation?.request.path ?? "?"}: ${ok}/${total} ok, ${failed} failed (concurrency ${operation?.concurrency ?? "?"})`,
|
|
508
468
|
];
|
|
509
469
|
|
|
510
470
|
for (const target of envelope.data.targets) {
|
|
511
471
|
const meta = target.meta;
|
|
512
|
-
const label =
|
|
472
|
+
const label =
|
|
473
|
+
meta.target.identity ??
|
|
474
|
+
meta.target.host ??
|
|
475
|
+
meta.target.input ??
|
|
476
|
+
"(unknown)";
|
|
513
477
|
const index = meta.target.recordIndex ?? "-";
|
|
514
478
|
if (target.ok) {
|
|
515
479
|
lines.push(
|