@tikoci/centrs 0.1.2 → 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 +59 -0
- package/GLOSSARY.txt +71 -0
- package/README.md +24 -8
- package/SECURITY.md +3 -3
- package/commands/api/README.md +27 -9
- package/commands/api/examples.md +109 -6
- 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 +39 -4
- package/commands/execute/examples.md +90 -6
- package/commands/retrieve/README.md +36 -50
- 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 +36 -11
- package/docs/MATRIX.md +68 -172
- 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/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/package.json +6 -6
- package/src/api-fanout.ts +454 -0
- package/src/api.ts +91 -19
- package/src/cli/api.ts +135 -11
- package/src/cli/btest.ts +20 -2
- 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 +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 +7 -0
- package/src/core/error-catalog.ts +55 -0
- package/src/core/fanout.ts +143 -2
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/execute-fanout.ts +438 -0
- package/src/execute.ts +120 -23
- package/src/index.ts +105 -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/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 +225 -133
- package/src/retrieve.ts +39 -7
- 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
|
@@ -0,0 +1,438 @@
|
|
|
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 (`expandCdbSelection`); 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
|
+
type CdbSelectionExpansion,
|
|
56
|
+
type CdbSelectionMember,
|
|
57
|
+
type CdbSelectionResolveInput,
|
|
58
|
+
expandCdbSelection,
|
|
59
|
+
isDefaultRecordTarget,
|
|
60
|
+
loadEnvFileDefaults,
|
|
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
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ExecuteFanoutOperationMeta {
|
|
75
|
+
kind: "fanout";
|
|
76
|
+
concurrency: number;
|
|
77
|
+
summary: FanoutSummary;
|
|
78
|
+
request: ExecuteRequestSummary;
|
|
79
|
+
selection: ExecuteSelectionSummary;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type ExecuteFanoutData = FanoutData<unknown, ExecuteOperationMeta>;
|
|
83
|
+
export type ExecuteFanoutEnvelope = CentrsSuccessEnvelope<
|
|
84
|
+
ExecuteFanoutData,
|
|
85
|
+
ExecuteFanoutOperationMeta
|
|
86
|
+
>;
|
|
87
|
+
export type ExecuteFanoutErrorEnvelope =
|
|
88
|
+
CentrsErrorEnvelope<ExecuteFanoutOperationMeta>;
|
|
89
|
+
|
|
90
|
+
/** Test/override seams; production callers pass nothing. */
|
|
91
|
+
export interface ExecuteFanoutInternals {
|
|
92
|
+
expand?: (
|
|
93
|
+
selection: TargetSelection,
|
|
94
|
+
input: CdbSelectionResolveInput,
|
|
95
|
+
env: Record<string, string | undefined>,
|
|
96
|
+
config?: Record<string, string | undefined>,
|
|
97
|
+
) => Promise<CdbSelectionExpansion>;
|
|
98
|
+
execute?: (
|
|
99
|
+
resolved: ResolvedExecuteRequest,
|
|
100
|
+
) => Promise<ExecuteSuccessEnvelope>;
|
|
101
|
+
sleep?: (ms: number) => Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ExecuteFanoutOptions {
|
|
105
|
+
concurrency?: number;
|
|
106
|
+
/** CLI: true (literal positionals allowed). MCP: false (CDB is the allowlist). */
|
|
107
|
+
allowAdhoc?: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function summarizeSelection(
|
|
111
|
+
selection: TargetSelection,
|
|
112
|
+
): ExecuteSelectionSummary {
|
|
113
|
+
return {
|
|
114
|
+
groups: selection.groups,
|
|
115
|
+
where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
|
|
116
|
+
all: selection.all,
|
|
117
|
+
default: selection.default,
|
|
118
|
+
positionals: selection.positionals,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Representative transport for the concurrency default (pinned > native member > global). */
|
|
123
|
+
function fanoutVia(
|
|
124
|
+
request: ExecuteRequest,
|
|
125
|
+
env: Record<string, string | undefined>,
|
|
126
|
+
globalVia: RouterOsProtocol,
|
|
127
|
+
expansion: CdbSelectionExpansion,
|
|
128
|
+
): RouterOsProtocol {
|
|
129
|
+
// Deliberately CLI/env only, not `config` — see the identical note in
|
|
130
|
+
// `retrieve-fanout.ts`'s `resolveFanoutConcurrencyProtocol`.
|
|
131
|
+
if (request.via !== undefined || env["CENTRS_VIA"] !== undefined) {
|
|
132
|
+
return globalVia;
|
|
133
|
+
}
|
|
134
|
+
const anyNative = expansion.targets.some(
|
|
135
|
+
(member) =>
|
|
136
|
+
member.kind === "cdb" &&
|
|
137
|
+
member.resolution.overrides.via?.value === "native-api",
|
|
138
|
+
);
|
|
139
|
+
return anyNative ? "native-api" : globalVia;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function memberTargetMeta(member: CdbSelectionMember): {
|
|
143
|
+
input?: string;
|
|
144
|
+
identity?: string;
|
|
145
|
+
recordIndex?: number;
|
|
146
|
+
} {
|
|
147
|
+
if (member.kind === "cdb") {
|
|
148
|
+
return {
|
|
149
|
+
input: member.resolution.identity,
|
|
150
|
+
identity: member.resolution.identity,
|
|
151
|
+
recordIndex: member.recordIndex,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return { input: member.input };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Ad-hoc literal targets have no CDB record. `resolveExecuteRequest` may borrow the
|
|
159
|
+
* `__default__` record's index when filling fallback creds, so the inner envelope
|
|
160
|
+
* would carry that borrowed `recordIndex` — making distinct literals collide on one
|
|
161
|
+
* index. Drop it for literal members and stamp the caller's `input` so the target
|
|
162
|
+
* stays identifiable in renderers (mirrors `api-fanout.ts`).
|
|
163
|
+
*/
|
|
164
|
+
function relabelAdhocMember(
|
|
165
|
+
envelope: ExecuteEnvelope,
|
|
166
|
+
member: CdbSelectionMember,
|
|
167
|
+
): ExecuteEnvelope {
|
|
168
|
+
if (member.kind !== "literal") {
|
|
169
|
+
return envelope;
|
|
170
|
+
}
|
|
171
|
+
const { recordIndex: _borrowed, ...target } = envelope.meta.target;
|
|
172
|
+
return {
|
|
173
|
+
...envelope,
|
|
174
|
+
meta: { ...envelope.meta, target: { ...target, input: member.input } },
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Run an `execute` fan-out. Returns a success envelope when the orchestration
|
|
180
|
+
* completes (even if every target failed); throws only for pre-flight failures
|
|
181
|
+
* (invalid command shape, CDB decrypt, unconfirmed write) the caller renders as
|
|
182
|
+
* an outer error via {@link buildExecuteFanoutErrorEnvelope}.
|
|
183
|
+
*/
|
|
184
|
+
export async function executeFanout(
|
|
185
|
+
request: ExecuteRequest,
|
|
186
|
+
selection: TargetSelection,
|
|
187
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
188
|
+
internals: ExecuteFanoutInternals = {},
|
|
189
|
+
options: ExecuteFanoutOptions = {},
|
|
190
|
+
): Promise<ExecuteFanoutEnvelope> {
|
|
191
|
+
// Validate + canonicalize the command ONCE, before selection expansion: a bad
|
|
192
|
+
// command shape is an outer error regardless of how many targets resolve, and
|
|
193
|
+
// write-ness is target-independent.
|
|
194
|
+
const config = await loadEnvFileDefaults(env);
|
|
195
|
+
const global = resolveExecuteGlobalContext(request, env, config);
|
|
196
|
+
const selectionSummary = summarizeSelection(selection);
|
|
197
|
+
|
|
198
|
+
const expand = internals.expand ?? expandCdbSelection;
|
|
199
|
+
const expansion = await expand(
|
|
200
|
+
selection,
|
|
201
|
+
{
|
|
202
|
+
cdbFile: request.cdbFile,
|
|
203
|
+
cdbPassword: request.cdbPassword,
|
|
204
|
+
allowAdhoc: options.allowAdhoc ?? true,
|
|
205
|
+
},
|
|
206
|
+
env,
|
|
207
|
+
config,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const via = fanoutVia(request, env, global.via, expansion);
|
|
211
|
+
const concurrency = resolveFanoutConcurrency(options.concurrency, via);
|
|
212
|
+
|
|
213
|
+
if (expansion.empty) {
|
|
214
|
+
return executeFanoutEnvelope({
|
|
215
|
+
selection: selectionSummary,
|
|
216
|
+
concurrency,
|
|
217
|
+
summary: { total: 0, ok: 0, failed: 0 },
|
|
218
|
+
requestSummary: global.summary,
|
|
219
|
+
settings: global.settings,
|
|
220
|
+
via: null,
|
|
221
|
+
warnings: expansion.warnings,
|
|
222
|
+
targets: [],
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Write-shaped fan-out: confirm ONCE up front, naming the blast radius.
|
|
227
|
+
await assertFanoutWriteConfirmed(request, global.summary, expansion);
|
|
228
|
+
|
|
229
|
+
const execute = internals.execute ?? runResolvedExecute;
|
|
230
|
+
const sleep = internals.sleep ?? defaultFanoutSleep;
|
|
231
|
+
|
|
232
|
+
const targets = await runFanout<
|
|
233
|
+
CdbSelectionMember,
|
|
234
|
+
ResolvedExecuteRequest,
|
|
235
|
+
ExecuteEnvelope
|
|
236
|
+
>({
|
|
237
|
+
members: expansion.targets,
|
|
238
|
+
concurrency,
|
|
239
|
+
resolve: (member) => {
|
|
240
|
+
if (member.kind === "cdb") {
|
|
241
|
+
if (isDefaultRecordTarget(member.resolution.target)) {
|
|
242
|
+
throw new CentrsError({
|
|
243
|
+
code: "target/unresolved",
|
|
244
|
+
summary:
|
|
245
|
+
"The `__default__` record is a credential fallback, not a connectable router.",
|
|
246
|
+
remediation:
|
|
247
|
+
"Select real devices with `--group` / `--where` / `--all` or a positional; `--default` cannot be a RouterOS target.",
|
|
248
|
+
context: { target: member.resolution.target },
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return resolveExecuteRequest(
|
|
252
|
+
{ ...request, targetInput: member.resolution.target },
|
|
253
|
+
env,
|
|
254
|
+
{ cdbResolution: member.resolution, config },
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return resolveExecuteRequest(
|
|
258
|
+
{ ...request, targetInput: member.input },
|
|
259
|
+
env,
|
|
260
|
+
{ config },
|
|
261
|
+
);
|
|
262
|
+
},
|
|
263
|
+
onResolveError: (member, error) =>
|
|
264
|
+
buildFanoutResolveFailure<ExecuteOperationMeta>({
|
|
265
|
+
error,
|
|
266
|
+
target: memberTargetMeta(member),
|
|
267
|
+
warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
|
|
268
|
+
summary: "Failed to resolve a fanout target.",
|
|
269
|
+
}),
|
|
270
|
+
execute: async (resolved, member) =>
|
|
271
|
+
relabelAdhocMember(await execute(resolved), member),
|
|
272
|
+
onExecuteError: (resolved, member, error) =>
|
|
273
|
+
relabelAdhocMember(
|
|
274
|
+
buildExecuteErrorEnvelopeFromResolved(resolved, error),
|
|
275
|
+
member,
|
|
276
|
+
),
|
|
277
|
+
sleep,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
return executeFanoutEnvelope({
|
|
281
|
+
selection: selectionSummary,
|
|
282
|
+
concurrency,
|
|
283
|
+
summary: summarizeFanout(targets),
|
|
284
|
+
requestSummary: global.summary,
|
|
285
|
+
settings: global.settings,
|
|
286
|
+
via: commonVia(targets),
|
|
287
|
+
warnings: expansion.warnings,
|
|
288
|
+
targets,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function assertFanoutWriteConfirmed(
|
|
293
|
+
request: ExecuteRequest,
|
|
294
|
+
summary: ExecuteRequestSummary,
|
|
295
|
+
expansion: CdbSelectionExpansion,
|
|
296
|
+
): Promise<void> {
|
|
297
|
+
if (!summary.write || request.yes) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const count = expansion.targets.length;
|
|
301
|
+
const prompt = `Run write-shaped \`${summary.command}\` across ${count} router(s)? Type yes to continue: `;
|
|
302
|
+
if (request.stdinIsTty ?? process.stdin.isTTY) {
|
|
303
|
+
const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
|
|
304
|
+
prompt,
|
|
305
|
+
);
|
|
306
|
+
if (confirmed) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
throw new CentrsError({
|
|
311
|
+
code: "usage/confirmation-required",
|
|
312
|
+
summary: `A write-shaped \`${summary.command}\` would run across ${count} router(s) and needs explicit confirmation.`,
|
|
313
|
+
remediation:
|
|
314
|
+
"Pass `--yes` to fan the write out to every selected router, or answer `yes` at the TTY prompt after reviewing the blast radius.",
|
|
315
|
+
context: { command: summary.command, routers: count },
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
interface ExecuteFanoutEnvelopeInput {
|
|
320
|
+
selection: ExecuteSelectionSummary;
|
|
321
|
+
concurrency: number;
|
|
322
|
+
summary: FanoutSummary;
|
|
323
|
+
requestSummary: ExecuteRequestSummary;
|
|
324
|
+
settings: CommonSettingsMeta;
|
|
325
|
+
via: RouterOsProtocol | null;
|
|
326
|
+
warnings: CdbSelectionExpansion["warnings"];
|
|
327
|
+
targets: readonly ExecuteEnvelope[];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function executeFanoutEnvelope(
|
|
331
|
+
input: ExecuteFanoutEnvelopeInput,
|
|
332
|
+
): ExecuteFanoutEnvelope {
|
|
333
|
+
return buildFanoutEnvelope<
|
|
334
|
+
unknown,
|
|
335
|
+
ExecuteOperationMeta,
|
|
336
|
+
ExecuteFanoutOperationMeta
|
|
337
|
+
>({
|
|
338
|
+
summary: input.summary,
|
|
339
|
+
targets: input.targets,
|
|
340
|
+
warnings: input.warnings,
|
|
341
|
+
settings: input.settings,
|
|
342
|
+
via: input.via,
|
|
343
|
+
operation: {
|
|
344
|
+
kind: "fanout",
|
|
345
|
+
concurrency: input.concurrency,
|
|
346
|
+
summary: input.summary,
|
|
347
|
+
request: input.requestSummary,
|
|
348
|
+
selection: input.selection,
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Outer error envelope for a fan-out that failed before per-target results. */
|
|
354
|
+
export function buildExecuteFanoutErrorEnvelope(
|
|
355
|
+
request: ExecuteRequest,
|
|
356
|
+
error: unknown,
|
|
357
|
+
): ExecuteFanoutErrorEnvelope {
|
|
358
|
+
const centrsError =
|
|
359
|
+
error instanceof CentrsError
|
|
360
|
+
? error
|
|
361
|
+
: new CentrsError({
|
|
362
|
+
code: "internal/unhandled",
|
|
363
|
+
summary: "execute fan-out failed with an unexpected error.",
|
|
364
|
+
remediation:
|
|
365
|
+
"Re-run with `--format json` to capture the structured error details.",
|
|
366
|
+
cause: error,
|
|
367
|
+
});
|
|
368
|
+
const requestedVia = plannedProtocols.includes(
|
|
369
|
+
request.via as RouterOsProtocol,
|
|
370
|
+
)
|
|
371
|
+
? (request.via as RouterOsProtocol)
|
|
372
|
+
: null;
|
|
373
|
+
return {
|
|
374
|
+
ok: false,
|
|
375
|
+
error: serializeCentrsError(centrsError),
|
|
376
|
+
warnings: [],
|
|
377
|
+
tips: [],
|
|
378
|
+
meta: {
|
|
379
|
+
target: {},
|
|
380
|
+
via: requestedVia,
|
|
381
|
+
settings: {},
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Render an execute fan-out envelope. `json`/`yaml` serialize the whole envelope;
|
|
388
|
+
* `text` renders a summary line plus one line per target in record-index order.
|
|
389
|
+
*/
|
|
390
|
+
export function renderExecuteFanoutEnvelope(
|
|
391
|
+
envelope: ExecuteFanoutEnvelope | ExecuteFanoutErrorEnvelope,
|
|
392
|
+
format: "json" | "yaml" | "text",
|
|
393
|
+
options: { verbose?: boolean } = {},
|
|
394
|
+
): string {
|
|
395
|
+
if (format === "json") {
|
|
396
|
+
return JSON.stringify(envelope, null, 2);
|
|
397
|
+
}
|
|
398
|
+
if (format === "yaml") {
|
|
399
|
+
return toYaml(envelope);
|
|
400
|
+
}
|
|
401
|
+
if (!envelope.ok) {
|
|
402
|
+
const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
|
|
403
|
+
if (envelope.error.remediation) {
|
|
404
|
+
lines.push(`Fix: ${envelope.error.remediation}`);
|
|
405
|
+
}
|
|
406
|
+
return lines.join("\n");
|
|
407
|
+
}
|
|
408
|
+
const operation = envelope.meta.operation;
|
|
409
|
+
const { total, ok, failed } = envelope.data.summary;
|
|
410
|
+
const lines: string[] = [
|
|
411
|
+
`execute ${operation?.request.command ?? "?"}: ${ok}/${total} ok, ${failed} failed (concurrency ${operation?.concurrency ?? "?"})`,
|
|
412
|
+
];
|
|
413
|
+
for (const target of envelope.data.targets) {
|
|
414
|
+
const meta = target.meta;
|
|
415
|
+
const label =
|
|
416
|
+
meta.target.identity ??
|
|
417
|
+
meta.target.host ??
|
|
418
|
+
meta.target.input ??
|
|
419
|
+
"(unknown)";
|
|
420
|
+
const index = meta.target.recordIndex ?? "-";
|
|
421
|
+
if (target.ok) {
|
|
422
|
+
lines.push(
|
|
423
|
+
` [${index}] ok ${label} (${target.meta.operation?.objectCount ?? 0} object(s))`,
|
|
424
|
+
);
|
|
425
|
+
} else {
|
|
426
|
+
lines.push(
|
|
427
|
+
` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
|
|
428
|
+
);
|
|
429
|
+
if (options.verbose && target.error.remediation) {
|
|
430
|
+
lines.push(` Fix: ${target.error.remediation}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
for (const warning of envelope.warnings) {
|
|
435
|
+
lines.push(`warning [${warning.code}]: ${warning.message}`);
|
|
436
|
+
}
|
|
437
|
+
return lines.join("\n");
|
|
438
|
+
}
|
package/src/execute.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
effectiveHostCandidate,
|
|
36
36
|
isIpTransport,
|
|
37
37
|
isMacAddress,
|
|
38
|
+
loadEnvFileDefaults,
|
|
38
39
|
parseDuration,
|
|
39
40
|
parseResolvePolicy,
|
|
40
41
|
type ResolvedAuth,
|
|
@@ -277,19 +278,29 @@ export async function runResolvedExecute(
|
|
|
277
278
|
export async function resolveExecuteRequest(
|
|
278
279
|
request: ExecuteRequest,
|
|
279
280
|
env: Record<string, string | undefined>,
|
|
281
|
+
options: {
|
|
282
|
+
cdbResolution?: CdbResolution;
|
|
283
|
+
config?: Record<string, string | undefined>;
|
|
284
|
+
} = {},
|
|
280
285
|
): Promise<ResolvedExecuteRequest> {
|
|
281
286
|
validateExecuteRequestShape(request);
|
|
282
287
|
const canonical = canonicalizeExecuteCommand(request.command);
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
288
|
+
const config = options.config ?? (await loadEnvFileDefaults(env));
|
|
289
|
+
// Fan-out passes the pre-resolved CDB record for a member (the CDB is loaded
|
|
290
|
+
// once in `expandCdbSelection`); single-target resolves it here.
|
|
291
|
+
const cdbResolution =
|
|
292
|
+
options.cdbResolution ??
|
|
293
|
+
(await resolveCdb(
|
|
294
|
+
{
|
|
295
|
+
targetInput: request.targetInput,
|
|
296
|
+
cdbFile: request.cdbFile,
|
|
297
|
+
cdbPassword: request.cdbPassword,
|
|
298
|
+
},
|
|
299
|
+
env,
|
|
300
|
+
config,
|
|
301
|
+
));
|
|
302
|
+
const via = resolveExecuteProtocol(request, env, cdbResolution, config);
|
|
303
|
+
const format = resolveFormat(request, env, config);
|
|
293
304
|
const validate = resolveBooleanSetting(
|
|
294
305
|
request.validate,
|
|
295
306
|
env,
|
|
@@ -297,18 +308,22 @@ export async function resolveExecuteRequest(
|
|
|
297
308
|
true,
|
|
298
309
|
"validate",
|
|
299
310
|
cdbResolution?.overrides.validate,
|
|
311
|
+
config,
|
|
300
312
|
);
|
|
301
313
|
const timeoutMs = resolveTimeoutSetting(
|
|
302
314
|
request.timeout,
|
|
303
315
|
env,
|
|
304
316
|
via.value,
|
|
305
317
|
cdbResolution?.overrides.timeoutMs,
|
|
318
|
+
config,
|
|
306
319
|
);
|
|
307
320
|
const maxResultsBytes = resolveOptionalIntegerSetting(
|
|
308
321
|
request.maxResultsBytes,
|
|
309
322
|
env,
|
|
310
323
|
"CENTRS_MAX_RESULTS",
|
|
311
324
|
"max-results",
|
|
325
|
+
undefined,
|
|
326
|
+
config,
|
|
312
327
|
);
|
|
313
328
|
const macResolution = isIpTransport(via.value)
|
|
314
329
|
? await resolveMacTarget({
|
|
@@ -316,7 +331,10 @@ export async function resolveExecuteRequest(
|
|
|
316
331
|
targetInput: request.targetInput,
|
|
317
332
|
cdbTarget: cdbResolution?.target,
|
|
318
333
|
env,
|
|
319
|
-
|
|
334
|
+
config,
|
|
335
|
+
policy: parseResolvePolicy(
|
|
336
|
+
request.resolve ?? env["CENTRS_RESOLVE"] ?? config["CENTRS_RESOLVE"],
|
|
337
|
+
),
|
|
320
338
|
operation: "execute",
|
|
321
339
|
})
|
|
322
340
|
: undefined;
|
|
@@ -330,6 +348,7 @@ export async function resolveExecuteRequest(
|
|
|
330
348
|
env,
|
|
331
349
|
via.value,
|
|
332
350
|
cdbResolution,
|
|
351
|
+
config,
|
|
333
352
|
);
|
|
334
353
|
const auth = resolveAuth(
|
|
335
354
|
{
|
|
@@ -339,6 +358,7 @@ export async function resolveExecuteRequest(
|
|
|
339
358
|
},
|
|
340
359
|
env,
|
|
341
360
|
cdbResolution,
|
|
361
|
+
config,
|
|
342
362
|
);
|
|
343
363
|
const insecure = resolveBooleanSetting(
|
|
344
364
|
request.insecure,
|
|
@@ -347,6 +367,7 @@ export async function resolveExecuteRequest(
|
|
|
347
367
|
false,
|
|
348
368
|
"insecure",
|
|
349
369
|
cdbResolution?.overrides.insecure,
|
|
370
|
+
config,
|
|
350
371
|
);
|
|
351
372
|
|
|
352
373
|
return {
|
|
@@ -366,6 +387,79 @@ export async function resolveExecuteRequest(
|
|
|
366
387
|
};
|
|
367
388
|
}
|
|
368
389
|
|
|
390
|
+
export interface ExecuteGlobalContext {
|
|
391
|
+
via: RouterOsProtocol;
|
|
392
|
+
write: boolean;
|
|
393
|
+
summary: ExecuteRequestSummary;
|
|
394
|
+
settings: CommonSettingsMeta;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Resolve the target-independent settings (protocol, format, validate, timeout,
|
|
399
|
+
* max-results) for a fan-out's outer `meta.operation.request` summary and its
|
|
400
|
+
* transport-aware concurrency default. No CDB record is consulted; the global
|
|
401
|
+
* `--via` (default `native-api`) decides the summary and concurrency base. Also
|
|
402
|
+
* canonicalizes the command once so write-ness is known before any target runs.
|
|
403
|
+
*/
|
|
404
|
+
export function resolveExecuteGlobalContext(
|
|
405
|
+
request: ExecuteRequest,
|
|
406
|
+
env: Record<string, string | undefined>,
|
|
407
|
+
config: Record<string, string | undefined> = {},
|
|
408
|
+
): ExecuteGlobalContext {
|
|
409
|
+
validateExecuteRequestShape(request);
|
|
410
|
+
const canonical = canonicalizeExecuteCommand(request.command);
|
|
411
|
+
const via = resolveExecuteProtocol(request, env, undefined, config);
|
|
412
|
+
const format = resolveFormat(request, env, config);
|
|
413
|
+
const validate = resolveBooleanSetting(
|
|
414
|
+
request.validate,
|
|
415
|
+
env,
|
|
416
|
+
"CENTRS_VALIDATE",
|
|
417
|
+
true,
|
|
418
|
+
"validate",
|
|
419
|
+
undefined,
|
|
420
|
+
config,
|
|
421
|
+
);
|
|
422
|
+
const timeoutMs = resolveTimeoutSetting(
|
|
423
|
+
request.timeout,
|
|
424
|
+
env,
|
|
425
|
+
via.value,
|
|
426
|
+
undefined,
|
|
427
|
+
config,
|
|
428
|
+
);
|
|
429
|
+
const maxResultsBytes = resolveOptionalIntegerSetting(
|
|
430
|
+
request.maxResultsBytes,
|
|
431
|
+
env,
|
|
432
|
+
"CENTRS_MAX_RESULTS",
|
|
433
|
+
"max-results",
|
|
434
|
+
undefined,
|
|
435
|
+
config,
|
|
436
|
+
);
|
|
437
|
+
return {
|
|
438
|
+
via: via.value,
|
|
439
|
+
write: isWriteShaped(canonical),
|
|
440
|
+
summary: {
|
|
441
|
+
command: request.command,
|
|
442
|
+
canonical,
|
|
443
|
+
write: isWriteShaped(canonical),
|
|
444
|
+
yes: request.yes ?? false,
|
|
445
|
+
validate: validate.value,
|
|
446
|
+
verbose: request.verbose ?? false,
|
|
447
|
+
timeoutMs: timeoutMs.value,
|
|
448
|
+
format: format.value,
|
|
449
|
+
maxResultsBytes: maxResultsBytes?.value,
|
|
450
|
+
},
|
|
451
|
+
settings: {
|
|
452
|
+
via: toCoreSource(via.source),
|
|
453
|
+
timeoutMs: toCoreSource(timeoutMs.source),
|
|
454
|
+
format: toCoreSource(format.source),
|
|
455
|
+
validate: toCoreSource(validate.source),
|
|
456
|
+
maxResultsBytes: maxResultsBytes
|
|
457
|
+
? toCoreSource(maxResultsBytes.source)
|
|
458
|
+
: undefined,
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
369
463
|
export function buildExecuteErrorEnvelope(
|
|
370
464
|
request: ExecuteRequest,
|
|
371
465
|
error: unknown,
|
|
@@ -756,16 +850,6 @@ async function runCommand(
|
|
|
756
850
|
return backend.execute({ path: "", command: "", script: resolved.command });
|
|
757
851
|
}
|
|
758
852
|
if (resolved.canonical.mode === "script") {
|
|
759
|
-
if (resolved.via.value === "native-api") {
|
|
760
|
-
throw new CentrsError({
|
|
761
|
-
code: "transport/unsupported-operation",
|
|
762
|
-
summary:
|
|
763
|
-
"The native API execute path requires a slash-prefixed RouterOS command.",
|
|
764
|
-
remediation:
|
|
765
|
-
"Use a path-shaped command such as `/system/identity/set name=x`, or pin `--via rest-api` for `/rest/execute` script mode.",
|
|
766
|
-
context: { via: resolved.via.value, command: resolved.command },
|
|
767
|
-
});
|
|
768
|
-
}
|
|
769
853
|
return backend.execute({ path: "", command: "", script: resolved.command });
|
|
770
854
|
}
|
|
771
855
|
return backend.execute({
|
|
@@ -821,8 +905,9 @@ function resolveExecuteProtocol(
|
|
|
821
905
|
request: ExecuteRequest,
|
|
822
906
|
env: Record<string, string | undefined>,
|
|
823
907
|
cdb?: CdbResolution,
|
|
908
|
+
config: Record<string, string | undefined> = {},
|
|
824
909
|
): ResolvedSetting<RouterOsProtocol> {
|
|
825
|
-
const defaultVia = isUnresolvedMacTarget(request, env, cdb)
|
|
910
|
+
const defaultVia = isUnresolvedMacTarget(request, env, cdb, config)
|
|
826
911
|
? "mac-telnet"
|
|
827
912
|
: "native-api";
|
|
828
913
|
const via = resolveStringSetting(
|
|
@@ -833,6 +918,7 @@ function resolveExecuteProtocol(
|
|
|
833
918
|
"via",
|
|
834
919
|
undefined,
|
|
835
920
|
cdb?.overrides.via,
|
|
921
|
+
config,
|
|
836
922
|
);
|
|
837
923
|
if (!via) {
|
|
838
924
|
throw new CentrsError({
|
|
@@ -877,8 +963,13 @@ function isUnresolvedMacTarget(
|
|
|
877
963
|
request: ExecuteRequest,
|
|
878
964
|
env: Record<string, string | undefined>,
|
|
879
965
|
cdb?: CdbResolution,
|
|
966
|
+
config: Record<string, string | undefined> = {},
|
|
880
967
|
): boolean {
|
|
881
|
-
if (
|
|
968
|
+
if (
|
|
969
|
+
request.via !== undefined ||
|
|
970
|
+
env["CENTRS_VIA"] !== undefined ||
|
|
971
|
+
config["CENTRS_VIA"] !== undefined
|
|
972
|
+
) {
|
|
882
973
|
return false;
|
|
883
974
|
}
|
|
884
975
|
// Decide the default transport from the *effective* target (host >
|
|
@@ -890,6 +981,7 @@ function isUnresolvedMacTarget(
|
|
|
890
981
|
targetInput: request.targetInput,
|
|
891
982
|
cdbTarget: cdb?.target,
|
|
892
983
|
env,
|
|
984
|
+
config,
|
|
893
985
|
});
|
|
894
986
|
return candidate ? isMacAddress(candidate) : false;
|
|
895
987
|
}
|
|
@@ -897,6 +989,7 @@ function isUnresolvedMacTarget(
|
|
|
897
989
|
function resolveFormat(
|
|
898
990
|
request: ExecuteRequest,
|
|
899
991
|
env: Record<string, string | undefined>,
|
|
992
|
+
config: Record<string, string | undefined> = {},
|
|
900
993
|
): ResolvedSetting<ExecuteOutputFormat> {
|
|
901
994
|
return resolveStringSetting(
|
|
902
995
|
request.format,
|
|
@@ -905,6 +998,8 @@ function resolveFormat(
|
|
|
905
998
|
"text",
|
|
906
999
|
"format",
|
|
907
1000
|
parseOutputFormat,
|
|
1001
|
+
undefined,
|
|
1002
|
+
config,
|
|
908
1003
|
) as ResolvedSetting<ExecuteOutputFormat>;
|
|
909
1004
|
}
|
|
910
1005
|
|
|
@@ -930,6 +1025,7 @@ function resolveTimeoutSetting(
|
|
|
930
1025
|
env: Record<string, string | undefined>,
|
|
931
1026
|
via: RouterOsProtocol,
|
|
932
1027
|
commentKv?: ResolvedSetting<number>,
|
|
1028
|
+
config: Record<string, string | undefined> = {},
|
|
933
1029
|
): ResolvedSetting<number> {
|
|
934
1030
|
const resolved = resolveStringSetting(
|
|
935
1031
|
timeout === undefined ? undefined : String(timeout),
|
|
@@ -950,6 +1046,7 @@ function resolveTimeoutSetting(
|
|
|
950
1046
|
return parsed;
|
|
951
1047
|
},
|
|
952
1048
|
commentKv,
|
|
1049
|
+
config,
|
|
953
1050
|
);
|
|
954
1051
|
if (!resolved) {
|
|
955
1052
|
throw new Error("timeout resolution produced no value");
|