@tikoci/centrs 0.1.9-next.0 → 0.1.9-next.22
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 +177 -40
- package/GLOSSARY.txt +8 -0
- package/README.md +12 -8
- package/SECURITY.md +11 -3
- package/commands/api/README.md +39 -15
- package/commands/api/examples.md +44 -6
- package/commands/execute/README.md +108 -14
- package/commands/execute/examples.md +118 -3
- package/commands/explain/README.md +2 -1
- package/commands/mcp/README.md +11 -0
- package/commands/mcp/examples.md +6 -0
- package/commands/retrieve/README.md +30 -13
- package/commands/retrieve/examples.md +10 -6
- package/commands/transfer/examples.md +9 -0
- package/docs/CLI.md +8 -8
- package/docs/CONSTITUTION.md +25 -2
- package/docs/MATRIX.md +5 -4
- package/docs/errors/cdb/empty-group.md +19 -3
- package/docs/errors/cdb/empty-selection.md +9 -3
- package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
- package/docs/errors/cmr/empty-selection.md +17 -3
- package/docs/errors/cmr/rule-selects-every-device.md +32 -0
- package/docs/errors/cmr/selection-includes-controller.md +27 -0
- package/docs/errors/usage/flag-after-separator.md +34 -0
- package/package.json +7 -5
- package/src/api-fanout.ts +11 -1
- package/src/api.ts +205 -65
- package/src/btest.ts +7 -2
- package/src/cli/api.ts +30 -16
- package/src/cli/common.ts +180 -1
- package/src/cli/devices.ts +0 -2
- package/src/cli/discover.ts +0 -1
- package/src/cli/execute.ts +44 -20
- package/src/cli/explain.ts +2 -0
- package/src/cli/mcp.ts +0 -1
- package/src/cli/retrieve.ts +40 -18
- package/src/cli/settings.ts +0 -3
- package/src/cli/terminal.ts +0 -2
- package/src/cli/transfer.ts +10 -8
- package/src/cli/version.ts +87 -0
- package/src/cli.ts +5 -3
- package/src/core/cmr-fleet.ts +486 -0
- package/src/core/cmr.ts +78 -0
- package/src/core/envelope.ts +32 -3
- package/src/core/error-catalog.ts +25 -3
- package/src/core/execute-command.ts +144 -19
- package/src/core/fanout.ts +130 -6
- package/src/errors.ts +2 -1
- package/src/execute-cmr.ts +95 -78
- package/src/execute-fanout.ts +11 -1
- package/src/execute.ts +105 -23
- package/src/index.ts +18 -1
- package/src/mcp/safety.ts +11 -0
- package/src/mcp/tools.ts +4 -1
- package/src/resolver/cdb.ts +13 -1
- package/src/resolver/index.ts +4 -0
- package/src/resolver/selection.ts +37 -1
- package/src/resolver/target.ts +96 -8
- package/src/retrieve-fanout.ts +4 -1
- package/src/retrieve-follow.ts +29 -33
- package/src/retrieve-sample.ts +14 -8
- package/src/retrieve-stream.ts +38 -6
- package/src/retrieve.ts +73 -59
- package/src/transfer-fanout.ts +8 -1
- package/src/transfer.ts +15 -19
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `centrs --version` / `-V` (#463): the package version, for scripts, agents and
|
|
3
|
+
* field reports.
|
|
4
|
+
*
|
|
5
|
+
* Text output is the bare version string (`0.1.9-next.0`), so a script can use
|
|
6
|
+
* it as is. `--json` / `--format json|yaml` return the standard envelope with
|
|
7
|
+
* `data.version`. Format comes from flags only, never `CENTRS_FORMAT`, so a
|
|
8
|
+
* plain `centrs --version` always prints the bare string.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { CentrsEnvelope } from "../core/envelope.ts";
|
|
12
|
+
import { centrsVersion } from "../core/version.ts";
|
|
13
|
+
import { toYaml } from "../core/yaml.ts";
|
|
14
|
+
import { CentrsError, formatCentrsErrorText } from "../errors.ts";
|
|
15
|
+
|
|
16
|
+
const versionFormats = ["text", "json", "yaml"] as const;
|
|
17
|
+
type VersionFormat = (typeof versionFormats)[number];
|
|
18
|
+
|
|
19
|
+
export type VersionEnvelope = CentrsEnvelope<
|
|
20
|
+
{ version: string },
|
|
21
|
+
{ command: "version" }
|
|
22
|
+
>;
|
|
23
|
+
|
|
24
|
+
export function versionEnvelope(): VersionEnvelope {
|
|
25
|
+
return {
|
|
26
|
+
ok: true,
|
|
27
|
+
data: { version: centrsVersion },
|
|
28
|
+
warnings: [],
|
|
29
|
+
tips: [],
|
|
30
|
+
meta: {
|
|
31
|
+
target: {},
|
|
32
|
+
via: null,
|
|
33
|
+
settings: {},
|
|
34
|
+
operation: { command: "version" },
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseVersionFormat(args: readonly string[]): VersionFormat {
|
|
40
|
+
let format: VersionFormat = "text";
|
|
41
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
42
|
+
const arg = args[index];
|
|
43
|
+
if (arg === "--json") {
|
|
44
|
+
format = "json";
|
|
45
|
+
} else if (arg === "--yaml") {
|
|
46
|
+
format = "yaml";
|
|
47
|
+
} else if (arg === "--format") {
|
|
48
|
+
const value = args[++index];
|
|
49
|
+
if (!versionFormats.includes(value as VersionFormat)) {
|
|
50
|
+
throw new CentrsError({
|
|
51
|
+
code: "settings/invalid-format",
|
|
52
|
+
summary: `--format must be one of ${versionFormats.join(", ")}; got ${value ?? "(missing)"}.`,
|
|
53
|
+
remediation: `Choose one of ${versionFormats.join(", ")}.`,
|
|
54
|
+
context: { format: value },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
format = value as VersionFormat;
|
|
58
|
+
} else {
|
|
59
|
+
throw new CentrsError({
|
|
60
|
+
code: "input/invalid-command",
|
|
61
|
+
summary: `\`centrs --version\` takes only an output format; got ${arg}.`,
|
|
62
|
+
remediation:
|
|
63
|
+
"Run `centrs --version`, or `centrs --version --json` for the envelope.",
|
|
64
|
+
context: { arg },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return format;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function runVersionCli(args: readonly string[]): number {
|
|
72
|
+
let format: VersionFormat;
|
|
73
|
+
try {
|
|
74
|
+
format = parseVersionFormat(args);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error(formatCentrsErrorText(error as CentrsError));
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
if (format === "json") {
|
|
80
|
+
console.log(JSON.stringify(versionEnvelope(), null, 2));
|
|
81
|
+
} else if (format === "yaml") {
|
|
82
|
+
console.log(toYaml(versionEnvelope()));
|
|
83
|
+
} else {
|
|
84
|
+
console.log(centrsVersion);
|
|
85
|
+
}
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { retrieveCommand, runRetrieveCli } from "./cli/retrieve.ts";
|
|
|
12
12
|
import { runSettingsCli, settingsCommand } from "./cli/settings.ts";
|
|
13
13
|
import { runTerminalCli, terminalCommand } from "./cli/terminal.ts";
|
|
14
14
|
import { runTransferCli, transferCommand } from "./cli/transfer.ts";
|
|
15
|
+
import { runVersionCli } from "./cli/version.ts";
|
|
15
16
|
import { asCentrsError, formatCentrsErrorText } from "./errors.ts";
|
|
16
17
|
import { describeCentrs, plannedProtocols, plannedSurfaces } from "./index.ts";
|
|
17
18
|
|
|
@@ -48,7 +49,7 @@ export function renderCliHelp(): string {
|
|
|
48
49
|
(command) => ` ${command.name.padEnd(12)} ${command.summary}`,
|
|
49
50
|
),
|
|
50
51
|
"",
|
|
51
|
-
"Use `centrs <command> --help` for command-specific options.",
|
|
52
|
+
"Use `centrs <command> --help` for command-specific options, and `centrs --version` (`-V`) for the version alone.",
|
|
52
53
|
].join("\n");
|
|
53
54
|
}
|
|
54
55
|
|
|
@@ -60,6 +61,9 @@ export async function runCli(
|
|
|
60
61
|
console.log(renderCliHelp());
|
|
61
62
|
return 0;
|
|
62
63
|
}
|
|
64
|
+
if (args[0] === "--version" || args[0] === "-V") {
|
|
65
|
+
return runVersionCli(args.slice(1));
|
|
66
|
+
}
|
|
63
67
|
|
|
64
68
|
const [command, ...rest] = args;
|
|
65
69
|
if (command === "retrieve") {
|
|
@@ -122,7 +126,6 @@ export async function runCli(
|
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
console.error(
|
|
125
|
-
// codeql[js/clear-text-logging] password reaches here only as SettingSource provenance ({kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
|
|
126
129
|
formatCentrsErrorText(
|
|
127
130
|
asCentrsError(new Error(`Unknown centrs command: ${command}`), {
|
|
128
131
|
code: "input/invalid-command",
|
|
@@ -137,7 +140,6 @@ export async function runCli(
|
|
|
137
140
|
// to a structured envelope, so reaching here means an unconverted throw
|
|
138
141
|
// escaped. Never let a raw stack trace reach the user.
|
|
139
142
|
console.error(
|
|
140
|
-
// codeql[js/clear-text-logging] password reaches here only as SettingSource provenance ({kind,key}), never the value — see CommonSettingsMeta.password in src/core/envelope.ts.
|
|
141
143
|
formatCentrsErrorText(
|
|
142
144
|
asCentrsError(error, {
|
|
143
145
|
code: "internal/unhandled",
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #453: name the fleet a CMR controller command reaches, before its write gate.
|
|
3
|
+
*
|
|
4
|
+
* A controller command such as `/cmr/device/reboot labels=all` is one request
|
|
5
|
+
* to one router, but it acts on every device its `labels`/`numbers` select,
|
|
6
|
+
* the controller's own row included. This module recognizes those commands,
|
|
7
|
+
* reads the controller's `/cmr/device` inventory (read-only), and resolves the
|
|
8
|
+
* selection host-side so the gate can say "N devices, the controller
|
|
9
|
+
* included" and warn on the manual's no-selector hazards. The command itself
|
|
10
|
+
* still runs exactly as typed; nothing here rewrites it.
|
|
11
|
+
*
|
|
12
|
+
* Grounded on a 7.26beta1 two-CHR lab (2026-10-15):
|
|
13
|
+
*
|
|
14
|
+
* - The controller's own row carries `controller=true`.
|
|
15
|
+
* - The manual's label rules evaluated over a row's `labels` ∪ `auto-labels`
|
|
16
|
+
* matched the device's own selection (a `run-script numbers=<id>
|
|
17
|
+
* labels=<expr>` probe per row) in every expression tried: `all`, `-x`,
|
|
18
|
+
* an identity, an architecture, an unknown label, `a,+b`, `a,-b`,
|
|
19
|
+
* `all,-a`, and a case change (labels are case-sensitive).
|
|
20
|
+
* - A row's `auto-labels` can be stale while the device is disconnected, so
|
|
21
|
+
* the result is the controller's current view, which the wording says.
|
|
22
|
+
* - Each row names its `upgrade-rule`, so `/cmr/upgrade/trigger` resolves
|
|
23
|
+
* exactly. `/cmr/upgrade/show-devices` is not reachable over REST (404).
|
|
24
|
+
* - A `/cmr/vlan` or `/cmr/wifi` item added without `labels` stores none.
|
|
25
|
+
* Per the manual, such a VLAN rule (no `port-labels` either) provisions
|
|
26
|
+
* every port of every connected device, the controller included, and an
|
|
27
|
+
* upgrade rule without labels covers every device.
|
|
28
|
+
*
|
|
29
|
+
* Not resolved, so the gate keeps its plain wording: script-shaped input (a
|
|
30
|
+
* positional or `[find …]` selector), an abbreviated selector argument, a
|
|
31
|
+
* console transport (no structured read), `/cmr/upgrade/job/run-next` (the
|
|
32
|
+
* job's device list is not in the inventory), and a failed read.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { ProtocolAdapter } from "../protocols/adapter.ts";
|
|
36
|
+
import {
|
|
37
|
+
CMR_SCRIPT_POLICIES,
|
|
38
|
+
type CmrCallerPolicy,
|
|
39
|
+
cmrCallerPolicyWarning,
|
|
40
|
+
readCmrCallerPolicy,
|
|
41
|
+
} from "./cmr.ts";
|
|
42
|
+
import type { Warning } from "./envelope.ts";
|
|
43
|
+
|
|
44
|
+
type FleetKind = "device" | "rule" | "trigger" | "job";
|
|
45
|
+
|
|
46
|
+
interface FleetEntry {
|
|
47
|
+
menu: readonly string[];
|
|
48
|
+
verbs: readonly string[];
|
|
49
|
+
kind: FleetKind;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const RULE_VERBS = ["add", "set", "remove", "enable", "disable"] as const;
|
|
53
|
+
|
|
54
|
+
/** Controller commands that act on the devices their selection names. */
|
|
55
|
+
const FLEET_COMMANDS: readonly FleetEntry[] = [
|
|
56
|
+
{
|
|
57
|
+
menu: ["cmr", "device"],
|
|
58
|
+
verbs: ["reboot", "upgrade", "pair", "run-script"],
|
|
59
|
+
kind: "device",
|
|
60
|
+
},
|
|
61
|
+
{ menu: ["cmr", "upgrade"], verbs: ["trigger"], kind: "trigger" },
|
|
62
|
+
{ menu: ["cmr", "upgrade", "job"], verbs: ["run-next"], kind: "job" },
|
|
63
|
+
{ menu: ["cmr", "upgrade"], verbs: RULE_VERBS, kind: "rule" },
|
|
64
|
+
{ menu: ["cmr", "vlan"], verbs: RULE_VERBS, kind: "rule" },
|
|
65
|
+
{ menu: ["cmr", "wifi"], verbs: RULE_VERBS, kind: "rule" },
|
|
66
|
+
{ menu: ["cmr", "wifi", "radio"], verbs: RULE_VERBS, kind: "rule" },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The policies a fleet command runs with on the clients, where #456 measured
|
|
71
|
+
* that RouterOS does not check them against the controller login (7.26beta1):
|
|
72
|
+
* `run-script` needs only `read` + `api`, `reboot` only `read` + `test` +
|
|
73
|
+
* `api`. Other fleet commands were not measured, so they carry no warning.
|
|
74
|
+
*/
|
|
75
|
+
const RUNS_WITH: Readonly<Record<string, readonly string[]>> = {
|
|
76
|
+
"/cmr/device/run-script": CMR_SCRIPT_POLICIES,
|
|
77
|
+
"/cmr/device/reboot": ["reboot"],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** WiFi band labels select radios, not devices (manual: `+2ghz`, `+5ghz`, `+6ghz`). */
|
|
81
|
+
const BAND_LABEL = /^\+[256]ghz$/;
|
|
82
|
+
|
|
83
|
+
const SELECTOR_ARGUMENTS = ["labels", "port-labels", "numbers", "number"];
|
|
84
|
+
|
|
85
|
+
/** A recognized fleet command, before anything is read. */
|
|
86
|
+
export interface CmrFleetCommand {
|
|
87
|
+
/** Full spelling, e.g. `/cmr/device/reboot` (abbreviations expanded). */
|
|
88
|
+
command: string;
|
|
89
|
+
menu: string;
|
|
90
|
+
verb: string;
|
|
91
|
+
kind: FleetKind;
|
|
92
|
+
attributes: Readonly<Record<string, string>>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One `/cmr/device` row the selection reaches. */
|
|
96
|
+
export interface CmrFleetDevice {
|
|
97
|
+
id: string;
|
|
98
|
+
identity: string;
|
|
99
|
+
controller: boolean;
|
|
100
|
+
connected: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What the gate says about a fleet command. */
|
|
104
|
+
export interface CmrFleetScope {
|
|
105
|
+
command: string;
|
|
106
|
+
/** The devices reached, by the controller's current inventory; absent when not resolved. */
|
|
107
|
+
devices?: CmrFleetDevice[];
|
|
108
|
+
/** Size of the controller's `/cmr/device` inventory. */
|
|
109
|
+
inventory?: number;
|
|
110
|
+
/** Why `devices` is absent. */
|
|
111
|
+
unresolved?: string;
|
|
112
|
+
/** A rule without a device selector (applies to every device). */
|
|
113
|
+
everyDevice?: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Recognize a fleet command from a structured path (`/cmr/device/reboot`,
|
|
118
|
+
* each segment possibly abbreviated as RouterOS accepts) and its attributes.
|
|
119
|
+
*/
|
|
120
|
+
export function cmrFleetCommand(
|
|
121
|
+
path: string,
|
|
122
|
+
attributes: Readonly<Record<string, string>>,
|
|
123
|
+
): CmrFleetCommand | undefined {
|
|
124
|
+
const segments = path
|
|
125
|
+
.split("/")
|
|
126
|
+
.filter((segment) => segment.length > 0)
|
|
127
|
+
.map((segment) => segment.toLowerCase());
|
|
128
|
+
for (const entry of FLEET_COMMANDS) {
|
|
129
|
+
if (segments.length !== entry.menu.length + 1) continue;
|
|
130
|
+
const menuMatches = entry.menu.every((name, k) =>
|
|
131
|
+
name.startsWith(segments[k] as string),
|
|
132
|
+
);
|
|
133
|
+
if (!menuMatches) continue;
|
|
134
|
+
const typed = segments.at(-1) as string;
|
|
135
|
+
const verbs = entry.verbs.filter((verb) => verb.startsWith(typed));
|
|
136
|
+
const verb = verbs.includes(typed) ? typed : verbs[0];
|
|
137
|
+
if (verb === undefined || (verbs.length > 1 && !verbs.includes(typed))) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const menu = `/${entry.menu.join("/")}`;
|
|
141
|
+
return {
|
|
142
|
+
command: `${menu}/${verb}`,
|
|
143
|
+
menu,
|
|
144
|
+
verb,
|
|
145
|
+
kind: entry.kind,
|
|
146
|
+
attributes,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Whether a comma-separated CMR `labels` expression selects a device with
|
|
154
|
+
* these labels. Plain labels OR together, `+label` must also match, `-label`
|
|
155
|
+
* excludes, `all` matches every device, and an expression with no plain label
|
|
156
|
+
* starts from every device (`-core` = `all,-core`). Case-sensitive.
|
|
157
|
+
*/
|
|
158
|
+
export function cmrLabelsSelect(
|
|
159
|
+
expression: string,
|
|
160
|
+
labels: ReadonlySet<string>,
|
|
161
|
+
): boolean {
|
|
162
|
+
let plain = false;
|
|
163
|
+
let matched = false;
|
|
164
|
+
for (const raw of expression.split(",")) {
|
|
165
|
+
const item = raw.trim();
|
|
166
|
+
if (item.length === 0) continue;
|
|
167
|
+
if (item.startsWith("+")) {
|
|
168
|
+
if (!labels.has(item.slice(1))) return false;
|
|
169
|
+
} else if (item.startsWith("-")) {
|
|
170
|
+
if (labels.has(item.slice(1))) return false;
|
|
171
|
+
} else {
|
|
172
|
+
plain = true;
|
|
173
|
+
if (item === "all" || labels.has(item)) matched = true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return plain ? matched : true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
type Row = Record<string, string>;
|
|
180
|
+
|
|
181
|
+
function labelSet(row: Row): Set<string> {
|
|
182
|
+
return new Set(
|
|
183
|
+
`${row["labels"] ?? ""},${row["auto-labels"] ?? ""}`
|
|
184
|
+
.split(",")
|
|
185
|
+
.map((label) => label.trim())
|
|
186
|
+
.filter((label) => label.length > 0),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function toDevice(row: Row): CmrFleetDevice {
|
|
191
|
+
return {
|
|
192
|
+
id: row[".id"] ?? "",
|
|
193
|
+
identity: row["identity"] ?? "",
|
|
194
|
+
controller: row["controller"] === "true",
|
|
195
|
+
connected: row["connected"] === "true",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function listItems(value: string): string[] {
|
|
200
|
+
return value
|
|
201
|
+
.split(",")
|
|
202
|
+
.map((item) => item.trim())
|
|
203
|
+
.filter((item) => item.length > 0);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A `numbers=` item names a row by `.id` (any case) or by its `name`/`identity`. */
|
|
207
|
+
function rowNamed(row: Row, item: string, nameField: string): boolean {
|
|
208
|
+
return (
|
|
209
|
+
row[".id"]?.toLowerCase() === item.toLowerCase() || row[nameField] === item
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Drop band labels from a WiFi expression; they pick radios, not devices. */
|
|
214
|
+
function deviceLabels(command: CmrFleetCommand, labels: string): string {
|
|
215
|
+
if (!command.menu.startsWith("/cmr/wifi")) return labels;
|
|
216
|
+
return listItems(labels)
|
|
217
|
+
.filter((item) => !BAND_LABEL.test(item))
|
|
218
|
+
.join(",");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function abbreviatedSelector(
|
|
222
|
+
attributes: Readonly<Record<string, string>>,
|
|
223
|
+
): string | undefined {
|
|
224
|
+
return Object.keys(attributes).find(
|
|
225
|
+
(key) =>
|
|
226
|
+
!SELECTOR_ARGUMENTS.includes(key) &&
|
|
227
|
+
SELECTOR_ARGUMENTS.some((name) => name.startsWith(key)),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Resolve which inventory rows a fleet command reaches. `read` returns a
|
|
233
|
+
* menu's rows (`/cmr/device`, or the rule's own menu for `set`/`remove`).
|
|
234
|
+
*/
|
|
235
|
+
export async function resolveCmrFleetScope(
|
|
236
|
+
command: CmrFleetCommand,
|
|
237
|
+
read: (menu: string) => Promise<Row[]>,
|
|
238
|
+
): Promise<CmrFleetScope> {
|
|
239
|
+
const scope = (fields: Omit<CmrFleetScope, "command">): CmrFleetScope => ({
|
|
240
|
+
command: command.command,
|
|
241
|
+
...fields,
|
|
242
|
+
});
|
|
243
|
+
const abbreviated = abbreviatedSelector(command.attributes);
|
|
244
|
+
if (abbreviated !== undefined) {
|
|
245
|
+
return scope({
|
|
246
|
+
unresolved: `the abbreviated argument \`${abbreviated}=\` was not expanded`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (command.kind === "job") {
|
|
250
|
+
return scope({
|
|
251
|
+
unresolved: "an upgrade job's devices are not in the inventory",
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
const rows = await read("/cmr/device");
|
|
255
|
+
const inventory = rows.length;
|
|
256
|
+
const select = (labels: string | undefined, numbers?: string) => {
|
|
257
|
+
const ids = numbers === undefined ? undefined : listItems(numbers);
|
|
258
|
+
return rows
|
|
259
|
+
.filter(
|
|
260
|
+
(row) =>
|
|
261
|
+
(ids === undefined ||
|
|
262
|
+
ids.some((item) => rowNamed(row, item, "identity"))) &&
|
|
263
|
+
(labels === undefined || cmrLabelsSelect(labels, labelSet(row))),
|
|
264
|
+
)
|
|
265
|
+
.map(toDevice);
|
|
266
|
+
};
|
|
267
|
+
const { attributes } = command;
|
|
268
|
+
|
|
269
|
+
if (command.kind === "device") {
|
|
270
|
+
const labels = attributes["labels"]?.trim() || undefined;
|
|
271
|
+
const numbers = attributes["numbers"]?.trim() || undefined;
|
|
272
|
+
if (labels === undefined && numbers === undefined) {
|
|
273
|
+
return scope({ inventory, unresolved: "no `numbers` or `labels`" });
|
|
274
|
+
}
|
|
275
|
+
return scope({ inventory, devices: select(labels, numbers) });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (command.kind === "trigger") {
|
|
279
|
+
const numbers = attributes["numbers"]?.trim();
|
|
280
|
+
if (!numbers) return scope({ inventory, unresolved: "no `numbers`" });
|
|
281
|
+
const rules = await read("/cmr/upgrade");
|
|
282
|
+
const names = new Set(
|
|
283
|
+
listItems(numbers).flatMap((item) =>
|
|
284
|
+
rules
|
|
285
|
+
.filter((rule) => rowNamed(rule, item, "name"))
|
|
286
|
+
.map((rule) => rule["name"] ?? ""),
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
return scope({
|
|
290
|
+
inventory,
|
|
291
|
+
devices: rows
|
|
292
|
+
.filter((row) => names.has(row["upgrade-rule"] ?? ""))
|
|
293
|
+
.map(toDevice),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// A rule: `add` uses its own selectors; the others act on existing items,
|
|
298
|
+
// and a `set` that changes a selector reaches the old and the new state.
|
|
299
|
+
// Each state is judged on its own: one unscoped rule among several still
|
|
300
|
+
// reaches every device.
|
|
301
|
+
const states: { labels?: string; portLabels?: string }[] = [];
|
|
302
|
+
if (command.verb === "add") {
|
|
303
|
+
states.push({
|
|
304
|
+
labels: attributes["labels"],
|
|
305
|
+
portLabels: attributes["port-labels"],
|
|
306
|
+
});
|
|
307
|
+
} else {
|
|
308
|
+
const numbers = attributes["numbers"]?.trim();
|
|
309
|
+
if (!numbers) return scope({ inventory, unresolved: "no `numbers`" });
|
|
310
|
+
const items = await read(command.menu);
|
|
311
|
+
const named = listItems(numbers).map((item) =>
|
|
312
|
+
items.find((rule) => rowNamed(rule, item, "name")),
|
|
313
|
+
);
|
|
314
|
+
if (named.some((rule) => rule === undefined)) {
|
|
315
|
+
return scope({
|
|
316
|
+
inventory,
|
|
317
|
+
unresolved: `\`numbers=${numbers}\` did not match a ${command.menu} item`,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
for (const rule of named as Row[]) {
|
|
321
|
+
const old = { labels: rule["labels"], portLabels: rule["port-labels"] };
|
|
322
|
+
states.push(old);
|
|
323
|
+
if (
|
|
324
|
+
command.verb === "set" &&
|
|
325
|
+
("labels" in attributes || "port-labels" in attributes)
|
|
326
|
+
) {
|
|
327
|
+
states.push({
|
|
328
|
+
labels: attributes["labels"] ?? old.labels,
|
|
329
|
+
portLabels: attributes["port-labels"] ?? old.portLabels,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const reached = new Map<string, CmrFleetDevice>();
|
|
335
|
+
let everyDevice = false;
|
|
336
|
+
for (const state of states) {
|
|
337
|
+
const labels = deviceLabels(command, state.labels?.trim() ?? "");
|
|
338
|
+
// VLAN: no `labels` but `port-labels` still narrows which ports change.
|
|
339
|
+
const narrowedByPort =
|
|
340
|
+
command.menu === "/cmr/vlan" && (state.portLabels?.trim() ?? "") !== "";
|
|
341
|
+
if (labels.length === 0 && !narrowedByPort) everyDevice = true;
|
|
342
|
+
for (const device of select(labels || "all")) {
|
|
343
|
+
reached.set(device.id, device);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return scope({ inventory, devices: [...reached.values()], everyDevice });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The gate's phrase for a scope, e.g. `2 CMR device(s) through controller
|
|
351
|
+
* ctl, the controller itself among them`.
|
|
352
|
+
*/
|
|
353
|
+
function describeReach(scope: CmrFleetScope, controller: string): string {
|
|
354
|
+
if (scope.devices === undefined) {
|
|
355
|
+
return `the CMR devices it selects through controller ${controller} (not resolved: ${scope.unresolved})`;
|
|
356
|
+
}
|
|
357
|
+
const self = scope.devices.some((device) => device.controller);
|
|
358
|
+
return `${scope.devices.length} CMR device(s) through controller ${controller}${self ? ", the controller itself among them" : ""}`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Warnings for a resolved scope: the controller in it, every device, nothing. */
|
|
362
|
+
export function cmrFleetWarnings(scope: CmrFleetScope): Warning[] {
|
|
363
|
+
const warnings: Warning[] = [];
|
|
364
|
+
if (scope.everyDevice) {
|
|
365
|
+
const subject = scope.command.endsWith("/add")
|
|
366
|
+
? `\`${scope.command}\` adds a rule`
|
|
367
|
+
: `\`${scope.command}\` acts on a rule`;
|
|
368
|
+
warnings.push({
|
|
369
|
+
code: "cmr/rule-selects-every-device",
|
|
370
|
+
message: scope.command.startsWith("/cmr/vlan/")
|
|
371
|
+
? `${subject} with no \`labels\` and no \`port-labels\`: it provisions every port of every connected device, the controller included, which cuts the fleet's management access.`
|
|
372
|
+
: `${subject} with no device \`labels\`: it applies to every device the controller manages, the controller included.`,
|
|
373
|
+
context: { command: scope.command, inventory: scope.inventory },
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
if (scope.devices === undefined) return warnings;
|
|
377
|
+
const controller = scope.devices.find((device) => device.controller);
|
|
378
|
+
if (controller && !scope.everyDevice) {
|
|
379
|
+
warnings.push({
|
|
380
|
+
code: "cmr/selection-includes-controller",
|
|
381
|
+
message: `\`${scope.command}\` selects the controller's own row (\`${controller.identity}\`) as well as ${scope.devices.length - 1} other device(s).`,
|
|
382
|
+
context: {
|
|
383
|
+
command: scope.command,
|
|
384
|
+
controller: controller.identity,
|
|
385
|
+
devices: scope.devices.length,
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
if (scope.devices.length === 0) {
|
|
390
|
+
warnings.push({
|
|
391
|
+
code: "cmr/empty-selection",
|
|
392
|
+
message: `\`${scope.command}\` selects no device in the controller's inventory now. Labels are case-sensitive and RouterOS accepts unknown ones silently.`,
|
|
393
|
+
context: { command: scope.command, inventory: scope.inventory },
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return warnings;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The gate's error context for a fleet scope. */
|
|
400
|
+
export function cmrFleetContext(scope: CmrFleetScope): Record<string, unknown> {
|
|
401
|
+
return {
|
|
402
|
+
command: scope.command,
|
|
403
|
+
...(scope.devices === undefined
|
|
404
|
+
? { unresolved: scope.unresolved }
|
|
405
|
+
: {
|
|
406
|
+
devices: scope.devices.length,
|
|
407
|
+
connected: scope.devices.filter((device) => device.connected).length,
|
|
408
|
+
controllerIncluded: scope.devices.some((device) => device.controller),
|
|
409
|
+
identities: scope.devices.map((device) => device.identity),
|
|
410
|
+
}),
|
|
411
|
+
...(scope.inventory === undefined ? {} : { inventory: scope.inventory }),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Result of {@link readCmrFleet}: the scope and the warnings it raises. */
|
|
416
|
+
export interface CmrFleetRead {
|
|
417
|
+
scope: CmrFleetScope;
|
|
418
|
+
warnings: Warning[];
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The write gate's wording for a fleet command: the TTY prompt and the
|
|
423
|
+
* refusal summary name the reach and repeat each warning, since a person
|
|
424
|
+
* answering the prompt never sees the envelope's `warnings`.
|
|
425
|
+
*/
|
|
426
|
+
export function cmrFleetGate(
|
|
427
|
+
fleet: CmrFleetRead,
|
|
428
|
+
controller: string,
|
|
429
|
+
): { prompt: string; summary: string; context: Record<string, unknown> } {
|
|
430
|
+
const reach = describeReach(fleet.scope, controller);
|
|
431
|
+
const notes = fleet.warnings
|
|
432
|
+
.filter((warning) => warning.code !== "cmr/selection-includes-controller")
|
|
433
|
+
.map((warning) => ` ${warning.message}`)
|
|
434
|
+
.join("");
|
|
435
|
+
return {
|
|
436
|
+
prompt: `Run write-shaped \`${fleet.scope.command}\` on ${reach}?${notes} Type yes to continue: `,
|
|
437
|
+
summary: `A write-shaped \`${fleet.scope.command}\` would reach ${reach}, and needs explicit confirmation.${notes}`,
|
|
438
|
+
context: { cmr: cmrFleetContext(fleet.scope) },
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Whether an adapter for this transport can do the structured scope read. */
|
|
443
|
+
export function cmrFleetReadable(via: string): boolean {
|
|
444
|
+
return via === "native-api" || via === "rest-api";
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Read a fleet command's scope (and, for `run-script`/`reboot`, the caller's
|
|
449
|
+
* policy) on the controller. Best effort: a failed read leaves the scope
|
|
450
|
+
* unresolved and the gate as strict as before.
|
|
451
|
+
*/
|
|
452
|
+
export async function readCmrFleet(
|
|
453
|
+
command: CmrFleetCommand,
|
|
454
|
+
adapter: Pick<ProtocolAdapter, "apiRequest">,
|
|
455
|
+
username: string | undefined,
|
|
456
|
+
): Promise<CmrFleetRead> {
|
|
457
|
+
const read = async (menu: string) => {
|
|
458
|
+
const result = await adapter.apiRequest({ verb: "print", path: menu });
|
|
459
|
+
return Array.isArray(result.data) ? (result.data as Row[]) : [];
|
|
460
|
+
};
|
|
461
|
+
let scope: CmrFleetScope;
|
|
462
|
+
let unreachable = false;
|
|
463
|
+
try {
|
|
464
|
+
scope = await resolveCmrFleetScope(command, read);
|
|
465
|
+
} catch (error) {
|
|
466
|
+
const code =
|
|
467
|
+
error instanceof Error && "code" in error ? String(error.code) : "error";
|
|
468
|
+
// The policy read would wait out the same dead controller (or login).
|
|
469
|
+
unreachable = code.startsWith("transport/") || code.startsWith("auth/");
|
|
470
|
+
scope = {
|
|
471
|
+
command: command.command,
|
|
472
|
+
unresolved: `the controller read failed (${code})`,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
const warnings = cmrFleetWarnings(scope);
|
|
476
|
+
const runsWith = RUNS_WITH[command.command];
|
|
477
|
+
if (runsWith !== undefined && username && !unreachable) {
|
|
478
|
+
const caller: CmrCallerPolicy | undefined = await readCmrCallerPolicy(
|
|
479
|
+
adapter,
|
|
480
|
+
username,
|
|
481
|
+
);
|
|
482
|
+
const warning = caller && cmrCallerPolicyWarning(caller, runsWith);
|
|
483
|
+
if (warning) warnings.push(warning);
|
|
484
|
+
}
|
|
485
|
+
return { scope, warnings };
|
|
486
|
+
}
|