@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,25 @@
|
|
|
1
|
+
# `settings/unknown-key`
|
|
2
|
+
|
|
3
|
+
`settings get`/`set`/`reset` received a token that is not a recognized CENTRS_*-shaped setting.
|
|
4
|
+
|
|
5
|
+
## Fix
|
|
6
|
+
|
|
7
|
+
`centrs settings get <attr>`, `set <attr> <value>`, and `reset <attr>` all
|
|
8
|
+
normalize the token first — with or without the `CENTRS_` prefix, in
|
|
9
|
+
kebab-case or SCREAMING_SNAKE (`format`, `FORMAT`, `centrs_format`, and
|
|
10
|
+
`CENTRS_FORMAT` all resolve to the same key) — then look it up against the 13
|
|
11
|
+
keys `settings` manages plus the 5 refused keys. This error fires when the
|
|
12
|
+
normalized token matches neither list: a typo, a made-up name, or a
|
|
13
|
+
`CENTRS_*` var no shipped command actually reads (see
|
|
14
|
+
`commands/settings/README.md`, "Not yet wired — exclude from v1", for three
|
|
15
|
+
such names that are deliberately unsupported today).
|
|
16
|
+
|
|
17
|
+
Run `centrs settings print --all` to see every key `settings` recognizes,
|
|
18
|
+
plus any unrecognized `CENTRS_*` lines already present in the file, tagged
|
|
19
|
+
`(unrecognized)` — that second list is the fastest way to spot a typo you
|
|
20
|
+
already wrote into `centrs.env` by hand.
|
|
21
|
+
|
|
22
|
+
This is a different failure from [`settings/reserved-key`](reserved-key.md):
|
|
23
|
+
that error means "this is a real setting, but writing it is off-limits";
|
|
24
|
+
`settings/unknown-key` means the token isn't a setting `centrs` knows about
|
|
25
|
+
at all. See examples 9 and 19 in `commands/settings/examples.md`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# `usage/fanout-not-supported`
|
|
2
|
+
|
|
3
|
+
The command or mode cannot fan out across multiple targets.
|
|
4
|
+
|
|
5
|
+
## Fix
|
|
6
|
+
|
|
7
|
+
Some surfaces are single-session by nature and reject a multi-target selection:
|
|
8
|
+
|
|
9
|
+
- `terminal` — an interactive console relay to one device.
|
|
10
|
+
- `api --stream` / `api --listen` — an open-ended native-api follow is a single
|
|
11
|
+
long-lived subscription.
|
|
12
|
+
|
|
13
|
+
Run the command against a single target (one positional, no `--group` / `--where` /
|
|
14
|
+
`--all` / `--default`). To act on a fleet, use a fan-out-capable verb instead
|
|
15
|
+
(`retrieve`, `execute`, `api` without `--stream`, `transfer`). See
|
|
16
|
+
[`docs/CONSTITUTION.md`](../../CONSTITUTION.md) (Target selection).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tikoci/centrs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"main": "src/index.ts",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"repository": {
|
|
@@ -11,15 +11,14 @@
|
|
|
11
11
|
"bugs": "https://github.com/tikoci/centrs/issues",
|
|
12
12
|
"packageManager": "bun@1.3.13",
|
|
13
13
|
"devDependencies": {
|
|
14
|
-
"@
|
|
15
|
-
"@biomejs/biome": "2.5.1",
|
|
14
|
+
"@biomejs/biome": "2.5.2",
|
|
16
15
|
"@secretlint/secretlint-rule-preset-recommend": "^13.0.2",
|
|
17
16
|
"@tikoci/quickchr": "^0.4.2",
|
|
18
17
|
"@types/bun": "^1.3.13",
|
|
19
18
|
"cloc": "^2.6.0-cloc",
|
|
20
19
|
"cspell": "^10.0.0",
|
|
21
20
|
"has-flag": "4.0.0",
|
|
22
|
-
"markdownlint-cli2": "^0.
|
|
21
|
+
"markdownlint-cli2": "^0.23.0",
|
|
23
22
|
"secretlint": "^13.0.2",
|
|
24
23
|
"typedoc": "^0.28.19"
|
|
25
24
|
},
|
|
@@ -70,10 +69,10 @@
|
|
|
70
69
|
"lint:biome": "biome check .",
|
|
71
70
|
"lint:fix": "biome check --write .",
|
|
72
71
|
"lint:secretlint": "secretlint \"**/*\"",
|
|
73
|
-
"lint:agents": "agent-lint scan --stdout",
|
|
74
72
|
"test": "bun test",
|
|
75
73
|
"test:smoke": "bun test test/integration/cli-smoke.test.ts",
|
|
76
|
-
"test:integration:fast": "bun test test/integration/devices.test.ts",
|
|
74
|
+
"test:integration:fast": "bun test test/integration/devices.test.ts test/integration/settings.test.ts",
|
|
75
|
+
"test:integration:settings": "bun test test/integration/settings.test.ts",
|
|
77
76
|
"test:integration:smoke": "CENTRS_RUN_FAST_INTEGRATION=1 bun test test/integration/chr-smoke.test.ts",
|
|
78
77
|
"test:integration": "CENTRS_RUN_FAST_INTEGRATION=1 bun test test/integration/",
|
|
79
78
|
"test:integration:stable": "CENTRS_CHR_CHANNEL=stable bun run test:integration",
|
|
@@ -82,6 +81,7 @@
|
|
|
82
81
|
"test:integration:development": "CENTRS_CHR_CHANNEL=development bun run test:integration",
|
|
83
82
|
"test:ci": "bun test --coverage",
|
|
84
83
|
"test:all": "bun run test:ci",
|
|
84
|
+
"check:agent": "bun run scripts/agent-check.ts",
|
|
85
85
|
"build": "bun build src/index.ts src/cli.ts src/mcp.ts src/tui.ts src/webproxy.ts src/protocols/index.ts --outdir dist --target bun",
|
|
86
86
|
"build:doc:api": "typedoc --out docs/api src/index.ts",
|
|
87
87
|
"docs:errors": "bun run scripts/gen-error-pages.ts",
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-target fan-out for `api`.
|
|
3
|
+
*
|
|
4
|
+
* A target selection (`--group` / `--where` / `--all` / `--default` / multiple
|
|
5
|
+
* positionals) expands to N members and runs each through the single-target api
|
|
6
|
+
* tail (`resolveApiRequest` → `runResolvedApi`), collecting per-target inner
|
|
7
|
+
* `ApiEnvelope`s into the locked `FanoutData` shape (`src/core/envelope.ts`).
|
|
8
|
+
*
|
|
9
|
+
* Boundaries (see `commands/api/README.md`, `docs/CONSTITUTION.md`):
|
|
10
|
+
* - The CDB is loaded ONCE (`expandCdbSelection`); a cdb member is resolved
|
|
11
|
+
* with its pre-resolved record, so `resolveCdb`'s `__default__` synthetic
|
|
12
|
+
* fallback never collides ad-hoc literals.
|
|
13
|
+
* - A `--default` / `__default__` member is guarded from being dialed as the
|
|
14
|
+
* literal hostname `"__default__"` — it fails that one target deterministically
|
|
15
|
+
* with `target/unresolved`.
|
|
16
|
+
* - A mutating fan-out is confirmed ONCE up front (`--yes`), never per target;
|
|
17
|
+
* without `--yes` the error names the blast radius (how many routers).
|
|
18
|
+
* - `--listen`/`--stream` and `--raw` are rejected in fan-out mode by the CLI
|
|
19
|
+
* before reaching here (single-session / envelope-stripping).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
type ApiEnvelope,
|
|
24
|
+
type ApiOperationMeta,
|
|
25
|
+
type ApiRequest,
|
|
26
|
+
type ApiRequestSummary,
|
|
27
|
+
type ApiSuccessEnvelope,
|
|
28
|
+
apiRequestSummaryFromRequest,
|
|
29
|
+
assertApiProtocolSupported,
|
|
30
|
+
buildApiErrorEnvelopeFromResolved,
|
|
31
|
+
parseApiMethod,
|
|
32
|
+
type ResolvedApiRequest,
|
|
33
|
+
resolveApiRequest,
|
|
34
|
+
runResolvedApi,
|
|
35
|
+
validateApiRequestShape,
|
|
36
|
+
} from "./api.ts";
|
|
37
|
+
import type {
|
|
38
|
+
CentrsErrorEnvelope,
|
|
39
|
+
CentrsSuccessEnvelope,
|
|
40
|
+
FanoutData,
|
|
41
|
+
FanoutSummary,
|
|
42
|
+
} from "./core/envelope.ts";
|
|
43
|
+
import {
|
|
44
|
+
buildFanoutEnvelope,
|
|
45
|
+
buildFanoutResolveFailure,
|
|
46
|
+
commonVia,
|
|
47
|
+
defaultFanoutSleep,
|
|
48
|
+
resolveFanoutConcurrency,
|
|
49
|
+
runFanout,
|
|
50
|
+
summarizeFanout,
|
|
51
|
+
} from "./core/fanout.ts";
|
|
52
|
+
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
53
|
+
import { promptForWriteConfirmation } from "./execute.ts";
|
|
54
|
+
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
55
|
+
import {
|
|
56
|
+
type CdbSelectionExpansion,
|
|
57
|
+
type CdbSelectionMember,
|
|
58
|
+
type CdbSelectionResolveInput,
|
|
59
|
+
expandCdbSelection,
|
|
60
|
+
isDefaultRecordTarget,
|
|
61
|
+
loadEnvFileDefaults,
|
|
62
|
+
type TargetSelection,
|
|
63
|
+
} from "./resolver/index.ts";
|
|
64
|
+
import { toYaml } from "./retrieve.ts";
|
|
65
|
+
|
|
66
|
+
/** Compact description of the selector that produced the fan-out. */
|
|
67
|
+
export interface ApiSelectionSummary {
|
|
68
|
+
groups: readonly string[];
|
|
69
|
+
where: readonly string[];
|
|
70
|
+
all: boolean;
|
|
71
|
+
default: boolean;
|
|
72
|
+
positionals: readonly string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ApiFanoutOperationMeta {
|
|
76
|
+
kind: "fanout";
|
|
77
|
+
concurrency: number;
|
|
78
|
+
summary: FanoutSummary;
|
|
79
|
+
request: ApiRequestSummary;
|
|
80
|
+
selection: ApiSelectionSummary;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type ApiFanoutData = FanoutData<unknown, ApiOperationMeta>;
|
|
84
|
+
export type ApiFanoutEnvelope = CentrsSuccessEnvelope<
|
|
85
|
+
ApiFanoutData,
|
|
86
|
+
ApiFanoutOperationMeta
|
|
87
|
+
>;
|
|
88
|
+
export type ApiFanoutErrorEnvelope =
|
|
89
|
+
CentrsErrorEnvelope<ApiFanoutOperationMeta>;
|
|
90
|
+
|
|
91
|
+
/** Test/override seams; production callers pass nothing. */
|
|
92
|
+
export interface ApiFanoutInternals {
|
|
93
|
+
expand?: (
|
|
94
|
+
selection: TargetSelection,
|
|
95
|
+
input: CdbSelectionResolveInput,
|
|
96
|
+
env: Record<string, string | undefined>,
|
|
97
|
+
config?: Record<string, string | undefined>,
|
|
98
|
+
) => Promise<CdbSelectionExpansion>;
|
|
99
|
+
execute?: (resolved: ResolvedApiRequest) => Promise<ApiSuccessEnvelope>;
|
|
100
|
+
sleep?: (ms: number) => Promise<void>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ApiFanoutOptions {
|
|
104
|
+
concurrency?: number;
|
|
105
|
+
/** CLI: true (literal positionals allowed). MCP: false (CDB is the allowlist). */
|
|
106
|
+
allowAdhoc?: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function summarizeSelection(selection: TargetSelection): ApiSelectionSummary {
|
|
110
|
+
return {
|
|
111
|
+
groups: selection.groups,
|
|
112
|
+
where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
|
|
113
|
+
all: selection.all,
|
|
114
|
+
default: selection.default,
|
|
115
|
+
positionals: selection.positionals,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Representative transport for the concurrency default (pinned > native member > rest). */
|
|
120
|
+
function fanoutVia(
|
|
121
|
+
request: ApiRequest,
|
|
122
|
+
env: Record<string, string | undefined>,
|
|
123
|
+
expansion: CdbSelectionExpansion,
|
|
124
|
+
): RouterOsProtocol {
|
|
125
|
+
// Deliberately CLI/env only, not `config` — see the note above this
|
|
126
|
+
// function's caller.
|
|
127
|
+
const pinned = request.via ?? env["CENTRS_VIA"];
|
|
128
|
+
if (
|
|
129
|
+
pinned !== undefined &&
|
|
130
|
+
plannedProtocols.includes(pinned as RouterOsProtocol)
|
|
131
|
+
) {
|
|
132
|
+
return pinned as RouterOsProtocol;
|
|
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" : "rest-api";
|
|
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. `resolveApiRequest` 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 (see {@link renderApiFanoutEnvelope}).
|
|
163
|
+
*/
|
|
164
|
+
function relabelAdhocMember(
|
|
165
|
+
envelope: ApiEnvelope,
|
|
166
|
+
member: CdbSelectionMember,
|
|
167
|
+
): ApiEnvelope {
|
|
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 `api` fan-out. Returns a success envelope when the orchestration
|
|
180
|
+
* completes (even if every target failed); throws only for pre-flight failures
|
|
181
|
+
* (invalid method, CDB decrypt, unconfirmed write) the caller renders as an
|
|
182
|
+
* outer error via {@link buildApiFanoutErrorEnvelope}.
|
|
183
|
+
*/
|
|
184
|
+
export async function apiFanout(
|
|
185
|
+
request: ApiRequest,
|
|
186
|
+
selection: TargetSelection,
|
|
187
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
188
|
+
internals: ApiFanoutInternals = {},
|
|
189
|
+
options: ApiFanoutOptions = {},
|
|
190
|
+
): Promise<ApiFanoutEnvelope> {
|
|
191
|
+
// Validate the request shape + method ONCE, before selection expansion: a
|
|
192
|
+
// missing endpoint or an invalid `-X` is an outer error regardless of how many
|
|
193
|
+
// targets the selection resolves to (so an empty selection cannot mask it).
|
|
194
|
+
validateApiRequestShape(request);
|
|
195
|
+
const config = await loadEnvFileDefaults(env);
|
|
196
|
+
const method = parseApiMethod(request.method);
|
|
197
|
+
const requestSummary = apiRequestSummaryFromRequest(request, env);
|
|
198
|
+
const selectionSummary = summarizeSelection(selection);
|
|
199
|
+
|
|
200
|
+
// A globally-pinned `--via` / `CENTRS_VIA` outranks every per-target CDB
|
|
201
|
+
// override (explicit/env > comment-kv in `resolveStringSetting`), so an invalid
|
|
202
|
+
// one fails every target identically. Reject it ONCE up front as an outer error,
|
|
203
|
+
// matching single-target `resolveApiProtocol`, instead of N per-target failures.
|
|
204
|
+
// Deliberately CLI/env only, not `config` — see the identical note in
|
|
205
|
+
// `retrieve-fanout.ts`'s `resolveFanoutConcurrencyProtocol`: a `centrs.env`
|
|
206
|
+
// default is the weakest tier and must not be treated as a hard pin.
|
|
207
|
+
const pinnedVia = request.via ?? env["CENTRS_VIA"];
|
|
208
|
+
if (pinnedVia !== undefined) {
|
|
209
|
+
assertApiProtocolSupported(pinnedVia);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const expand = internals.expand ?? expandCdbSelection;
|
|
213
|
+
const expansion = await expand(
|
|
214
|
+
selection,
|
|
215
|
+
{
|
|
216
|
+
cdbFile: request.cdbFile,
|
|
217
|
+
cdbPassword: request.cdbPassword,
|
|
218
|
+
allowAdhoc: options.allowAdhoc ?? true,
|
|
219
|
+
},
|
|
220
|
+
env,
|
|
221
|
+
config,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const via = fanoutVia(request, env, expansion);
|
|
225
|
+
const concurrency = resolveFanoutConcurrency(options.concurrency, via);
|
|
226
|
+
|
|
227
|
+
if (expansion.empty) {
|
|
228
|
+
return buildApiFanoutSuccess({
|
|
229
|
+
summary: { total: 0, ok: 0, failed: 0 },
|
|
230
|
+
targets: [],
|
|
231
|
+
warnings: expansion.warnings,
|
|
232
|
+
via: null,
|
|
233
|
+
concurrency,
|
|
234
|
+
requestSummary,
|
|
235
|
+
selectionSummary,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Mutating fan-out: confirm ONCE up front, naming the blast radius.
|
|
240
|
+
await assertFanoutWriteConfirmed(request, requestSummary, method, expansion);
|
|
241
|
+
|
|
242
|
+
const execute = internals.execute ?? runResolvedApi;
|
|
243
|
+
const sleep = internals.sleep ?? defaultFanoutSleep;
|
|
244
|
+
|
|
245
|
+
const targets = await runFanout<
|
|
246
|
+
CdbSelectionMember,
|
|
247
|
+
ResolvedApiRequest,
|
|
248
|
+
ApiEnvelope
|
|
249
|
+
>({
|
|
250
|
+
members: expansion.targets,
|
|
251
|
+
concurrency,
|
|
252
|
+
resolve: (member) => {
|
|
253
|
+
if (member.kind === "cdb") {
|
|
254
|
+
if (isDefaultRecordTarget(member.resolution.target)) {
|
|
255
|
+
throw new CentrsError({
|
|
256
|
+
code: "target/unresolved",
|
|
257
|
+
summary:
|
|
258
|
+
"The `__default__` record is a credential fallback, not a connectable router.",
|
|
259
|
+
remediation:
|
|
260
|
+
"Select real devices with `--group` / `--where` / `--all` or a positional; `--default` cannot be a RouterOS target.",
|
|
261
|
+
context: { target: member.resolution.target },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return resolveApiRequest(
|
|
265
|
+
{ ...request, targetInput: member.resolution.target },
|
|
266
|
+
env,
|
|
267
|
+
{ cdbResolution: member.resolution, config },
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
return resolveApiRequest({ ...request, targetInput: member.input }, env, {
|
|
271
|
+
config,
|
|
272
|
+
});
|
|
273
|
+
},
|
|
274
|
+
onResolveError: (member, error) =>
|
|
275
|
+
buildFanoutResolveFailure<ApiOperationMeta>({
|
|
276
|
+
error,
|
|
277
|
+
target: memberTargetMeta(member),
|
|
278
|
+
warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
|
|
279
|
+
}),
|
|
280
|
+
execute: async (resolved, member) =>
|
|
281
|
+
relabelAdhocMember(await execute(resolved), member),
|
|
282
|
+
onExecuteError: (resolved, member, error) =>
|
|
283
|
+
relabelAdhocMember(
|
|
284
|
+
buildApiErrorEnvelopeFromResolved(resolved, error),
|
|
285
|
+
member,
|
|
286
|
+
),
|
|
287
|
+
sleep,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
return buildApiFanoutSuccess({
|
|
291
|
+
summary: summarizeFanout(targets),
|
|
292
|
+
targets,
|
|
293
|
+
warnings: expansion.warnings,
|
|
294
|
+
via: commonVia(targets),
|
|
295
|
+
concurrency,
|
|
296
|
+
requestSummary,
|
|
297
|
+
selectionSummary,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function assertFanoutWriteConfirmed(
|
|
302
|
+
request: ApiRequest,
|
|
303
|
+
summary: ApiRequestSummary,
|
|
304
|
+
method: string,
|
|
305
|
+
expansion: CdbSelectionExpansion,
|
|
306
|
+
): Promise<void> {
|
|
307
|
+
if (!summary.write || request.yes) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const count = expansion.targets.length;
|
|
311
|
+
const prompt = `Run mutating ${method} ${summary.path} across ${count} router(s)? Type yes to continue: `;
|
|
312
|
+
if (request.stdinIsTty ?? process.stdin.isTTY) {
|
|
313
|
+
const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
|
|
314
|
+
prompt,
|
|
315
|
+
);
|
|
316
|
+
if (confirmed) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
throw new CentrsError({
|
|
321
|
+
code: "usage/confirmation-required",
|
|
322
|
+
summary: `A mutating ${method} ${summary.path} would run across ${count} router(s) and needs explicit confirmation.`,
|
|
323
|
+
remediation:
|
|
324
|
+
"Pass `--yes` to fan the write out to every selected router, or answer `yes` at the TTY prompt after reviewing the blast radius.",
|
|
325
|
+
context: { method, path: summary.path, routers: count },
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
interface ApiFanoutSuccessInput {
|
|
330
|
+
summary: FanoutSummary;
|
|
331
|
+
targets: readonly ApiEnvelope[];
|
|
332
|
+
warnings: CdbSelectionExpansion["warnings"];
|
|
333
|
+
via: RouterOsProtocol | null;
|
|
334
|
+
concurrency: number;
|
|
335
|
+
requestSummary: ApiRequestSummary;
|
|
336
|
+
selectionSummary: ApiSelectionSummary;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function buildApiFanoutSuccess(
|
|
340
|
+
input: ApiFanoutSuccessInput,
|
|
341
|
+
): ApiFanoutEnvelope {
|
|
342
|
+
return buildFanoutEnvelope<unknown, ApiOperationMeta, ApiFanoutOperationMeta>(
|
|
343
|
+
{
|
|
344
|
+
summary: input.summary,
|
|
345
|
+
targets: input.targets,
|
|
346
|
+
warnings: input.warnings,
|
|
347
|
+
settings: {},
|
|
348
|
+
via: input.via,
|
|
349
|
+
operation: {
|
|
350
|
+
kind: "fanout",
|
|
351
|
+
concurrency: input.concurrency,
|
|
352
|
+
summary: input.summary,
|
|
353
|
+
request: input.requestSummary,
|
|
354
|
+
selection: input.selectionSummary,
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Outer error envelope for a fan-out that failed before per-target results. */
|
|
361
|
+
export function buildApiFanoutErrorEnvelope(
|
|
362
|
+
request: ApiRequest,
|
|
363
|
+
selection: TargetSelection,
|
|
364
|
+
error: unknown,
|
|
365
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
366
|
+
): ApiFanoutErrorEnvelope {
|
|
367
|
+
const centrsError =
|
|
368
|
+
error instanceof CentrsError
|
|
369
|
+
? error
|
|
370
|
+
: new CentrsError({
|
|
371
|
+
code: "internal/unhandled",
|
|
372
|
+
summary: "api fan-out failed with an unexpected error.",
|
|
373
|
+
remediation:
|
|
374
|
+
"Re-run with `--format json` to capture the structured error details.",
|
|
375
|
+
cause: error,
|
|
376
|
+
});
|
|
377
|
+
const requestedVia = plannedProtocols.includes(
|
|
378
|
+
request.via as RouterOsProtocol,
|
|
379
|
+
)
|
|
380
|
+
? (request.via as RouterOsProtocol)
|
|
381
|
+
: null;
|
|
382
|
+
return {
|
|
383
|
+
ok: false,
|
|
384
|
+
error: serializeCentrsError(centrsError),
|
|
385
|
+
warnings: [],
|
|
386
|
+
tips: [],
|
|
387
|
+
meta: {
|
|
388
|
+
target: {},
|
|
389
|
+
via: requestedVia,
|
|
390
|
+
settings: {},
|
|
391
|
+
operation: {
|
|
392
|
+
kind: "fanout",
|
|
393
|
+
concurrency: 0,
|
|
394
|
+
summary: { total: 0, ok: 0, failed: 0 },
|
|
395
|
+
request: apiRequestSummaryFromRequest(request, env),
|
|
396
|
+
selection: summarizeSelection(selection),
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Render an api fan-out envelope. `json`/`yaml` serialize the whole envelope;
|
|
404
|
+
* `text` renders a summary line plus one line per target in record-index order.
|
|
405
|
+
*/
|
|
406
|
+
export function renderApiFanoutEnvelope(
|
|
407
|
+
envelope: ApiFanoutEnvelope | ApiFanoutErrorEnvelope,
|
|
408
|
+
format: "json" | "yaml" | "text",
|
|
409
|
+
options: { verbose?: boolean } = {},
|
|
410
|
+
): string {
|
|
411
|
+
if (format === "json") {
|
|
412
|
+
return JSON.stringify(envelope, null, 2);
|
|
413
|
+
}
|
|
414
|
+
if (format === "yaml") {
|
|
415
|
+
return toYaml(envelope);
|
|
416
|
+
}
|
|
417
|
+
if (!envelope.ok) {
|
|
418
|
+
const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
|
|
419
|
+
if (envelope.error.remediation) {
|
|
420
|
+
lines.push(`Fix: ${envelope.error.remediation}`);
|
|
421
|
+
}
|
|
422
|
+
return lines.join("\n");
|
|
423
|
+
}
|
|
424
|
+
const operation = envelope.meta.operation;
|
|
425
|
+
const { total, ok, failed } = envelope.data.summary;
|
|
426
|
+
const lines: string[] = [
|
|
427
|
+
`api ${operation?.request.method} ${operation?.request.path}: ${ok}/${total} ok, ${failed} failed (concurrency ${operation?.concurrency ?? "?"})`,
|
|
428
|
+
];
|
|
429
|
+
for (const target of envelope.data.targets) {
|
|
430
|
+
const meta = target.meta;
|
|
431
|
+
const label =
|
|
432
|
+
meta.target.identity ??
|
|
433
|
+
meta.target.host ??
|
|
434
|
+
meta.target.input ??
|
|
435
|
+
"(unknown)";
|
|
436
|
+
const index = meta.target.recordIndex ?? "-";
|
|
437
|
+
if (target.ok) {
|
|
438
|
+
lines.push(
|
|
439
|
+
` [${index}] ok ${label} (${target.meta.operation?.objectCount ?? 0} object(s))`,
|
|
440
|
+
);
|
|
441
|
+
} else {
|
|
442
|
+
lines.push(
|
|
443
|
+
` [${index}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
|
|
444
|
+
);
|
|
445
|
+
if (options.verbose && target.error.remediation) {
|
|
446
|
+
lines.push(` Fix: ${target.error.remediation}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
for (const warning of envelope.warnings) {
|
|
451
|
+
lines.push(`warning [${warning.code}]: ${warning.message}`);
|
|
452
|
+
}
|
|
453
|
+
return lines.join("\n");
|
|
454
|
+
}
|