raidcom-mcp 0.0.0-stage → 0.1.2
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 +57 -0
- package/INSTALL.md +169 -0
- package/LICENSE +21 -0
- package/README.docker.md +50 -0
- package/README.md +117 -2
- package/README.plugin.md +56 -0
- package/dist/builder.js +409 -0
- package/dist/catalog.js +79 -0
- package/dist/cmdline.js +145 -0
- package/dist/config.js +118 -0
- package/dist/http.js +198 -0
- package/dist/index.js +307 -0
- package/dist/recipes.js +307 -0
- package/dist/search.js +157 -0
- package/dist/tools.js +179 -0
- package/package.json +54 -4
- package/spec/examples.json +390 -0
- package/spec/limits.json +236 -0
- package/spec/notes/conventions.md +56 -0
- package/spec/notes/horcm-setup.md +27 -0
- package/spec/raidcom-catalog.json +1 -0
- package/spec/recipes.json +1106 -0
package/dist/builder.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// Validates a structured request against the catalogue and renders it as a raidcom command line.
|
|
2
|
+
//
|
|
3
|
+
// A request is a command name ("add lun"), an object of options ({"-port": ["CL1-A-0"], "-ldev_id": 200}) and a
|
|
4
|
+
// few global settings (instance, serial number). Validation is layered so the model gets precise, fixable
|
|
5
|
+
// messages instead of a rejected command with no explanation:
|
|
6
|
+
// errors the command would be refused by raidcom, or cannot be written safely; nothing is built
|
|
7
|
+
// warnings the command is built, but something looks wrong (an option from a different syntax form, an
|
|
8
|
+
// unusual value)
|
|
9
|
+
// notes what to do around the command (asynchronous completion, resource locking)
|
|
10
|
+
import { commandName, distance, isDestructive, isReadOnly } from "./catalog.js";
|
|
11
|
+
// ---------- small helpers ----------
|
|
12
|
+
/** Characters allowed in a value written without quotes. `#` is not among them: a word starting with it would make
|
|
13
|
+
* the shell ignore the rest of the line, so a value containing it is always quoted. */
|
|
14
|
+
const PLAIN = /^[A-Za-z0-9_.:\/@+,=%-]+$/;
|
|
15
|
+
/** Characters allowed in a value at all: plain ones and spaces (the value is then quoted). */
|
|
16
|
+
const SAFE = /^[A-Za-z0-9_.:\/@#+,=% -]+$/;
|
|
17
|
+
/** Single-quotes a value when it contains anything but plain characters. */
|
|
18
|
+
export function shellQuote(v) {
|
|
19
|
+
return PLAIN.test(v) ? v : `'${v.replace(/'/g, `'\\''`)}'`;
|
|
20
|
+
}
|
|
21
|
+
/** Values of one option as strings. `true` is an option without a value; `false`, null and undefined drop it. */
|
|
22
|
+
function toValues(v) {
|
|
23
|
+
if (v === undefined || v === null || v === false)
|
|
24
|
+
return undefined;
|
|
25
|
+
if (v === true)
|
|
26
|
+
return [];
|
|
27
|
+
if (Array.isArray(v))
|
|
28
|
+
return v.map((x) => String(x));
|
|
29
|
+
return [String(v)];
|
|
30
|
+
}
|
|
31
|
+
function expandName(flag) {
|
|
32
|
+
return flag.startsWith("-") ? flag : `-${flag}`;
|
|
33
|
+
}
|
|
34
|
+
/** Finds the catalogue spelling of an option the caller wrote loosely (ldev_id, -LDEV_ID, ldev-id). */
|
|
35
|
+
function resolveFlag(cmd, raw) {
|
|
36
|
+
const flags = Object.keys(cmd.flags);
|
|
37
|
+
const want = expandName(raw);
|
|
38
|
+
if (flags.includes(want))
|
|
39
|
+
return want;
|
|
40
|
+
const norm = (s) => s.toLowerCase().replace(/-/g, "_");
|
|
41
|
+
return flags.find((f) => norm(f) === norm(want));
|
|
42
|
+
}
|
|
43
|
+
function nearest(cmd, raw, n = 3) {
|
|
44
|
+
const want = expandName(raw).toLowerCase();
|
|
45
|
+
return Object.keys(cmd.flags)
|
|
46
|
+
.map((f) => ({ f, d: distance(f.toLowerCase(), want) - (f.toLowerCase().includes(want.slice(1)) || want.includes(f.toLowerCase().slice(1)) ? 3 : 0) }))
|
|
47
|
+
.sort((a, b) => a.d - b.d)
|
|
48
|
+
.slice(0, n)
|
|
49
|
+
.map((x) => x.f);
|
|
50
|
+
}
|
|
51
|
+
const flagsOf = (n, acc = []) => {
|
|
52
|
+
if (n.t === "flag")
|
|
53
|
+
acc.push(n.name);
|
|
54
|
+
else if (n.t === "seq")
|
|
55
|
+
n.items.forEach((x) => flagsOf(x, acc));
|
|
56
|
+
else if (n.t === "alt")
|
|
57
|
+
n.alts.forEach((x) => flagsOf(x, acc));
|
|
58
|
+
else
|
|
59
|
+
flagsOf(n.item, acc);
|
|
60
|
+
return acc;
|
|
61
|
+
};
|
|
62
|
+
const label = (n) => [...new Set(flagsOf(n))].join(" ");
|
|
63
|
+
function fit(n, given) {
|
|
64
|
+
switch (n.t) {
|
|
65
|
+
case "flag":
|
|
66
|
+
return given.has(n.name) ? { missing: [], used: new Set([n.name]), leftovers: new Set() } : { missing: [n.name], used: new Set(), leftovers: new Set() };
|
|
67
|
+
case "seq": {
|
|
68
|
+
const out = { missing: [], used: new Set(), leftovers: new Set() };
|
|
69
|
+
for (const item of n.items) {
|
|
70
|
+
const r = fit(item, given);
|
|
71
|
+
out.missing.push(...r.missing);
|
|
72
|
+
r.used.forEach((x) => out.used.add(x));
|
|
73
|
+
r.leftovers.forEach((x) => out.leftovers.add(x));
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
case "opt": {
|
|
78
|
+
// Nothing inside an optional group is required, whatever was given: the manual shares options such as
|
|
79
|
+
// -request_id between groups, so insisting on "the rest of the group" would reject valid commands.
|
|
80
|
+
const used = new Set(flagsOf(n.item).filter((f) => given.has(f)));
|
|
81
|
+
return { missing: [], used, leftovers: new Set() };
|
|
82
|
+
}
|
|
83
|
+
case "grp":
|
|
84
|
+
return fit(n.item, given);
|
|
85
|
+
case "alt": {
|
|
86
|
+
const fits = n.alts.map((a) => fit(a, given));
|
|
87
|
+
const started = fits.map((f, i) => ({ f, i })).filter((x) => x.f.used.size > 0);
|
|
88
|
+
if (!started.length)
|
|
89
|
+
return { missing: [`one of: ${n.alts.map(label).join(" | ")}`], used: new Set(), leftovers: new Set() };
|
|
90
|
+
started.sort((a, b) => a.f.missing.length - b.f.missing.length || b.f.used.size - a.f.used.size || a.i - b.i);
|
|
91
|
+
const best = started[0].f;
|
|
92
|
+
const leftovers = new Set(best.leftovers);
|
|
93
|
+
for (const other of started.slice(1))
|
|
94
|
+
other.f.used.forEach((x) => !best.used.has(x) && leftovers.add(x));
|
|
95
|
+
return { missing: best.missing, used: best.used, leftovers };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// ---------- value checks ----------
|
|
100
|
+
const LDEV_FLAGS = new Set(["-ldev_id", "-tse_ldev_id", "-ese_ldev_id", "-mapping_ldev_id"]);
|
|
101
|
+
const num = (s) => (/^0x[0-9a-f]+$/i.test(s) ? parseInt(s, 16) : /^\d+$/.test(s) ? Number(s) : NaN);
|
|
102
|
+
function parsePort(v, limits) {
|
|
103
|
+
const m = /^CL([0-9A-Z])-([A-Z])(?:-(\d{1,3}))?$/i.exec(v);
|
|
104
|
+
if (!m)
|
|
105
|
+
return { ok: false, normal: v, message: `"${v}" is not a port name like CL1-A (or CL1-A-0 with a host group ID)` };
|
|
106
|
+
const col = m[1].toUpperCase();
|
|
107
|
+
const letter = m[2].toUpperCase();
|
|
108
|
+
const normal = `CL${col}-${letter}${m[3] !== undefined ? `-${m[3]}` : ""}`;
|
|
109
|
+
if (!limits.portColumns.includes(col))
|
|
110
|
+
return { ok: false, normal, message: `port column ${col} is outside ${limits.label} (columns ${limits.portColumns[0]} to ${limits.portColumns.at(-1)})` };
|
|
111
|
+
if (!limits.portLetters.includes(letter))
|
|
112
|
+
return { ok: false, normal, message: `port letter ${letter} is not available on ${limits.label} (valid: ${limits.portLetters.split("").join(", ")})` };
|
|
113
|
+
const lim = limits.portColumnsLimited;
|
|
114
|
+
if (lim && lim.cols.includes(col) && !lim.letters.includes(letter))
|
|
115
|
+
return { ok: false, normal, message: `port columns ${lim.cols[0]} to ${lim.cols.at(-1)} only have ports ${lim.letters.split("").join(", ")}` };
|
|
116
|
+
if (m[3] !== undefined && Number(m[3]) > 254)
|
|
117
|
+
return { ok: false, normal, message: `host group ID ${m[3]} is above 254` };
|
|
118
|
+
return { ok: true, normal };
|
|
119
|
+
}
|
|
120
|
+
/** Checks (and normalises) the values of one option. Returns the values to write. */
|
|
121
|
+
function checkValues(flag, values, ctx) {
|
|
122
|
+
const { cmd, limits, errors, warnings } = ctx;
|
|
123
|
+
const shape = cmd.flags[flag];
|
|
124
|
+
const range = (what, n, [lo, hi]) => {
|
|
125
|
+
if (n < lo || n > hi)
|
|
126
|
+
errors.push(`${flag}: ${what} ${n} is outside ${lo} to ${hi}${ctx.modelGiven ? ` for ${limits.label}` : " (the widest range of any model; pass `model` for a tighter check)"}`);
|
|
127
|
+
};
|
|
128
|
+
if (shape) {
|
|
129
|
+
if (values.length < shape.min)
|
|
130
|
+
errors.push(`${flag} needs ${shape.min === 1 ? "a value" : `at least ${shape.min} values`}${shape.placeholders?.length ? ` (${shape.placeholders.map((p) => `<${p}>`).join(" ")})` : ""}`);
|
|
131
|
+
if (shape.max !== null && values.length > shape.max) {
|
|
132
|
+
errors.push(`${flag} takes at most ${shape.max} value${shape.max === 1 ? "" : "s"}${shape.placeholders?.length ? ` (${shape.placeholders.map((p) => `<${p}>`).join(" ")})` : ""}; got ${values.length}: ${values.join(" ")}`);
|
|
133
|
+
}
|
|
134
|
+
if (shape.enum) {
|
|
135
|
+
for (const v of values) {
|
|
136
|
+
if (!shape.enum.some((e) => e.toLowerCase() === v.toLowerCase()))
|
|
137
|
+
errors.push(`${flag} must be ${shape.enum.map((e) => `"${e}"`).join(" or ")}, not "${v}"`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const out = [...values];
|
|
142
|
+
const literals = shape?.literals ?? [];
|
|
143
|
+
for (let i = 0; i < out.length; i++) {
|
|
144
|
+
const v = out[i];
|
|
145
|
+
if (!SAFE.test(v)) {
|
|
146
|
+
errors.push(`${flag}: value ${JSON.stringify(v)} contains characters that are not allowed in a raidcom value (letters, digits and _ . : / @ # + , = % - and spaces are)`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (literals.some((l) => l.toLowerCase() === v.toLowerCase()))
|
|
150
|
+
continue;
|
|
151
|
+
// raidcom would read a value such as -IH9 as an option of its own
|
|
152
|
+
if (/^\s*-[A-Za-z]/.test(v)) {
|
|
153
|
+
errors.push(`${flag}: value "${v}" starts with "-", so raidcom would read it as an option. Give each option separately in \`options\`.`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (LDEV_FLAGS.has(flag)) {
|
|
157
|
+
const parts = v.split("-");
|
|
158
|
+
// mainframe notation: CU:LDEV in hex, e.g. dd:0f (CU 0xdd, LDEV 0x0f) is LDEV number 0xdd0f
|
|
159
|
+
const cuLdev = (x) => (/^[0-9a-f]{1,2}:[0-9a-f]{2}$/i.test(x) ? parseInt(x.replace(":", ""), 16) : Number.NaN);
|
|
160
|
+
const nums = parts.map((x) => (x.includes(":") ? cuLdev(x) : num(x)));
|
|
161
|
+
if (parts.length > 2 || nums.some(Number.isNaN))
|
|
162
|
+
errors.push(`${flag}: "${v}" is not a decimal number, 0x hex number, CU:LDEV such as dd:0f, or range such as 100-110`);
|
|
163
|
+
else
|
|
164
|
+
nums.forEach((n) => range("LDEV ID", n, limits.ldev));
|
|
165
|
+
}
|
|
166
|
+
else if (flag === "-lun_id") {
|
|
167
|
+
const n = num(v);
|
|
168
|
+
if (Number.isNaN(n))
|
|
169
|
+
warnings.push(`-lun_id: "${v}" is not a number`);
|
|
170
|
+
else
|
|
171
|
+
range("LUN", n, limits.lun);
|
|
172
|
+
}
|
|
173
|
+
else if (flag === "-pool" || flag === "-pool_id") {
|
|
174
|
+
if (/^\d+$/.test(v))
|
|
175
|
+
range("pool ID", Number(v), limits.pool);
|
|
176
|
+
}
|
|
177
|
+
else if ((flag === "-port" && i === 0) || flag === "-additional_port") {
|
|
178
|
+
const p = parsePort(v, limits);
|
|
179
|
+
if (p.ok)
|
|
180
|
+
out[i] = p.normal;
|
|
181
|
+
else if (/^CL/i.test(v))
|
|
182
|
+
errors.push(`${flag}: ${p.message}`);
|
|
183
|
+
else
|
|
184
|
+
warnings.push(`${flag}: ${p.message}`);
|
|
185
|
+
}
|
|
186
|
+
else if (flag === "-parity_grp_id" && !/^\d+-\d+$/.test(v)) {
|
|
187
|
+
warnings.push(`-parity_grp_id: "${v}" is not in the form gno-sgno, for example 1-1`);
|
|
188
|
+
}
|
|
189
|
+
else if (flag === "-capacity" && !/^\d+(\.\d+)?\s?[A-Za-z]{0,5}$/.test(v)) {
|
|
190
|
+
warnings.push(`-capacity: "${v}" does not look like a size such as 100G, 500M or 2T`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
// ---------- global options ----------
|
|
196
|
+
const PAIR_TYPES = ["H", "M", "TC", "SI"];
|
|
197
|
+
/** Largest instance number accepted; HORCM instance numbers are small, this only keeps nonsense out. */
|
|
198
|
+
const MAX_INSTANCE = 9999;
|
|
199
|
+
/** Longest command name or option name looked up; longer input is refused before any fuzzy matching. */
|
|
200
|
+
const MAX_NAME = 64;
|
|
201
|
+
/**
|
|
202
|
+
* Validates the global options. Every value that reaches command or script text goes through here, whether the
|
|
203
|
+
* caller is build() or a script, so nothing in `global` is ever written unchecked.
|
|
204
|
+
*/
|
|
205
|
+
export function checkGlobal(raw, limits, modelGiven) {
|
|
206
|
+
const out = { errors: [], warnings: [], head: [], tail: [], instanceFlag: "" };
|
|
207
|
+
if (raw === undefined || raw === null)
|
|
208
|
+
return out;
|
|
209
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
210
|
+
out.errors.push("global must be an object such as {\"instance\": 0}");
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
const g = raw;
|
|
214
|
+
const known = ["instance", "pair_type", "serial", "unit", "fx", "nomsg", "checkmode", "store"];
|
|
215
|
+
for (const k of Object.keys(g))
|
|
216
|
+
if (!known.includes(k))
|
|
217
|
+
out.errors.push(`"${k}" is not a global option. Known: ${known.join(", ")}`);
|
|
218
|
+
if (g.nomsg === true)
|
|
219
|
+
out.head.push("-nomsg");
|
|
220
|
+
else if (g.nomsg !== undefined && g.nomsg !== false)
|
|
221
|
+
out.errors.push("nomsg must be true or false");
|
|
222
|
+
if (g.pair_type !== undefined && (typeof g.pair_type !== "string" || !PAIR_TYPES.includes(g.pair_type))) {
|
|
223
|
+
out.errors.push(`pair_type must be one of ${PAIR_TYPES.join(", ")}, not ${JSON.stringify(g.pair_type)}`);
|
|
224
|
+
}
|
|
225
|
+
else if (g.pair_type !== undefined && g.instance === undefined)
|
|
226
|
+
out.warnings.push("pair_type needs an instance number; it was ignored");
|
|
227
|
+
if (g.instance !== undefined) {
|
|
228
|
+
if (typeof g.instance !== "number" || !Number.isSafeInteger(g.instance) || g.instance < 0 || g.instance > MAX_INSTANCE) {
|
|
229
|
+
out.errors.push(`instance must be a whole number from 0 to ${MAX_INSTANCE}, not ${JSON.stringify(g.instance)}`);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
out.instance = g.instance;
|
|
233
|
+
out.instanceFlag = `-I${g.pair_type ?? ""}${g.instance}`;
|
|
234
|
+
out.tail.push(out.instanceFlag);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (g.serial !== undefined) {
|
|
238
|
+
const s = typeof g.serial === "number" || typeof g.serial === "string" ? String(g.serial) : "";
|
|
239
|
+
if (!/^\d{4,8}$/.test(s))
|
|
240
|
+
out.errors.push(`serial must be digits only, for example 810123 (got ${JSON.stringify(g.serial)})`);
|
|
241
|
+
else {
|
|
242
|
+
const n = Number(s);
|
|
243
|
+
if (n < limits.serial[0] || n > limits.serial[1]) {
|
|
244
|
+
const msg = `serial ${s} is outside ${limits.serial[0]} to ${limits.serial[1]}${modelGiven ? ` for ${limits.label}` : ""}. VSP 5000 series serials start with 5 (500000 to 599999) and VSP G1x00 / VSP F1500 add a leading 3`;
|
|
245
|
+
(modelGiven ? out.errors : out.warnings).push(msg);
|
|
246
|
+
}
|
|
247
|
+
out.tail.push("-s", s);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (g.unit !== undefined) {
|
|
251
|
+
if (typeof g.unit !== "number" || !Number.isSafeInteger(g.unit) || g.unit < 0 || g.unit > MAX_INSTANCE)
|
|
252
|
+
out.errors.push(`unit must be a whole number from 0 to ${MAX_INSTANCE}, not ${JSON.stringify(g.unit)}`);
|
|
253
|
+
else
|
|
254
|
+
out.tail.push("-u", String(g.unit));
|
|
255
|
+
}
|
|
256
|
+
if (g.fx === true)
|
|
257
|
+
out.tail.push("-fx");
|
|
258
|
+
else if (g.fx !== undefined && g.fx !== false)
|
|
259
|
+
out.errors.push("fx must be true or false");
|
|
260
|
+
if (g.checkmode !== undefined) {
|
|
261
|
+
if (g.checkmode !== "precheck")
|
|
262
|
+
out.errors.push(`checkmode must be "precheck", not ${JSON.stringify(g.checkmode)}`);
|
|
263
|
+
else
|
|
264
|
+
out.tail.push("-checkmode", "precheck");
|
|
265
|
+
}
|
|
266
|
+
if (g.store !== undefined) {
|
|
267
|
+
if (typeof g.store !== "string" || !PLAIN.test(g.store) || g.store.startsWith("-"))
|
|
268
|
+
out.errors.push(`store must be a plain file name (letters, digits and _ . : / @ + , = % -, not starting with -), not ${JSON.stringify(g.store)}`);
|
|
269
|
+
else
|
|
270
|
+
out.tail.push("-store", g.store);
|
|
271
|
+
}
|
|
272
|
+
if (out.errors.length) {
|
|
273
|
+
out.head = [];
|
|
274
|
+
out.tail = [];
|
|
275
|
+
out.instanceFlag = "";
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
// ---------- the builder ----------
|
|
280
|
+
function formLabel(f) {
|
|
281
|
+
return f.title ? `${f.title.replace(/:$/, "")}: ${f.usage}` : f.usage;
|
|
282
|
+
}
|
|
283
|
+
export function build(catalog, req) {
|
|
284
|
+
const errors = [];
|
|
285
|
+
const warnings = [];
|
|
286
|
+
const notes = [];
|
|
287
|
+
const fail = () => ({ ok: false, errors, warnings, notes });
|
|
288
|
+
if (typeof req.command !== "string" || !req.command.trim()) {
|
|
289
|
+
errors.push("command must be a command name such as \"add lun\"");
|
|
290
|
+
return fail();
|
|
291
|
+
}
|
|
292
|
+
if (req.command.length > MAX_NAME) {
|
|
293
|
+
errors.push(`Unknown raidcom command (the name is longer than ${MAX_NAME} characters). Use raidcom_list_commands to search.`);
|
|
294
|
+
return fail();
|
|
295
|
+
}
|
|
296
|
+
const cmd = catalog.find(req.command);
|
|
297
|
+
if (!cmd) {
|
|
298
|
+
errors.push(`Unknown raidcom command "${req.command}". Closest: ${catalog.suggest(req.command).join(", ")}. Use raidcom_list_commands to search.`);
|
|
299
|
+
return fail();
|
|
300
|
+
}
|
|
301
|
+
const name = commandName(cmd);
|
|
302
|
+
const modelHit = catalog.model(req.model);
|
|
303
|
+
if (req.model && !modelHit)
|
|
304
|
+
warnings.push(`Unknown model "${req.model}", so model-specific limits were not applied. Known: ${[...catalog.models.keys()].filter((k) => k !== "generic").join(", ")}.`);
|
|
305
|
+
const limits = modelHit?.limits ?? catalog.models.get("generic");
|
|
306
|
+
const ctx = { cmd, limits, modelGiven: !!modelHit, errors, warnings };
|
|
307
|
+
// options → catalogue spelling → values
|
|
308
|
+
const given = new Map();
|
|
309
|
+
const options = req.options ?? {};
|
|
310
|
+
if (typeof options !== "object" || Array.isArray(options)) {
|
|
311
|
+
errors.push("options must be an object of option names and values, such as {\"-ldev_id\": 200}");
|
|
312
|
+
return fail();
|
|
313
|
+
}
|
|
314
|
+
for (const [raw, v] of Object.entries(options)) {
|
|
315
|
+
const values = toValues(v);
|
|
316
|
+
if (values === undefined)
|
|
317
|
+
continue;
|
|
318
|
+
if (raw.length > MAX_NAME) {
|
|
319
|
+
errors.push(`An option name longer than ${MAX_NAME} characters is not an option of raidcom ${name}.`);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const flag = resolveFlag(cmd, raw);
|
|
323
|
+
if (!flag) {
|
|
324
|
+
errors.push(`"${raw}" is not an option of raidcom ${name}. Closest: ${nearest(cmd, raw).join(", ") || "none (this command has no options)"}.`);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
let vals = values;
|
|
328
|
+
const shape = cmd.flags[flag];
|
|
329
|
+
// "CL1-A-0 Linux" for an option that takes a port and a host group name is two values written as one
|
|
330
|
+
if (vals.length === 1 && /\s/.test(vals[0]) && shape.max !== 1)
|
|
331
|
+
vals = vals[0].trim().split(/\s+/);
|
|
332
|
+
if (given.has(flag))
|
|
333
|
+
warnings.push(`${flag} was given twice; the last value is used`);
|
|
334
|
+
given.set(flag, vals);
|
|
335
|
+
}
|
|
336
|
+
// which syntax form fits best
|
|
337
|
+
const set = new Set(given.keys());
|
|
338
|
+
let chosen;
|
|
339
|
+
let bestScore = Infinity;
|
|
340
|
+
for (const form of cmd.forms) {
|
|
341
|
+
const f = fit(form.ast, set);
|
|
342
|
+
const extras = [...set].filter((x) => !f.used.has(x)).length;
|
|
343
|
+
const score = f.missing.length * 10 + extras;
|
|
344
|
+
if (score < bestScore) {
|
|
345
|
+
bestScore = score;
|
|
346
|
+
chosen = { form, fit: f };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (chosen) {
|
|
350
|
+
const required = chosen.fit.missing.map((m) => `raidcom ${name} needs ${m}`);
|
|
351
|
+
if (required.length && cmd.forms.length > 1)
|
|
352
|
+
required.push(`Syntax forms of raidcom ${name}:\n${cmd.forms.map((f) => ` - ${formLabel(f)}`).join("\n")}`);
|
|
353
|
+
if (req.strict === false)
|
|
354
|
+
warnings.push(...required);
|
|
355
|
+
else
|
|
356
|
+
errors.push(...required);
|
|
357
|
+
const extras = [...set].filter((x) => !chosen.fit.used.has(x));
|
|
358
|
+
for (const x of extras) {
|
|
359
|
+
const elsewhere = cmd.forms.filter((f) => f !== chosen.form && flagsOf(f.ast).includes(x));
|
|
360
|
+
const inThis = flagsOf(chosen.form.ast).includes(x);
|
|
361
|
+
if (inThis)
|
|
362
|
+
warnings.push(`${x} is an alternative to another option you gave; raidcom expects one or the other (${chosen.form.usage})`);
|
|
363
|
+
else if (elsewhere.length)
|
|
364
|
+
warnings.push(`${x} belongs to a different syntax form than the one matched (${chosen.form.usage}); describe the command to see each form`);
|
|
365
|
+
else
|
|
366
|
+
warnings.push(`${x} is not part of any syntax form of raidcom ${name} but is listed among its options`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// values
|
|
370
|
+
const rendered = [];
|
|
371
|
+
for (const [flag, values] of given)
|
|
372
|
+
rendered.push([flag, checkValues(flag, values, ctx)]);
|
|
373
|
+
// global options
|
|
374
|
+
const gc = checkGlobal(req.global, limits, !!modelHit);
|
|
375
|
+
errors.push(...gc.errors);
|
|
376
|
+
warnings.push(...gc.warnings);
|
|
377
|
+
const head = gc.head;
|
|
378
|
+
const tail = gc.tail;
|
|
379
|
+
const g = (req.global ?? {});
|
|
380
|
+
if (errors.length)
|
|
381
|
+
return fail();
|
|
382
|
+
const argv = ["raidcom", cmd.action, cmd.object, ...head];
|
|
383
|
+
for (const [flag, values] of rendered)
|
|
384
|
+
argv.push(flag, ...values);
|
|
385
|
+
argv.push(...tail);
|
|
386
|
+
const readOnly = isReadOnly(cmd);
|
|
387
|
+
const destructive = isDestructive(cmd);
|
|
388
|
+
if (cmd.async)
|
|
389
|
+
notes.push("Runs asynchronously: confirm it finished with `raidcom get command_status` before relying on the result (add -request_id auto where the command accepts it).");
|
|
390
|
+
if (destructive)
|
|
391
|
+
notes.push("This command removes configuration or data. Check the target IDs before running it.");
|
|
392
|
+
if (!readOnly && !g.checkmode)
|
|
393
|
+
notes.push("Use global.checkmode=precheck to have raidcom check the command without changing the array.");
|
|
394
|
+
if (!readOnly)
|
|
395
|
+
notes.push("Changing commands should run with the resource group locked (raidcom lock resource) when other users manage the array.");
|
|
396
|
+
return {
|
|
397
|
+
ok: true,
|
|
398
|
+
command: argv.map(shellQuote).join(" "),
|
|
399
|
+
argv,
|
|
400
|
+
name,
|
|
401
|
+
form: chosen ? { title: chosen.form.title, usage: chosen.form.usage } : undefined,
|
|
402
|
+
readOnly,
|
|
403
|
+
destructive,
|
|
404
|
+
async: cmd.async,
|
|
405
|
+
errors,
|
|
406
|
+
warnings,
|
|
407
|
+
notes,
|
|
408
|
+
};
|
|
409
|
+
}
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The raidcom command catalogue (spec/raidcom-catalog.json, produced by scripts/generate-catalog.mjs) and the
|
|
2
|
+
// model limits (spec/limits.json).
|
|
3
|
+
import { loadJson } from "./config.js";
|
|
4
|
+
/** Actions that only read. Everything else changes the storage system or its monitoring state. */
|
|
5
|
+
const READ_ACTIONS = new Set(["get", "discover", "check", "send"]);
|
|
6
|
+
/** Actions that remove configuration or data. */
|
|
7
|
+
const DESTRUCTIVE_ACTIONS = new Set(["delete", "initialize", "unmap", "disconnect"]);
|
|
8
|
+
export const isReadOnly = (c) => READ_ACTIONS.has(c.action);
|
|
9
|
+
export const isDestructive = (c) => DESTRUCTIVE_ACTIONS.has(c.action);
|
|
10
|
+
export const commandName = (c) => `${c.action} ${c.object}`;
|
|
11
|
+
export class Catalog {
|
|
12
|
+
commands;
|
|
13
|
+
models;
|
|
14
|
+
source;
|
|
15
|
+
generated;
|
|
16
|
+
aliases = new Map();
|
|
17
|
+
constructor(catalogPath, limitsPath) {
|
|
18
|
+
const file = loadJson(catalogPath, "command catalogue");
|
|
19
|
+
this.source = file.source;
|
|
20
|
+
this.generated = file.generated;
|
|
21
|
+
this.commands = new Map(Object.entries(file.commands));
|
|
22
|
+
const limits = loadJson(limitsPath, "model limits");
|
|
23
|
+
this.models = new Map(Object.entries(limits.models));
|
|
24
|
+
for (const [id, m] of this.models) {
|
|
25
|
+
this.aliases.set(squash(id), id);
|
|
26
|
+
this.aliases.set(squash(m.label), id);
|
|
27
|
+
for (const a of m.aliases ?? [])
|
|
28
|
+
this.aliases.set(squash(a), id);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** "raidcom add lun", "add lun" or "lun add" → the catalogue entry. */
|
|
32
|
+
find(name) {
|
|
33
|
+
const words = name.trim().toLowerCase().replace(/^raidcom\s+/, "").split(/\s+/).filter(Boolean);
|
|
34
|
+
if (words.length !== 2)
|
|
35
|
+
return undefined;
|
|
36
|
+
return this.commands.get(`${words[0]} ${words[1]}`) ?? this.commands.get(`${words[1]} ${words[0]}`);
|
|
37
|
+
}
|
|
38
|
+
/** Names close to a mistyped one, for error messages. */
|
|
39
|
+
suggest(name, n = 4) {
|
|
40
|
+
// edit distance costs length × length; a name this long matches nothing anyway
|
|
41
|
+
if (name.length > 64)
|
|
42
|
+
return [];
|
|
43
|
+
const words = name.trim().toLowerCase().replace(/^raidcom\s+/, "").split(/\s+/).filter(Boolean);
|
|
44
|
+
const target = words.join(" ");
|
|
45
|
+
const scored = [...this.commands.keys()].map((k) => {
|
|
46
|
+
let score = distance(k, target);
|
|
47
|
+
// sharing the object or the action with the request makes a name a better suggestion
|
|
48
|
+
const [a, o] = k.split(" ");
|
|
49
|
+
if (words.includes(o))
|
|
50
|
+
score -= 3;
|
|
51
|
+
if (words.includes(a))
|
|
52
|
+
score -= 1;
|
|
53
|
+
return { k, score };
|
|
54
|
+
});
|
|
55
|
+
return scored.sort((x, y) => x.score - y.score).slice(0, n).map((x) => x.k);
|
|
56
|
+
}
|
|
57
|
+
model(id) {
|
|
58
|
+
if (!id)
|
|
59
|
+
return undefined;
|
|
60
|
+
const key = this.aliases.get(squash(id));
|
|
61
|
+
const limits = key ? this.models.get(key) : undefined;
|
|
62
|
+
return key && limits ? { id: key, limits } : undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const squash = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
66
|
+
/** Levenshtein distance. */
|
|
67
|
+
export function distance(a, b) {
|
|
68
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
69
|
+
for (let i = 1; i <= a.length; i++) {
|
|
70
|
+
let diag = prev[0];
|
|
71
|
+
prev[0] = i;
|
|
72
|
+
for (let j = 1; j <= b.length; j++) {
|
|
73
|
+
const tmp = prev[j];
|
|
74
|
+
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
75
|
+
diag = tmp;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return prev[b.length];
|
|
79
|
+
}
|
package/dist/cmdline.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/** Splits on whitespace, honouring single and double quotes. Returns undefined for unbalanced quotes. */
|
|
2
|
+
export function splitWords(line) {
|
|
3
|
+
const words = [];
|
|
4
|
+
let cur = "";
|
|
5
|
+
let started = false;
|
|
6
|
+
let quote = null;
|
|
7
|
+
for (const ch of line) {
|
|
8
|
+
if (quote) {
|
|
9
|
+
if (ch === quote)
|
|
10
|
+
quote = null;
|
|
11
|
+
else
|
|
12
|
+
cur += ch;
|
|
13
|
+
}
|
|
14
|
+
else if (ch === "'" || ch === '"') {
|
|
15
|
+
quote = ch;
|
|
16
|
+
started = true;
|
|
17
|
+
}
|
|
18
|
+
else if (/\s/.test(ch)) {
|
|
19
|
+
if (started || cur)
|
|
20
|
+
words.push(cur);
|
|
21
|
+
cur = "";
|
|
22
|
+
started = false;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
cur += ch;
|
|
26
|
+
started = true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (quote)
|
|
30
|
+
return undefined;
|
|
31
|
+
if (started || cur)
|
|
32
|
+
words.push(cur);
|
|
33
|
+
return words;
|
|
34
|
+
}
|
|
35
|
+
const instanceFlag = /^-I(H|M|TC|SI)?(\d+)?$/;
|
|
36
|
+
export function parseCommandLine(catalog, line) {
|
|
37
|
+
const errors = [];
|
|
38
|
+
const warnings = [];
|
|
39
|
+
let text = line.trim().replace(/^(\$|#)\s*/, "");
|
|
40
|
+
// Several lines are a script, not one command. A trailing backslash continues a command onto the next line.
|
|
41
|
+
text = text.replace(/\\\r?\n\s*/g, " ");
|
|
42
|
+
if (/\r|\n/.test(text))
|
|
43
|
+
return { errors: ["Pass one raidcom command at a time (found several lines)."], warnings };
|
|
44
|
+
// Outside quotes, these let a shell run something other than raidcom.
|
|
45
|
+
const outside = text.replace(/'[^']*'|"[^"]*"/g, "");
|
|
46
|
+
if (/[;|&<>`]|\$\(|\$\{/.test(outside)) {
|
|
47
|
+
return { errors: ["The command contains a shell operator (; | & < > ` or $( ). Only a plain raidcom command can be checked."], warnings };
|
|
48
|
+
}
|
|
49
|
+
const words = splitWords(text);
|
|
50
|
+
if (!words)
|
|
51
|
+
return { errors: ["The command has an unclosed quote."], warnings };
|
|
52
|
+
if (words[0] !== "raidcom")
|
|
53
|
+
return { errors: [`The command must start with "raidcom", not "${words[0] ?? ""}".`], warnings };
|
|
54
|
+
if (words.length < 3 || words[1].startsWith("-") || words[2].startsWith("-")) {
|
|
55
|
+
return { errors: ["Expected `raidcom <action> <object> ...`, for example `raidcom get ldev -ldev_id 100`. Login, logout and script (-zt) forms are not part of the catalogue."], warnings };
|
|
56
|
+
}
|
|
57
|
+
const cmd = catalog.find(`${words[1]} ${words[2]}`);
|
|
58
|
+
if (!cmd)
|
|
59
|
+
return { errors: [`Unknown raidcom command "${words[1]} ${words[2]}". Closest: ${catalog.suggest(`${words[1]} ${words[2]}`).join(", ")}.`], warnings };
|
|
60
|
+
const options = Object.create(null);
|
|
61
|
+
const global = {};
|
|
62
|
+
const rest = words.slice(3);
|
|
63
|
+
for (let i = 0; i < rest.length;) {
|
|
64
|
+
const w = rest[i];
|
|
65
|
+
if (!/^-[A-Za-z]/.test(w)) {
|
|
66
|
+
errors.push(`Unexpected "${w}": a value must follow the option it belongs to. ${i === 0 ? "" : `(After ${rest[i - 1]}.)`}`.trim());
|
|
67
|
+
i++;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// Options of the command win over global options of the same name.
|
|
71
|
+
const own = cmd.flags[w] ?? Object.entries(cmd.flags).find(([k]) => k.toLowerCase() === w.toLowerCase())?.[1];
|
|
72
|
+
if (!own) {
|
|
73
|
+
const mi = instanceFlag.exec(w);
|
|
74
|
+
if (mi) {
|
|
75
|
+
if (global.instance !== undefined)
|
|
76
|
+
warnings.push(`${w}: the instance was given twice; the last one is used`);
|
|
77
|
+
if (mi[2] === undefined)
|
|
78
|
+
errors.push(`${w} needs an instance number, for example -I0 or -IH0.`);
|
|
79
|
+
else
|
|
80
|
+
global.instance = Number(mi[2]);
|
|
81
|
+
if (mi[1])
|
|
82
|
+
global.pair_type = mi[1];
|
|
83
|
+
i++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const next = rest[i + 1];
|
|
87
|
+
const hasValue = next !== undefined && !/^-[A-Za-z]/.test(next);
|
|
88
|
+
if ((w === "-s" || w === "-u") && hasValue && (w === "-s" ? global.serial : global.unit) !== undefined)
|
|
89
|
+
warnings.push(`${w} was given twice; the last value is used`);
|
|
90
|
+
if (w === "-s" && hasValue) {
|
|
91
|
+
global.serial = next;
|
|
92
|
+
i += 2;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
// a non-number stays text, so the error shows what was written
|
|
96
|
+
if (w === "-u" && hasValue) {
|
|
97
|
+
global.unit = (/^\d+$/.test(next) ? Number(next) : next);
|
|
98
|
+
i += 2;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (w === "-fx") {
|
|
102
|
+
global.fx = true;
|
|
103
|
+
i++;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (w === "-nomsg") {
|
|
107
|
+
global.nomsg = true;
|
|
108
|
+
i++;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (w === "-checkmode" && hasValue) {
|
|
112
|
+
global.checkmode = next;
|
|
113
|
+
i += 2;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (w === "-store" && hasValue) {
|
|
117
|
+
global.store = next;
|
|
118
|
+
i += 2;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (w === "-login") {
|
|
122
|
+
warnings.push("-login and its user name and password were left out; run raidcom -login separately so credentials stay out of command text.");
|
|
123
|
+
// exactly the two words after -login are the user name and password, whatever they look like
|
|
124
|
+
i += 3;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (w === "-logout") {
|
|
128
|
+
warnings.push("-logout was left out.");
|
|
129
|
+
i++;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// collect the values that follow
|
|
134
|
+
const max = own ? own.max : null;
|
|
135
|
+
const values = [];
|
|
136
|
+
let j = i + 1;
|
|
137
|
+
while (j < rest.length && !/^-[A-Za-z]/.test(rest[j]) && (max === null || values.length < max))
|
|
138
|
+
values.push(rest[j++]);
|
|
139
|
+
if (Object.hasOwn(options, w))
|
|
140
|
+
warnings.push(`${w} was given twice; the last value is used`);
|
|
141
|
+
options[w] = values.length ? values : true;
|
|
142
|
+
i = j;
|
|
143
|
+
}
|
|
144
|
+
return { request: { command: `${cmd.action} ${cmd.object}`, options, global }, errors, warnings };
|
|
145
|
+
}
|