@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
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-target fan-out for `execute`.
|
|
3
|
+
*
|
|
4
|
+
* A target selection (`--group` / `--where` / `--all` / `--default` / multiple
|
|
5
|
+
* positionals before `--`) expands to N members and runs the SAME RouterOS
|
|
6
|
+
* command on each through the single-target execute tail (`resolveExecuteRequest`
|
|
7
|
+
* → `runResolvedExecute`), collecting per-target inner `ExecuteEnvelope`s into the
|
|
8
|
+
* locked `FanoutData` shape (`src/core/envelope.ts`).
|
|
9
|
+
*
|
|
10
|
+
* Boundaries (see `commands/execute/README.md`, `docs/CONSTITUTION.md`), shared
|
|
11
|
+
* with `api-fanout.ts` / `retrieve-fanout.ts`:
|
|
12
|
+
* - The CDB is loaded ONCE (`expandSelection`); a cdb member is resolved with
|
|
13
|
+
* its pre-resolved record, so `resolveCdb`'s `__default__` synthetic fallback
|
|
14
|
+
* never collides ad-hoc literals.
|
|
15
|
+
* - A `--default` / `__default__` member is guarded from being dialed as the
|
|
16
|
+
* literal hostname `"__default__"` — it fails that one target deterministically
|
|
17
|
+
* with `target/unresolved`.
|
|
18
|
+
* - A **write-shaped** command (add/set/remove) is confirmed ONCE up front
|
|
19
|
+
* (`--yes`), never per target; without `--yes` the error names the blast radius
|
|
20
|
+
* (how many routers). Write-ness is target-independent (`canonicalizeExecuteCommand`).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
CentrsErrorEnvelope,
|
|
25
|
+
CentrsSuccessEnvelope,
|
|
26
|
+
CommonSettingsMeta,
|
|
27
|
+
FanoutData,
|
|
28
|
+
FanoutSummary,
|
|
29
|
+
} from "./core/envelope.ts";
|
|
30
|
+
import {
|
|
31
|
+
buildFanoutEnvelope,
|
|
32
|
+
buildFanoutResolveFailure,
|
|
33
|
+
commonVia,
|
|
34
|
+
defaultFanoutSleep,
|
|
35
|
+
resolveFanoutConcurrency,
|
|
36
|
+
runFanout,
|
|
37
|
+
summarizeFanout,
|
|
38
|
+
} from "./core/fanout.ts";
|
|
39
|
+
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
40
|
+
import {
|
|
41
|
+
buildExecuteErrorEnvelopeFromResolved,
|
|
42
|
+
type ExecuteEnvelope,
|
|
43
|
+
type ExecuteOperationMeta,
|
|
44
|
+
type ExecuteRequest,
|
|
45
|
+
type ExecuteRequestSummary,
|
|
46
|
+
type ExecuteSuccessEnvelope,
|
|
47
|
+
promptForWriteConfirmation,
|
|
48
|
+
type ResolvedExecuteRequest,
|
|
49
|
+
resolveExecuteGlobalContext,
|
|
50
|
+
resolveExecuteRequest,
|
|
51
|
+
runResolvedExecute,
|
|
52
|
+
} from "./execute.ts";
|
|
53
|
+
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
54
|
+
import {
|
|
55
|
+
expandSelection,
|
|
56
|
+
isDefaultRecordTarget,
|
|
57
|
+
loadEnvFileDefaults,
|
|
58
|
+
type SelectionExpansion,
|
|
59
|
+
type SelectionMember,
|
|
60
|
+
type SelectionResolveInput,
|
|
61
|
+
type TargetSelection,
|
|
62
|
+
} from "./resolver/index.ts";
|
|
63
|
+
import { toYaml } from "./retrieve.ts";
|
|
64
|
+
|
|
65
|
+
/** Compact description of the selector that produced the fan-out. */
|
|
66
|
+
export interface ExecuteSelectionSummary {
|
|
67
|
+
groups: readonly string[];
|
|
68
|
+
where: readonly string[];
|
|
69
|
+
all: boolean;
|
|
70
|
+
default: boolean;
|
|
71
|
+
positionals: readonly string[];
|
|
72
|
+
/** quickchr machine names (`--quickchr`, repeatable; exclusive of the rest). */
|
|
73
|
+
quickchr?: readonly string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ExecuteFanoutOperationMeta {
|
|
77
|
+
kind: "fanout";
|
|
78
|
+
concurrency: number;
|
|
79
|
+
summary: FanoutSummary;
|
|
80
|
+
request: ExecuteRequestSummary;
|
|
81
|
+
selection: ExecuteSelectionSummary;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type ExecuteFanoutData = FanoutData<unknown, ExecuteOperationMeta>;
|
|
85
|
+
export type ExecuteFanoutEnvelope = CentrsSuccessEnvelope<
|
|
86
|
+
ExecuteFanoutData,
|
|
87
|
+
ExecuteFanoutOperationMeta
|
|
88
|
+
>;
|
|
89
|
+
export type ExecuteFanoutErrorEnvelope =
|
|
90
|
+
CentrsErrorEnvelope<ExecuteFanoutOperationMeta>;
|
|
91
|
+
|
|
92
|
+
/** Test/override seams; production callers pass nothing. */
|
|
93
|
+
export interface ExecuteFanoutInternals {
|
|
94
|
+
expand?: (
|
|
95
|
+
selection: TargetSelection,
|
|
96
|
+
input: SelectionResolveInput,
|
|
97
|
+
env: Record<string, string | undefined>,
|
|
98
|
+
config?: Record<string, string | undefined>,
|
|
99
|
+
) => Promise<SelectionExpansion>;
|
|
100
|
+
execute?: (
|
|
101
|
+
resolved: ResolvedExecuteRequest,
|
|
102
|
+
) => Promise<ExecuteSuccessEnvelope>;
|
|
103
|
+
sleep?: (ms: number) => Promise<void>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ExecuteFanoutOptions {
|
|
107
|
+
concurrency?: number;
|
|
108
|
+
/** CLI: true (literal positionals allowed). MCP: false (CDB is the allowlist). */
|
|
109
|
+
allowAdhoc?: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function summarizeSelection(
|
|
113
|
+
selection: TargetSelection,
|
|
114
|
+
): ExecuteSelectionSummary {
|
|
115
|
+
return {
|
|
116
|
+
groups: selection.groups,
|
|
117
|
+
where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
|
|
118
|
+
all: selection.all,
|
|
119
|
+
default: selection.default,
|
|
120
|
+
positionals: selection.positionals,
|
|
121
|
+
quickchr: selection.quickchr,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Representative transport for the concurrency default (pinned > native member > global). */
|
|
126
|
+
function fanoutVia(
|
|
127
|
+
request: ExecuteRequest,
|
|
128
|
+
env: Record<string, string | undefined>,
|
|
129
|
+
globalVia: RouterOsProtocol,
|
|
130
|
+
expansion: SelectionExpansion,
|
|
131
|
+
): RouterOsProtocol {
|
|
132
|
+
// Deliberately CLI/env only, not `config` — see the identical note in
|
|
133
|
+
// `retrieve-fanout.ts`'s `resolveFanoutConcurrencyProtocol`.
|
|
134
|
+
if (request.via !== undefined || env["CENTRS_VIA"] !== undefined) {
|
|
135
|
+
return globalVia;
|
|
136
|
+
}
|
|
137
|
+
const anyNative = expansion.targets.some(
|
|
138
|
+
(member) =>
|
|
139
|
+
member.kind === "cdb" &&
|
|
140
|
+
member.resolution.overrides.via?.value === "native-api",
|
|
141
|
+
);
|
|
142
|
+
return anyNative ? "native-api" : globalVia;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function memberTargetMeta(member: SelectionMember): {
|
|
146
|
+
input?: string;
|
|
147
|
+
identity?: string;
|
|
148
|
+
recordIndex?: number;
|
|
149
|
+
} {
|
|
150
|
+
if (member.kind === "cdb") {
|
|
151
|
+
return {
|
|
152
|
+
input: member.resolution.identity,
|
|
153
|
+
identity: member.resolution.identity,
|
|
154
|
+
recordIndex: member.recordIndex,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (member.kind === "quickchr") {
|
|
158
|
+
return { input: member.name, identity: member.name };
|
|
159
|
+
}
|
|
160
|
+
return { input: member.input };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Ad-hoc literal targets have no CDB record. `resolveExecuteRequest` may borrow the
|
|
165
|
+
* `__default__` record's index when filling fallback creds, so the inner envelope
|
|
166
|
+
* would carry that borrowed `recordIndex` — making distinct literals collide on one
|
|
167
|
+
* index. Drop it for literal members and stamp the caller's `input` so the target
|
|
168
|
+
* stays identifiable in renderers (mirrors `api-fanout.ts`).
|
|
169
|
+
*/
|
|
170
|
+
function relabelAdhocMember(
|
|
171
|
+
envelope: ExecuteEnvelope,
|
|
172
|
+
member: SelectionMember,
|
|
173
|
+
): ExecuteEnvelope {
|
|
174
|
+
if (member.kind !== "literal") {
|
|
175
|
+
return envelope;
|
|
176
|
+
}
|
|
177
|
+
const { recordIndex: _borrowed, ...target } = envelope.meta.target;
|
|
178
|
+
return {
|
|
179
|
+
...envelope,
|
|
180
|
+
meta: { ...envelope.meta, target: { ...target, input: member.input } },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Run an `execute` fan-out. Returns a success envelope when the orchestration
|
|
186
|
+
* completes (even if every target failed); throws only for pre-flight failures
|
|
187
|
+
* (invalid command shape, CDB decrypt, unconfirmed write) the caller renders as
|
|
188
|
+
* an outer error via {@link buildExecuteFanoutErrorEnvelope}.
|
|
189
|
+
*/
|
|
190
|
+
export async function executeFanout(
|
|
191
|
+
request: ExecuteRequest,
|
|
192
|
+
selection: TargetSelection,
|
|
193
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
194
|
+
internals: ExecuteFanoutInternals = {},
|
|
195
|
+
options: ExecuteFanoutOptions = {},
|
|
196
|
+
): Promise<ExecuteFanoutEnvelope> {
|
|
197
|
+
// Validate + canonicalize the command ONCE, before selection expansion: a bad
|
|
198
|
+
// command shape is an outer error regardless of how many targets resolve, and
|
|
199
|
+
// write-ness is target-independent.
|
|
200
|
+
const config = await loadEnvFileDefaults(env);
|
|
201
|
+
const global = resolveExecuteGlobalContext(request, env, config);
|
|
202
|
+
const selectionSummary = summarizeSelection(selection);
|
|
203
|
+
|
|
204
|
+
const expand = internals.expand ?? expandSelection;
|
|
205
|
+
const expansion = await expand(
|
|
206
|
+
selection,
|
|
207
|
+
{
|
|
208
|
+
cdbFile: request.cdbFile,
|
|
209
|
+
cdbPassword: request.cdbPassword,
|
|
210
|
+
allowAdhoc: options.allowAdhoc ?? true,
|
|
211
|
+
},
|
|
212
|
+
env,
|
|
213
|
+
config,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const via = fanoutVia(request, env, global.via, expansion);
|
|
217
|
+
const concurrency = resolveFanoutConcurrency(options.concurrency, via);
|
|
218
|
+
|
|
219
|
+
if (expansion.empty) {
|
|
220
|
+
return executeFanoutEnvelope({
|
|
221
|
+
selection: selectionSummary,
|
|
222
|
+
concurrency,
|
|
223
|
+
summary: { total: 0, ok: 0, failed: 0 },
|
|
224
|
+
requestSummary: global.summary,
|
|
225
|
+
settings: global.settings,
|
|
226
|
+
via: null,
|
|
227
|
+
warnings: expansion.warnings,
|
|
228
|
+
targets: [],
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Write-shaped fan-out: confirm ONCE up front, naming the blast radius.
|
|
233
|
+
await assertFanoutWriteConfirmed(request, global.summary, expansion);
|
|
234
|
+
|
|
235
|
+
const execute = internals.execute ?? runResolvedExecute;
|
|
236
|
+
const sleep = internals.sleep ?? defaultFanoutSleep;
|
|
237
|
+
|
|
238
|
+
const targets = await runFanout<
|
|
239
|
+
SelectionMember,
|
|
240
|
+
ResolvedExecuteRequest,
|
|
241
|
+
ExecuteEnvelope
|
|
242
|
+
>({
|
|
243
|
+
members: expansion.targets,
|
|
244
|
+
concurrency,
|
|
245
|
+
resolve: (member) => {
|
|
246
|
+
if (member.kind === "cdb") {
|
|
247
|
+
if (isDefaultRecordTarget(member.resolution.target)) {
|
|
248
|
+
throw new CentrsError({
|
|
249
|
+
code: "target/unresolved",
|
|
250
|
+
summary:
|
|
251
|
+
"The `__default__` record is a credential fallback, not a connectable router.",
|
|
252
|
+
remediation:
|
|
253
|
+
"Select real devices with `--group` / `--where` / `--all` or a positional; `--default` cannot be a RouterOS target.",
|
|
254
|
+
context: { target: member.resolution.target },
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return resolveExecuteRequest(
|
|
258
|
+
{ ...request, targetInput: member.resolution.target },
|
|
259
|
+
env,
|
|
260
|
+
{ cdbResolution: member.resolution, config },
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (member.kind === "quickchr") {
|
|
264
|
+
// The live descriptor resolves here, per member — a stopped/unknown
|
|
265
|
+
// machine is an inner per-target failure, not an expansion error.
|
|
266
|
+
return resolveExecuteRequest(
|
|
267
|
+
{ ...request, targetInput: undefined, quickchr: member.name },
|
|
268
|
+
env,
|
|
269
|
+
{ config },
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return resolveExecuteRequest(
|
|
273
|
+
{ ...request, targetInput: member.input },
|
|
274
|
+
env,
|
|
275
|
+
{ config },
|
|
276
|
+
);
|
|
277
|
+
},
|
|
278
|
+
onResolveError: (member, error) =>
|
|
279
|
+
buildFanoutResolveFailure<ExecuteOperationMeta>({
|
|
280
|
+
error,
|
|
281
|
+
target: memberTargetMeta(member),
|
|
282
|
+
warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
|
|
283
|
+
summary: "Failed to resolve a fanout target.",
|
|
284
|
+
}),
|
|
285
|
+
execute: async (resolved, member) =>
|
|
286
|
+
relabelAdhocMember(await execute(resolved), member),
|
|
287
|
+
onExecuteError: (resolved, member, error) =>
|
|
288
|
+
relabelAdhocMember(
|
|
289
|
+
buildExecuteErrorEnvelopeFromResolved(resolved, error),
|
|
290
|
+
member,
|
|
291
|
+
),
|
|
292
|
+
sleep,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
return executeFanoutEnvelope({
|
|
296
|
+
selection: selectionSummary,
|
|
297
|
+
concurrency,
|
|
298
|
+
summary: summarizeFanout(targets),
|
|
299
|
+
requestSummary: global.summary,
|
|
300
|
+
settings: global.settings,
|
|
301
|
+
via: commonVia(targets),
|
|
302
|
+
warnings: expansion.warnings,
|
|
303
|
+
targets,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function assertFanoutWriteConfirmed(
|
|
308
|
+
request: ExecuteRequest,
|
|
309
|
+
summary: ExecuteRequestSummary,
|
|
310
|
+
expansion: SelectionExpansion,
|
|
311
|
+
): Promise<void> {
|
|
312
|
+
if (!summary.write || request.yes) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const count = expansion.targets.length;
|
|
316
|
+
const prompt = `Run write-shaped \`${summary.command}\` across ${count} router(s)? Type yes to continue: `;
|
|
317
|
+
if (request.stdinIsTty ?? process.stdin.isTTY) {
|
|
318
|
+
const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
|
|
319
|
+
prompt,
|
|
320
|
+
);
|
|
321
|
+
if (confirmed) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
throw new CentrsError({
|
|
326
|
+
code: "usage/confirmation-required",
|
|
327
|
+
summary: `A write-shaped \`${summary.command}\` would run across ${count} router(s) and needs explicit confirmation.`,
|
|
328
|
+
remediation:
|
|
329
|
+
"Pass `--yes` to fan the write out to every selected router, or answer `yes` at the TTY prompt after reviewing the blast radius.",
|
|
330
|
+
context: { command: summary.command, routers: count },
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
interface ExecuteFanoutEnvelopeInput {
|
|
335
|
+
selection: ExecuteSelectionSummary;
|
|
336
|
+
concurrency: number;
|
|
337
|
+
summary: FanoutSummary;
|
|
338
|
+
requestSummary: ExecuteRequestSummary;
|
|
339
|
+
settings: CommonSettingsMeta;
|
|
340
|
+
via: RouterOsProtocol | null;
|
|
341
|
+
warnings: SelectionExpansion["warnings"];
|
|
342
|
+
targets: readonly ExecuteEnvelope[];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function executeFanoutEnvelope(
|
|
346
|
+
input: ExecuteFanoutEnvelopeInput,
|
|
347
|
+
): ExecuteFanoutEnvelope {
|
|
348
|
+
return buildFanoutEnvelope<
|
|
349
|
+
unknown,
|
|
350
|
+
ExecuteOperationMeta,
|
|
351
|
+
ExecuteFanoutOperationMeta
|
|
352
|
+
>({
|
|
353
|
+
summary: input.summary,
|
|
354
|
+
targets: input.targets,
|
|
355
|
+
warnings: input.warnings,
|
|
356
|
+
settings: input.settings,
|
|
357
|
+
via: input.via,
|
|
358
|
+
operation: {
|
|
359
|
+
kind: "fanout",
|
|
360
|
+
concurrency: input.concurrency,
|
|
361
|
+
summary: input.summary,
|
|
362
|
+
request: input.requestSummary,
|
|
363
|
+
selection: input.selection,
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Outer error envelope for a fan-out that failed before per-target results. */
|
|
369
|
+
export function buildExecuteFanoutErrorEnvelope(
|
|
370
|
+
request: ExecuteRequest,
|
|
371
|
+
error: unknown,
|
|
372
|
+
): ExecuteFanoutErrorEnvelope {
|
|
373
|
+
const centrsError =
|
|
374
|
+
error instanceof CentrsError
|
|
375
|
+
? error
|
|
376
|
+
: new CentrsError({
|
|
377
|
+
code: "internal/unhandled",
|
|
378
|
+
summary: "execute fan-out failed with an unexpected error.",
|
|
379
|
+
remediation:
|
|
380
|
+
"Re-run with `--format json` to capture the structured error details.",
|
|
381
|
+
cause: error,
|
|
382
|
+
});
|
|
383
|
+
const requestedVia = plannedProtocols.includes(
|
|
384
|
+
request.via as RouterOsProtocol,
|
|
385
|
+
)
|
|
386
|
+
? (request.via as RouterOsProtocol)
|
|
387
|
+
: null;
|
|
388
|
+
return {
|
|
389
|
+
ok: false,
|
|
390
|
+
error: serializeCentrsError(centrsError),
|
|
391
|
+
warnings: [],
|
|
392
|
+
tips: [],
|
|
393
|
+
meta: {
|
|
394
|
+
target: {},
|
|
395
|
+
via: requestedVia,
|
|
396
|
+
settings: {},
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Render an execute fan-out envelope. `json`/`yaml` serialize the whole envelope;
|
|
403
|
+
* `text` renders a summary line plus one line per target in record-index order.
|
|
404
|
+
*/
|
|
405
|
+
export function renderExecuteFanoutEnvelope(
|
|
406
|
+
envelope: ExecuteFanoutEnvelope | ExecuteFanoutErrorEnvelope,
|
|
407
|
+
format: "json" | "yaml" | "text",
|
|
408
|
+
options: { verbose?: boolean } = {},
|
|
409
|
+
): string {
|
|
410
|
+
if (format === "json") {
|
|
411
|
+
return JSON.stringify(envelope, null, 2);
|
|
412
|
+
}
|
|
413
|
+
if (format === "yaml") {
|
|
414
|
+
return toYaml(envelope);
|
|
415
|
+
}
|
|
416
|
+
if (!envelope.ok) {
|
|
417
|
+
const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
|
|
418
|
+
if (envelope.error.remediation) {
|
|
419
|
+
lines.push(`Fix: ${envelope.error.remediation}`);
|
|
420
|
+
}
|
|
421
|
+
return lines.join("\n");
|
|
422
|
+
}
|
|
423
|
+
const operation = envelope.meta.operation;
|
|
424
|
+
const { total, ok, failed } = envelope.data.summary;
|
|
425
|
+
const lines: string[] = [
|
|
426
|
+
`execute ${operation?.request.command ?? "?"}: ${ok}/${total} ok, ${failed} failed (concurrency ${operation?.concurrency ?? "?"})`,
|
|
427
|
+
];
|
|
428
|
+
for (const target of envelope.data.targets) {
|
|
429
|
+
const meta = target.meta;
|
|
430
|
+
const label =
|
|
431
|
+
meta.target.identity ??
|
|
432
|
+
meta.target.host ??
|
|
433
|
+
meta.target.input ??
|
|
434
|
+
"(unknown)";
|
|
435
|
+
const index = meta.target.recordIndex ?? "-";
|
|
436
|
+
if (target.ok) {
|
|
437
|
+
lines.push(
|
|
438
|
+
` [${index}] ok ${label} (${target.meta.operation?.objectCount ?? 0} object(s))`,
|
|
439
|
+
);
|
|
440
|
+
} else {
|
|
441
|
+
lines.push(
|
|
442
|
+
` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
|
|
443
|
+
);
|
|
444
|
+
if (options.verbose && target.error.remediation) {
|
|
445
|
+
lines.push(` Fix: ${target.error.remediation}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
for (const warning of envelope.warnings) {
|
|
450
|
+
lines.push(`warning [${warning.code}]: ${warning.message}`);
|
|
451
|
+
}
|
|
452
|
+
return lines.join("\n");
|
|
453
|
+
}
|