@tikoci/centrs 0.1.2 → 0.1.4
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 +98 -0
- package/GLOSSARY.txt +284 -0
- package/LICENSE +21 -0
- package/README.md +55 -11
- package/SECURITY.md +3 -3
- package/commands/AGENTS.md +7 -1
- package/commands/api/README.md +46 -27
- package/commands/api/examples.md +124 -6
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +376 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +73 -26
- package/commands/execute/examples.md +131 -9
- package/commands/explain/README.md +1716 -0
- package/commands/explain/examples.md +606 -0
- package/commands/retrieve/README.md +55 -71
- package/commands/retrieve/examples.md +56 -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 +57 -19
- package/commands/transfer/examples.md +95 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +102 -26
- package/docs/MATRIX.md +106 -173
- package/docs/errors/README.md +5 -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/quickchr/machine-not-found.md +22 -0
- package/docs/errors/quickchr/machine-stopped.md +20 -0
- package/docs/errors/quickchr/package-unavailable.md +27 -0
- package/docs/errors/quickchr/unsupported-via.md +35 -0
- package/docs/errors/quickchr/unsupported.md +35 -0
- package/docs/errors/routeros/version-unsupported.md +26 -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/transport/provider-trust.md +29 -0
- package/docs/errors/usage/fanout-not-supported.md +16 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +49 -14
- package/src/api-fanout.ts +469 -0
- package/src/api.ts +171 -56
- package/src/cli/api.ts +156 -13
- package/src/cli/btest.ts +38 -4
- package/src/cli/common.ts +11 -0
- package/src/cli/devices.ts +235 -6
- package/src/cli/discover.ts +3 -1
- package/src/cli/execute.ts +129 -9
- package/src/cli/explain.ts +503 -0
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +167 -90
- package/src/cli/selection.ts +260 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +192 -51
- package/src/cli.ts +22 -2
- package/src/core/envelope.ts +5 -2
- package/src/core/error-catalog.ts +95 -0
- package/src/core/fanout.ts +143 -2
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +453 -0
- package/src/execute.ts +216 -68
- package/src/explain/arg-tokens.ts +114 -0
- package/src/explain/args.ts +1403 -0
- package/src/explain/blocks.ts +132 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +38 -0
- package/src/explain/catalog.ts +1345 -0
- package/src/explain/coordinates.ts +329 -0
- package/src/explain/defects.ts +217 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +664 -0
- package/src/explain/operator-tokens.ts +310 -0
- package/src/explain/operators.ts +458 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1324 -0
- package/src/explain/quoted-string.ts +241 -0
- package/src/explain/scope-brace.ts +308 -0
- package/src/explain/segment.ts +757 -0
- package/src/explain/string-tokens.ts +67 -0
- package/src/explain/symbol-values.ts +520 -0
- package/src/explain/symbols.ts +1597 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +497 -0
- package/src/explain/value-tokens.ts +80 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +813 -0
- package/src/explain/write.ts +815 -0
- package/src/explain.ts +2152 -0
- package/src/index.ts +307 -1
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +38 -18
- package/src/protocols/index.ts +11 -5
- package/src/protocols/mac-telnet-console.ts +61 -17
- 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 +65 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +391 -0
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +21 -3
- package/src/retrieve-fanout.ts +239 -133
- package/src/retrieve.ts +120 -25
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +544 -0
- package/src/transfer.ts +133 -40
package/src/resolver/target.ts
CHANGED
|
@@ -84,6 +84,7 @@ export function resolveTarget(
|
|
|
84
84
|
env: Record<string, string | undefined>,
|
|
85
85
|
via: RouterOsProtocol,
|
|
86
86
|
cdb?: CdbResolution,
|
|
87
|
+
config: Record<string, string | undefined> = {},
|
|
87
88
|
): ResolvedTarget {
|
|
88
89
|
const hostSetting = resolveStringSetting(
|
|
89
90
|
input.host,
|
|
@@ -91,6 +92,9 @@ export function resolveTarget(
|
|
|
91
92
|
ENV_HOST,
|
|
92
93
|
cdb?.target ?? input.targetInput,
|
|
93
94
|
"host",
|
|
95
|
+
undefined,
|
|
96
|
+
undefined,
|
|
97
|
+
config,
|
|
94
98
|
);
|
|
95
99
|
|
|
96
100
|
if (!hostSetting || hostSetting.value.trim().length === 0) {
|
|
@@ -137,6 +141,9 @@ export function resolveTarget(
|
|
|
137
141
|
ENV_HOST,
|
|
138
142
|
undefined,
|
|
139
143
|
"host",
|
|
144
|
+
undefined,
|
|
145
|
+
undefined,
|
|
146
|
+
config,
|
|
140
147
|
);
|
|
141
148
|
const macPortSetting = resolveOptionalIntegerSetting(
|
|
142
149
|
input.port,
|
|
@@ -144,6 +151,7 @@ export function resolveTarget(
|
|
|
144
151
|
ENV_PORT,
|
|
145
152
|
"port",
|
|
146
153
|
cdb?.overrides.port,
|
|
154
|
+
config,
|
|
147
155
|
);
|
|
148
156
|
// The device identity is the MAC (positional or CDB), NOT --host/CENTRS_HOST,
|
|
149
157
|
// which are only the UDP delivery endpoint. Attribute meta.target.source to
|
|
@@ -217,6 +225,7 @@ export function resolveTarget(
|
|
|
217
225
|
ENV_PORT,
|
|
218
226
|
"port",
|
|
219
227
|
cdb?.overrides.port,
|
|
228
|
+
config,
|
|
220
229
|
);
|
|
221
230
|
const scheme = parsedUrl.protocol === "https:" ? "https" : "http";
|
|
222
231
|
|
|
@@ -320,7 +329,14 @@ export function resolveAuth(
|
|
|
320
329
|
credentials: { username?: string; password?: string; sshKey?: string },
|
|
321
330
|
env: Record<string, string | undefined>,
|
|
322
331
|
cdb?: CdbResolution,
|
|
332
|
+
config: Record<string, string | undefined> = {},
|
|
323
333
|
): ResolvedAuth {
|
|
334
|
+
// `CENTRS_USERNAME`/`CENTRS_PASSWORD` are deliberately NOT threaded through
|
|
335
|
+
// the `config` tier: `settings` refuses to write either (constitution: the
|
|
336
|
+
// `__default__` CDB record is the credential store), and the config-file
|
|
337
|
+
// loader itself does not surface them even when hand-added to the file —
|
|
338
|
+
// a stronger defense than read-time redaction alone, since a hand-edited
|
|
339
|
+
// credential line in `centrs.env` then can't function as a live source.
|
|
324
340
|
const username = resolveStringSetting(
|
|
325
341
|
credentials.username,
|
|
326
342
|
env,
|
|
@@ -328,8 +344,9 @@ export function resolveAuth(
|
|
|
328
344
|
undefined,
|
|
329
345
|
"username",
|
|
330
346
|
);
|
|
331
|
-
// SSH key path: --ssh-key → CENTRS_SSH_KEY → CDB `ssh-key=` comment-kv →
|
|
332
|
-
// Path only; the SFTP/SSH transport reads the file, centrs
|
|
347
|
+
// SSH key path: --ssh-key → CENTRS_SSH_KEY → CDB `ssh-key=` comment-kv →
|
|
348
|
+
// config → unset. Path only; the SFTP/SSH transport reads the file, centrs
|
|
349
|
+
// never stores material.
|
|
333
350
|
const sshKey = resolveStringSetting(
|
|
334
351
|
credentials.sshKey,
|
|
335
352
|
env,
|
|
@@ -338,6 +355,7 @@ export function resolveAuth(
|
|
|
338
355
|
"ssh-key",
|
|
339
356
|
undefined,
|
|
340
357
|
cdb?.overrides.sshKey,
|
|
358
|
+
config,
|
|
341
359
|
);
|
|
342
360
|
const password = resolveStringSetting(
|
|
343
361
|
credentials.password,
|
|
@@ -437,6 +455,6 @@ export function defaultPortForScheme(scheme: "http" | "https"): number {
|
|
|
437
455
|
return scheme === "https" ? 443 : 80;
|
|
438
456
|
}
|
|
439
457
|
|
|
440
|
-
function formatHostForUrl(host: string): string {
|
|
458
|
+
export function formatHostForUrl(host: string): string {
|
|
441
459
|
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
442
460
|
}
|
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 (`expandSelection`); 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
|
|
|
@@ -33,19 +38,24 @@ import type {
|
|
|
33
38
|
FanoutSummary,
|
|
34
39
|
} from "./core/envelope.ts";
|
|
35
40
|
import {
|
|
41
|
+
buildFanoutEnvelope,
|
|
42
|
+
buildFanoutResolveFailure,
|
|
43
|
+
commonVia,
|
|
36
44
|
defaultFanoutSleep,
|
|
37
45
|
resolveFanoutConcurrency,
|
|
38
|
-
|
|
39
|
-
runWithRetry,
|
|
46
|
+
runFanout,
|
|
40
47
|
summarizeFanout,
|
|
41
48
|
} from "./core/fanout.ts";
|
|
42
49
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
43
50
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
44
51
|
import {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
expandSelection,
|
|
53
|
+
isDefaultRecordTarget,
|
|
54
|
+
loadEnvFileDefaults,
|
|
55
|
+
type SelectionExpansion,
|
|
56
|
+
type SelectionMember,
|
|
57
|
+
type SelectionResolveInput,
|
|
58
|
+
type TargetSelection,
|
|
49
59
|
} from "./resolver/index.ts";
|
|
50
60
|
import {
|
|
51
61
|
buildResolvedRetrieve,
|
|
@@ -59,17 +69,29 @@ import {
|
|
|
59
69
|
type RetrieveSuccessEnvelope,
|
|
60
70
|
resolveMacForRetrieve,
|
|
61
71
|
resolveRetrieveGlobalContext,
|
|
72
|
+
resolveRetrieveRequest,
|
|
62
73
|
runResolvedRetrieve,
|
|
63
74
|
toYaml,
|
|
64
75
|
validateRetrieveRequestShape,
|
|
65
76
|
} from "./retrieve.ts";
|
|
66
77
|
|
|
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
|
+
/** quickchr machine names (`--quickchr`, repeatable; exclusive of the rest). */
|
|
86
|
+
quickchr?: readonly string[];
|
|
87
|
+
}
|
|
88
|
+
|
|
67
89
|
export interface RetrieveFanoutOperationMeta {
|
|
68
90
|
kind: "fanout";
|
|
69
|
-
group: string;
|
|
70
91
|
concurrency: number;
|
|
71
92
|
summary: FanoutSummary;
|
|
72
93
|
request: RetrieveRequestSummary;
|
|
94
|
+
selection: RetrieveSelectionSummary;
|
|
73
95
|
}
|
|
74
96
|
|
|
75
97
|
export type RetrieveFanoutData = FanoutData<unknown, RetrieveOperationMeta>;
|
|
@@ -81,60 +103,113 @@ export type RetrieveFanoutErrorEnvelope =
|
|
|
81
103
|
CentrsErrorEnvelope<RetrieveFanoutOperationMeta>;
|
|
82
104
|
|
|
83
105
|
/** Test/override seams; production callers pass nothing. */
|
|
84
|
-
export interface
|
|
106
|
+
export interface RetrieveFanoutInternals {
|
|
85
107
|
/** Per-target executor (attempts; throws on failure). Defaults to the live tail. */
|
|
86
108
|
execute?: (
|
|
87
109
|
resolved: ResolvedRetrieveRequest,
|
|
88
|
-
recordIndex: number,
|
|
89
110
|
) => Promise<RetrieveSuccessEnvelope>;
|
|
90
|
-
/**
|
|
111
|
+
/** Selection expansion. Defaults to the real CDB-backed expansion. */
|
|
91
112
|
expand?: (
|
|
92
|
-
|
|
113
|
+
selection: TargetSelection,
|
|
114
|
+
input: SelectionResolveInput,
|
|
93
115
|
env: Record<string, string | undefined>,
|
|
94
|
-
|
|
116
|
+
config?: Record<string, string | undefined>,
|
|
117
|
+
) => Promise<SelectionExpansion>;
|
|
95
118
|
/** Backoff sleeper. Defaults to a real timer; tests pass a no-op. */
|
|
96
119
|
sleep?: (ms: number) => Promise<void>;
|
|
97
120
|
}
|
|
98
121
|
|
|
122
|
+
export interface RetrieveFanoutOptions {
|
|
123
|
+
concurrency?: number;
|
|
124
|
+
/** CLI: true (literal positionals allowed). MCP: false (CDB is the allowlist). */
|
|
125
|
+
allowAdhoc?: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function summarizeSelection(
|
|
129
|
+
selection: TargetSelection,
|
|
130
|
+
): RetrieveSelectionSummary {
|
|
131
|
+
return {
|
|
132
|
+
groups: selection.groups,
|
|
133
|
+
where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
|
|
134
|
+
all: selection.all,
|
|
135
|
+
default: selection.default,
|
|
136
|
+
positionals: selection.positionals,
|
|
137
|
+
quickchr: selection.quickchr,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function memberTargetMeta(member: SelectionMember): {
|
|
142
|
+
input?: string;
|
|
143
|
+
identity?: string;
|
|
144
|
+
recordIndex?: number;
|
|
145
|
+
} {
|
|
146
|
+
if (member.kind === "cdb") {
|
|
147
|
+
return {
|
|
148
|
+
input: member.resolution.identity,
|
|
149
|
+
identity: member.resolution.identity,
|
|
150
|
+
recordIndex: member.recordIndex,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (member.kind === "quickchr") {
|
|
154
|
+
return { input: member.name, identity: member.name };
|
|
155
|
+
}
|
|
156
|
+
return { input: member.input };
|
|
157
|
+
}
|
|
158
|
+
|
|
99
159
|
/**
|
|
100
|
-
*
|
|
160
|
+
* Ad-hoc literal targets have no CDB record. `resolveRetrieveRequest` may borrow
|
|
161
|
+
* the `__default__` record's index when filling fallback creds, so the inner
|
|
162
|
+
* envelope would carry that borrowed `recordIndex` — making distinct literals
|
|
163
|
+
* collide on one index. Drop it for literal members and stamp the caller's `input`
|
|
164
|
+
* so the target stays identifiable in renderers (mirrors `api-fanout.ts`).
|
|
165
|
+
*/
|
|
166
|
+
function relabelAdhocMember(
|
|
167
|
+
envelope: RetrieveEnvelope,
|
|
168
|
+
member: SelectionMember,
|
|
169
|
+
): RetrieveEnvelope {
|
|
170
|
+
if (member.kind !== "literal") {
|
|
171
|
+
return envelope;
|
|
172
|
+
}
|
|
173
|
+
const { recordIndex: _borrowed, ...target } = envelope.meta.target;
|
|
174
|
+
return {
|
|
175
|
+
...envelope,
|
|
176
|
+
meta: { ...envelope.meta, target: { ...target, input: member.input } },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Run a `retrieve` fan-out. Always returns a success envelope when the
|
|
101
182
|
* orchestration completes (even if every target failed); throws only for
|
|
102
183
|
* pre-flight failures (bad request shape, CDB decrypt) the caller renders as an
|
|
103
184
|
* outer error.
|
|
104
185
|
*/
|
|
105
|
-
export async function
|
|
186
|
+
export async function retrieveFanout(
|
|
106
187
|
request: RetrieveRequest,
|
|
188
|
+
selection: TargetSelection,
|
|
107
189
|
env: Record<string, string | undefined> = Bun.env,
|
|
108
|
-
internals:
|
|
190
|
+
internals: RetrieveFanoutInternals = {},
|
|
191
|
+
options: RetrieveFanoutOptions = {},
|
|
109
192
|
): Promise<RetrieveFanoutEnvelope> {
|
|
110
|
-
const group = request.group;
|
|
111
|
-
if (group === undefined || group.length === 0) {
|
|
112
|
-
throw new CentrsError({
|
|
113
|
-
code: "usage/missing-group",
|
|
114
|
-
summary: "retrieve group fanout requires a non-empty `--group` value.",
|
|
115
|
-
remediation:
|
|
116
|
-
"Pass `--group <name>` matching a CDB group, e.g. `centrs retrieve --group prod /system/resource`.",
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
|
|
120
193
|
const attributeSelections = validateRetrieveRequestShape(request);
|
|
194
|
+
const config = await loadEnvFileDefaults(env);
|
|
121
195
|
const global = resolveRetrieveGlobalContext(
|
|
122
196
|
request,
|
|
123
197
|
env,
|
|
124
198
|
attributeSelections,
|
|
199
|
+
config,
|
|
125
200
|
);
|
|
126
|
-
|
|
127
|
-
resolveFanoutConcurrency(request.concurrency, global.via);
|
|
128
|
-
}
|
|
201
|
+
const selectionSummary = summarizeSelection(selection);
|
|
129
202
|
|
|
130
|
-
const expand = internals.expand ??
|
|
203
|
+
const expand = internals.expand ?? expandSelection;
|
|
131
204
|
const expansion = await expand(
|
|
205
|
+
selection,
|
|
132
206
|
{
|
|
133
|
-
group,
|
|
134
207
|
cdbFile: request.cdbFile,
|
|
135
208
|
cdbPassword: request.cdbPassword,
|
|
209
|
+
allowAdhoc: options.allowAdhoc ?? true,
|
|
136
210
|
},
|
|
137
211
|
env,
|
|
212
|
+
config,
|
|
138
213
|
);
|
|
139
214
|
|
|
140
215
|
const concurrencyVia = resolveFanoutConcurrencyProtocol(
|
|
@@ -144,13 +219,13 @@ export async function retrieveGroup(
|
|
|
144
219
|
expansion,
|
|
145
220
|
);
|
|
146
221
|
const concurrency = resolveFanoutConcurrency(
|
|
147
|
-
request.concurrency,
|
|
222
|
+
options.concurrency ?? request.concurrency,
|
|
148
223
|
concurrencyVia,
|
|
149
224
|
);
|
|
150
225
|
|
|
151
226
|
if (expansion.empty) {
|
|
152
|
-
return
|
|
153
|
-
|
|
227
|
+
return retrieveFanoutEnvelope({
|
|
228
|
+
selection: selectionSummary,
|
|
154
229
|
concurrency,
|
|
155
230
|
summary: { total: 0, ok: 0, failed: 0 },
|
|
156
231
|
requestSummary: global.summary,
|
|
@@ -164,40 +239,76 @@ export async function retrieveGroup(
|
|
|
164
239
|
const execute = internals.execute ?? runResolvedRetrieve;
|
|
165
240
|
const sleep = internals.sleep ?? defaultFanoutSleep;
|
|
166
241
|
|
|
167
|
-
const targets = await
|
|
168
|
-
|
|
242
|
+
const targets = await runFanout<
|
|
243
|
+
SelectionMember,
|
|
244
|
+
ResolvedRetrieveRequest,
|
|
245
|
+
RetrieveEnvelope
|
|
246
|
+
>({
|
|
247
|
+
members: expansion.targets,
|
|
169
248
|
concurrency,
|
|
170
|
-
async (member)
|
|
171
|
-
|
|
172
|
-
|
|
249
|
+
resolve: async (member) => {
|
|
250
|
+
if (member.kind === "cdb") {
|
|
251
|
+
if (isDefaultRecordTarget(member.resolution.target)) {
|
|
252
|
+
throw new CentrsError({
|
|
253
|
+
code: "target/unresolved",
|
|
254
|
+
summary:
|
|
255
|
+
"The `__default__` record is a credential fallback, not a connectable router.",
|
|
256
|
+
remediation:
|
|
257
|
+
"Select real devices with `--group` / `--where` / `--all` or a positional; `--default` cannot be a RouterOS target.",
|
|
258
|
+
context: { target: member.resolution.target },
|
|
259
|
+
});
|
|
260
|
+
}
|
|
173
261
|
const macResolution = await resolveMacForRetrieve(
|
|
174
262
|
request,
|
|
175
263
|
env,
|
|
176
264
|
member.resolution,
|
|
265
|
+
config,
|
|
177
266
|
);
|
|
178
|
-
|
|
267
|
+
return buildResolvedRetrieve(
|
|
179
268
|
request,
|
|
180
269
|
env,
|
|
181
270
|
member.resolution,
|
|
182
271
|
attributeSelections,
|
|
183
272
|
macResolution,
|
|
273
|
+
config,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
if (member.kind === "quickchr") {
|
|
277
|
+
// The live descriptor resolves here, per member — a stopped/unknown
|
|
278
|
+
// machine is an inner per-target failure, not an expansion error.
|
|
279
|
+
return resolveRetrieveRequest(
|
|
280
|
+
{ ...request, targetInput: undefined, quickchr: member.name },
|
|
281
|
+
env,
|
|
184
282
|
);
|
|
185
|
-
} catch (error) {
|
|
186
|
-
return buildResolveFailureEnvelope(member.resolution, error);
|
|
187
283
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
{
|
|
284
|
+
// Ad-hoc literal: full single-target resolution (CDB load → `__default__`
|
|
285
|
+
// fallback creds), mirroring single-target `retrieve`.
|
|
286
|
+
return resolveRetrieveRequest(
|
|
287
|
+
{ ...request, targetInput: member.input },
|
|
288
|
+
env,
|
|
192
289
|
);
|
|
193
290
|
},
|
|
194
|
-
|
|
291
|
+
onResolveError: (member, error) =>
|
|
292
|
+
buildFanoutResolveFailure<RetrieveOperationMeta>({
|
|
293
|
+
error,
|
|
294
|
+
target: memberTargetMeta(member),
|
|
295
|
+
warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
|
|
296
|
+
summary: "Failed to resolve a fanout target.",
|
|
297
|
+
}),
|
|
298
|
+
execute: async (resolved, member) =>
|
|
299
|
+
relabelAdhocMember(await execute(resolved), member),
|
|
300
|
+
onExecuteError: (resolved, member, error) =>
|
|
301
|
+
relabelAdhocMember(
|
|
302
|
+
buildRetrieveErrorEnvelopeFromResolved(resolved, error),
|
|
303
|
+
member,
|
|
304
|
+
),
|
|
305
|
+
sleep,
|
|
306
|
+
});
|
|
195
307
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
group,
|
|
308
|
+
return retrieveFanoutEnvelope({
|
|
309
|
+
selection: selectionSummary,
|
|
199
310
|
concurrency,
|
|
200
|
-
summary,
|
|
311
|
+
summary: summarizeFanout(targets),
|
|
201
312
|
requestSummary: global.summary,
|
|
202
313
|
settings: global.settings,
|
|
203
314
|
via: commonVia(targets),
|
|
@@ -206,6 +317,37 @@ export async function retrieveGroup(
|
|
|
206
317
|
});
|
|
207
318
|
}
|
|
208
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Back-compat shim for the `--group`-only fan-out: builds a single-group
|
|
322
|
+
* {@link TargetSelection} and delegates to {@link retrieveFanout}. Kept for the
|
|
323
|
+
* MCP `centrs_retrieve` tool, whose surface exposes only `group`.
|
|
324
|
+
*/
|
|
325
|
+
export async function retrieveGroup(
|
|
326
|
+
request: RetrieveRequest,
|
|
327
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
328
|
+
internals: RetrieveFanoutInternals = {},
|
|
329
|
+
): Promise<RetrieveFanoutEnvelope> {
|
|
330
|
+
const group = request.group;
|
|
331
|
+
if (group === undefined || group.length === 0) {
|
|
332
|
+
throw new CentrsError({
|
|
333
|
+
code: "usage/missing-group",
|
|
334
|
+
summary: "retrieve group fanout requires a non-empty `--group` value.",
|
|
335
|
+
remediation:
|
|
336
|
+
"Pass `--group <name>` matching a CDB group, e.g. `centrs retrieve --group prod /system/resource`.",
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
const selection: TargetSelection = {
|
|
340
|
+
positionals: [],
|
|
341
|
+
groups: [group],
|
|
342
|
+
all: false,
|
|
343
|
+
default: false,
|
|
344
|
+
where: [],
|
|
345
|
+
};
|
|
346
|
+
return retrieveFanout(request, selection, env, internals, {
|
|
347
|
+
concurrency: request.concurrency,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
209
351
|
/** Outer error envelope for a fanout that failed before per-target results. */
|
|
210
352
|
export function buildRetrieveFanoutErrorEnvelope(
|
|
211
353
|
request: RetrieveRequest,
|
|
@@ -216,7 +358,7 @@ export function buildRetrieveFanoutErrorEnvelope(
|
|
|
216
358
|
? error
|
|
217
359
|
: new CentrsError({
|
|
218
360
|
code: "internal/unhandled",
|
|
219
|
-
summary: "retrieve
|
|
361
|
+
summary: "retrieve fan-out failed with an unexpected error.",
|
|
220
362
|
remediation:
|
|
221
363
|
"Re-run with `--format json` to capture the structured error details for debugging.",
|
|
222
364
|
cause: error,
|
|
@@ -245,14 +387,20 @@ function resolveFanoutConcurrencyProtocol(
|
|
|
245
387
|
request: RetrieveRequest,
|
|
246
388
|
env: Record<string, string | undefined>,
|
|
247
389
|
globalVia: RouterOsProtocol,
|
|
248
|
-
expansion:
|
|
390
|
+
expansion: SelectionExpansion,
|
|
249
391
|
): RouterOsProtocol {
|
|
392
|
+
// Deliberately CLI/env only, not `config` — a `centrs.env` default is the
|
|
393
|
+
// weakest precedence tier and must not be treated as a hard pin the way an
|
|
394
|
+
// explicit `--via`/`CENTRS_VIA` is; doing so would defeat per-target CDB
|
|
395
|
+
// `via=` overrides for members that disagree with the global default.
|
|
250
396
|
if (request.via !== undefined || env["CENTRS_VIA"] !== undefined) {
|
|
251
397
|
return globalVia;
|
|
252
398
|
}
|
|
253
399
|
if (
|
|
254
400
|
expansion.targets.some(
|
|
255
|
-
(
|
|
401
|
+
(member) =>
|
|
402
|
+
member.kind === "cdb" &&
|
|
403
|
+
member.resolution.overrides.via?.value === "native-api",
|
|
256
404
|
)
|
|
257
405
|
) {
|
|
258
406
|
return "native-api";
|
|
@@ -260,37 +408,8 @@ function resolveFanoutConcurrencyProtocol(
|
|
|
260
408
|
return globalVia;
|
|
261
409
|
}
|
|
262
410
|
|
|
263
|
-
function buildResolveFailureEnvelope(
|
|
264
|
-
resolution: CdbResolution,
|
|
265
|
-
error: unknown,
|
|
266
|
-
): RetrieveEnvelope {
|
|
267
|
-
const centrsError =
|
|
268
|
-
error instanceof CentrsError
|
|
269
|
-
? error
|
|
270
|
-
: new CentrsError({
|
|
271
|
-
code: "internal/unhandled",
|
|
272
|
-
summary: "Failed to resolve a fanout target.",
|
|
273
|
-
cause: error,
|
|
274
|
-
});
|
|
275
|
-
return {
|
|
276
|
-
ok: false,
|
|
277
|
-
error: serializeCentrsError(centrsError),
|
|
278
|
-
warnings: [...resolution.warnings],
|
|
279
|
-
tips: [],
|
|
280
|
-
meta: {
|
|
281
|
-
target: {
|
|
282
|
-
input: resolution.identity,
|
|
283
|
-
identity: resolution.identity,
|
|
284
|
-
recordIndex: resolution.recordIndex,
|
|
285
|
-
},
|
|
286
|
-
via: null,
|
|
287
|
-
settings: {},
|
|
288
|
-
},
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
|
|
292
411
|
interface FanoutEnvelopeInput {
|
|
293
|
-
|
|
412
|
+
selection: RetrieveSelectionSummary;
|
|
294
413
|
concurrency: number;
|
|
295
414
|
summary: FanoutSummary;
|
|
296
415
|
requestSummary: RetrieveRequestSummary;
|
|
@@ -300,45 +419,28 @@ interface FanoutEnvelopeInput {
|
|
|
300
419
|
targets: readonly RetrieveEnvelope[];
|
|
301
420
|
}
|
|
302
421
|
|
|
303
|
-
|
|
422
|
+
/** retrieve's outer fan-out success envelope (delegates to the shared core). */
|
|
423
|
+
function retrieveFanoutEnvelope(
|
|
304
424
|
input: FanoutEnvelopeInput,
|
|
305
425
|
): RetrieveFanoutEnvelope {
|
|
306
|
-
return
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
426
|
+
return buildFanoutEnvelope<
|
|
427
|
+
unknown,
|
|
428
|
+
RetrieveOperationMeta,
|
|
429
|
+
RetrieveFanoutOperationMeta
|
|
430
|
+
>({
|
|
431
|
+
summary: input.summary,
|
|
432
|
+
targets: input.targets,
|
|
312
433
|
warnings: input.warnings,
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
concurrency: input.concurrency,
|
|
322
|
-
summary: input.summary,
|
|
323
|
-
request: input.requestSummary,
|
|
324
|
-
},
|
|
434
|
+
settings: input.settings,
|
|
435
|
+
via: input.via,
|
|
436
|
+
operation: {
|
|
437
|
+
kind: "fanout",
|
|
438
|
+
concurrency: input.concurrency,
|
|
439
|
+
summary: input.summary,
|
|
440
|
+
request: input.requestSummary,
|
|
441
|
+
selection: input.selection,
|
|
325
442
|
},
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function commonVia(
|
|
330
|
-
targets: readonly RetrieveEnvelope[],
|
|
331
|
-
): RouterOsProtocol | null {
|
|
332
|
-
let via: RouterOsProtocol | null | undefined;
|
|
333
|
-
for (const target of targets) {
|
|
334
|
-
const candidate = target.meta.via;
|
|
335
|
-
if (via === undefined) {
|
|
336
|
-
via = candidate;
|
|
337
|
-
} else if (via !== candidate) {
|
|
338
|
-
return null;
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
return via ?? null;
|
|
443
|
+
});
|
|
342
444
|
}
|
|
343
445
|
|
|
344
446
|
/**
|
|
@@ -376,12 +478,16 @@ function renderFanoutText(
|
|
|
376
478
|
const operation = envelope.meta.operation;
|
|
377
479
|
const { total, ok, failed } = envelope.data.summary;
|
|
378
480
|
const lines: string[] = [
|
|
379
|
-
`
|
|
481
|
+
`retrieve ${operation?.request.path ?? "?"}: ${ok}/${total} ok, ${failed} failed (concurrency ${operation?.concurrency ?? "?"})`,
|
|
380
482
|
];
|
|
381
483
|
|
|
382
484
|
for (const target of envelope.data.targets) {
|
|
383
485
|
const meta = target.meta;
|
|
384
|
-
const label =
|
|
486
|
+
const label =
|
|
487
|
+
meta.target.identity ??
|
|
488
|
+
meta.target.host ??
|
|
489
|
+
meta.target.input ??
|
|
490
|
+
"(unknown)";
|
|
385
491
|
const index = meta.target.recordIndex ?? "-";
|
|
386
492
|
if (target.ok) {
|
|
387
493
|
lines.push(
|