dsh-ensp 0.7.0 → 0.8.0
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/README.en.md +86 -16
- package/README.md +85 -15
- package/docs/ENSP-FORMAT.md +8 -8
- package/docs/SCENARIOS.md +332 -286
- package/docs/UI-ANCHORS.md +21 -0
- package/docs/plugin-detail.en.md +2 -0
- package/docs/plugin-detail.zh.md +2 -0
- package/package.json +2 -4
- package/scripts/gen-scenarios-doc.mjs +7 -3
- package/scripts/survey-topologies.mjs +15 -6
- package/src/cli/ensp.mjs +252 -28
- package/src/core/acceptance.mjs +258 -53
- package/src/core/build.mjs +15 -1
- package/src/core/compat.mjs +249 -17
- package/src/core/console.mjs +4 -0
- package/src/core/doctor.mjs +9 -12
- package/src/core/docx.mjs +359 -0
- package/src/core/index.mjs +28 -9
- package/src/core/labconfig.mjs +98 -0
- package/src/core/labs.mjs +12 -12
- package/src/core/launcher.mjs +96 -37
- package/src/core/models.mjs +27 -18
- package/src/core/ooxml.mjs +430 -0
- package/src/core/paths.mjs +59 -10
- package/src/core/presets.mjs +9 -5
- package/src/core/report.mjs +411 -0
- package/src/dsh/index.mjs +626 -652
- package/src/mcp/server.mjs +213 -356
- package/src/tools.mjs +271 -0
- package/screenshots.json +0 -3
- package/scripts/gen-knowledge.mjs +0 -196
- package/src/core/knowledge.json +0 -959
- package/src/core/knowledge.mjs +0 -103
package/src/mcp/server.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { readFile, writeFile, readdir } from 'node:fs/promises'
|
|
20
20
|
import path from 'node:path'
|
|
21
21
|
|
|
22
|
-
import { buildTopology, twoPcLab, twoPcLabConfig, autoLayout
|
|
22
|
+
import { buildTopology, twoPcLab, twoPcLabConfig, autoLayout } from '../core/build.mjs'
|
|
23
23
|
import { readTopology, writeTopology, summarise } from '../core/topology.mjs'
|
|
24
24
|
import {
|
|
25
25
|
LAB_IDS,
|
|
@@ -28,395 +28,252 @@ import {
|
|
|
28
28
|
labAddressing,
|
|
29
29
|
labDevices,
|
|
30
30
|
} from '../core/labs.mjs'
|
|
31
|
-
import {
|
|
31
|
+
import { countLabConfigs, hasLabConfig, labConfigScript } from '../core/labconfig.mjs'
|
|
32
|
+
import { checkTopologySupport, pluginMediaPlan, pluginModels, installPluginMedia } from '../core/compat.mjs'
|
|
32
33
|
import {
|
|
33
34
|
enspPid,
|
|
34
35
|
locateWindow,
|
|
35
36
|
launchEnsp,
|
|
37
|
+
showEnsp,
|
|
36
38
|
startAllDevices,
|
|
37
39
|
stopAllDevices,
|
|
38
40
|
waitForConsole,
|
|
39
41
|
} from '../core/launcher.mjs'
|
|
40
42
|
import { calibrateAnchors, inspectAnchors, DEFAULT_ANCHORS } from '../core/calibrate.mjs'
|
|
41
43
|
import { doctor, modelsOf } from '../core/doctor.mjs'
|
|
42
|
-
import { runAcceptance, hasChecks, EXPECTATIONS } from '../core/acceptance.mjs'
|
|
43
|
-
import { knowledgeFor, searchKnowledge, knowledgeTopics } from '../core/knowledge.mjs'
|
|
44
|
+
import { runAcceptance, hasChecks, EXPECTATIONS, formatAcceptance } from '../core/acceptance.mjs'
|
|
44
45
|
import { curriculumFor, renderCurriculum, searchCurriculum } from '../core/curriculum.mjs'
|
|
45
46
|
import { pushConfig, pushConfigMany, queryDevice } from '../core/provision.mjs'
|
|
47
|
+
import { writeReport } from '../core/report.mjs'
|
|
46
48
|
import { SCENARIOS, SCENARIO_IDS, byLevel, renderScenario, searchScenarios } from '../core/presets.mjs'
|
|
47
49
|
import { PACKAGE_NAME, VERSION } from '../core/version.mjs'
|
|
50
|
+
import { TOOL_SPECS, toInputSchema } from '../tools.mjs'
|
|
48
51
|
|
|
49
52
|
const SERVER_INFO = { name: PACKAGE_NAME, version: VERSION }
|
|
50
53
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Tool handlers, keyed by name.
|
|
56
|
+
*
|
|
57
|
+
* The names, descriptions and parameter schemas live in `../tools.mjs` so the MCP
|
|
58
|
+
* table and the DSH plugin table cannot drift apart. Only the implementations stay
|
|
59
|
+
* here, because the two front ends return different shapes.
|
|
60
|
+
*/
|
|
61
|
+
const HANDLERS = {
|
|
62
|
+
async ensp_status({ topology }) {
|
|
63
|
+
const pid = enspPid()
|
|
64
|
+
if (!pid) return { running: false }
|
|
65
|
+
const hwnd = locateWindow(pid)
|
|
66
|
+
const anchors = hwnd ? calibrateAnchors(hwnd) : null
|
|
67
|
+
const out = {
|
|
68
|
+
running: true,
|
|
69
|
+
pid,
|
|
70
|
+
hwnd,
|
|
71
|
+
anchors: anchors ? { start: anchors.start, stop: anchors.stop, method: anchors.method } : null,
|
|
72
|
+
}
|
|
73
|
+
if (topology) {
|
|
74
|
+
const topo = await readTopology(topology)
|
|
75
|
+
out.devices = await Promise.all(
|
|
76
|
+
topo.devices
|
|
77
|
+
.filter((d) => d.comPort)
|
|
78
|
+
.map(async (d) => {
|
|
79
|
+
const r = await waitForConsole({ port: d.comPort, timeoutMs: 4000 })
|
|
80
|
+
return { name: d.name, port: d.comPort, ready: r.ready, prompt: r.prompt }
|
|
81
|
+
}),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
},
|
|
86
|
+
async ensp_build_topology(args) {
|
|
87
|
+
let topo
|
|
88
|
+
if (args.scenario) {
|
|
89
|
+
if (!hasLab(args.scenario)) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`scenario "${args.scenario}" has no topology blueprint; ${LAB_IDS.length} of ${SCENARIO_IDS.length} have one: ${LAB_IDS.join(', ')}`,
|
|
82
92
|
)
|
|
83
93
|
}
|
|
84
|
-
|
|
85
|
-
}
|
|
94
|
+
topo = buildScenarioTopology(args.scenario)
|
|
95
|
+
} else if (args.preset === 'two-pc' || (!args.devices && !args.links)) {
|
|
96
|
+
topo = twoPcLab({ vlan: args.vlan ?? 10, subnet: args.subnet ?? '192.168.10' })
|
|
97
|
+
} else {
|
|
98
|
+
const b = buildTopology()
|
|
99
|
+
for (const d of args.devices ?? []) b.add(d.name, d.model, { x: d.x, y: d.y, pc: d.pc })
|
|
100
|
+
for (const l of args.links ?? []) b.link(l.a, l.aPort, l.b, l.bPort)
|
|
101
|
+
topo = autoLayout(b.finish())
|
|
102
|
+
}
|
|
103
|
+
const target = path.resolve(args.path)
|
|
104
|
+
await writeTopology(target, topo)
|
|
105
|
+
const support = checkTopologySupport(topo)
|
|
106
|
+
return {
|
|
107
|
+
path: target,
|
|
108
|
+
...summarise(topo),
|
|
109
|
+
...(args.scenario
|
|
110
|
+
? { scenario: args.scenario, devices: labDevices(args.scenario), addressing: labAddressing(args.scenario) }
|
|
111
|
+
: {}),
|
|
112
|
+
...(support.ok ? {} : { unsupportedDevices: support.blocked }),
|
|
113
|
+
}
|
|
86
114
|
},
|
|
87
|
-
{
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
'Generate a .topo file. Use `preset: "two-pc"` for the built-in two-PC/one-switch lab, ' +
|
|
91
|
-
'`scenario` to build the topology behind a scenario id, or pass `devices` and `links` ' +
|
|
92
|
-
'explicitly. Returns the written path and a summary.',
|
|
93
|
-
inputSchema: obj({
|
|
94
|
-
path: { type: 'string', description: 'output .topo path' },
|
|
95
|
-
preset: { type: 'string', enum: ['two-pc'], description: 'built-in lab' },
|
|
96
|
-
scenario: { type: 'string', description: 'scenario id with a topology blueprint' },
|
|
97
|
-
vlan: { type: 'number', description: 'for preset two-pc (default 10)' },
|
|
98
|
-
subnet: { type: 'string', description: 'for preset two-pc (default 192.168.10)' },
|
|
99
|
-
devices: {
|
|
100
|
-
type: 'array',
|
|
101
|
-
description: 'explicit device list',
|
|
102
|
-
items: obj({
|
|
103
|
-
name: { type: 'string' },
|
|
104
|
-
model: { type: 'string', enum: Object.keys(MODEL_SPECS) },
|
|
105
|
-
x: { type: 'number' },
|
|
106
|
-
y: { type: 'number' },
|
|
107
|
-
pc: obj({ ip: { type: 'string' }, gateway: { type: 'string' } }),
|
|
108
|
-
}),
|
|
109
|
-
},
|
|
110
|
-
links: {
|
|
111
|
-
type: 'array',
|
|
112
|
-
description: 'explicit links using VRP interface names',
|
|
113
|
-
items: obj({
|
|
114
|
-
a: { type: 'string' },
|
|
115
|
-
aPort: { type: 'string' },
|
|
116
|
-
b: { type: 'string' },
|
|
117
|
-
bPort: { type: 'string' },
|
|
118
|
-
}),
|
|
119
|
-
},
|
|
120
|
-
}),
|
|
121
|
-
async run(args) {
|
|
122
|
-
let topo
|
|
123
|
-
if (args.scenario) {
|
|
124
|
-
if (!hasLab(args.scenario)) {
|
|
125
|
-
throw new Error(
|
|
126
|
-
`scenario "${args.scenario}" has no topology blueprint; ${LAB_IDS.length} of ${SCENARIO_IDS.length} have one: ${LAB_IDS.join(', ')}`,
|
|
127
|
-
)
|
|
128
|
-
}
|
|
129
|
-
topo = buildScenarioTopology(args.scenario)
|
|
130
|
-
} else if (args.preset === 'two-pc' || (!args.devices && !args.links)) {
|
|
131
|
-
topo = twoPcLab({ vlan: args.vlan ?? 10, subnet: args.subnet ?? '192.168.10' })
|
|
132
|
-
} else {
|
|
133
|
-
const b = buildTopology()
|
|
134
|
-
for (const d of args.devices ?? []) b.add(d.name, d.model, { x: d.x, y: d.y, pc: d.pc })
|
|
135
|
-
for (const l of args.links ?? []) b.link(l.a, l.aPort, l.b, l.bPort)
|
|
136
|
-
topo = autoLayout(b.finish())
|
|
137
|
-
}
|
|
138
|
-
const target = path.resolve(args.path)
|
|
139
|
-
await writeTopology(target, topo)
|
|
140
|
-
const support = checkTopologySupport(topo)
|
|
141
|
-
return {
|
|
142
|
-
path: target,
|
|
143
|
-
...summarise(topo),
|
|
144
|
-
...(args.scenario
|
|
145
|
-
? { scenario: args.scenario, devices: labDevices(args.scenario), addressing: labAddressing(args.scenario) }
|
|
146
|
-
: {}),
|
|
147
|
-
...(support.ok ? {} : { unsupportedDevices: support.blocked }),
|
|
148
|
-
}
|
|
149
|
-
},
|
|
115
|
+
async ensp_open({ topology }) {
|
|
116
|
+
const launched = await launchEnsp({ topology: path.resolve(topology) })
|
|
117
|
+
return { pid: launched.pid, hwnd: launched.hwnd, reused: Boolean(launched.reused) }
|
|
150
118
|
},
|
|
151
|
-
{
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
119
|
+
async ensp_start(args) {
|
|
120
|
+
const topo = await readTopology(path.resolve(args.topology))
|
|
121
|
+
const hwnd = args.hwnd ?? locateWindow(enspPid())
|
|
122
|
+
if (!hwnd) throw new Error('eNSP window not found; call ensp_open first')
|
|
123
|
+
const result = await startAllDevices({
|
|
124
|
+
devices: topo.devices,
|
|
125
|
+
hwnd,
|
|
126
|
+
perDeviceTimeoutMs: args.perDeviceTimeoutMs ?? 240000,
|
|
127
|
+
minimise: !args.keepWindow,
|
|
128
|
+
})
|
|
129
|
+
return {
|
|
130
|
+
allReady: result.allReady,
|
|
131
|
+
ready: result.ready,
|
|
132
|
+
total: result.total,
|
|
133
|
+
anchors: result.anchors,
|
|
134
|
+
devices: result.devices.map((d) => ({ name: d.name, port: d.port, ready: d.ready, prompt: d.prompt, waitedMs: d.waitedMs })),
|
|
135
|
+
}
|
|
161
136
|
},
|
|
162
|
-
{
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
'measured, never assumed. Minimises the window afterwards unless keepWindow is set.',
|
|
168
|
-
inputSchema: obj(
|
|
169
|
-
{
|
|
170
|
-
topology: { type: 'string' },
|
|
171
|
-
hwnd: { type: 'string', description: 'optional; located automatically' },
|
|
172
|
-
perDeviceTimeoutMs: { type: 'number', description: 'default 240000' },
|
|
173
|
-
keepWindow: { type: 'boolean', description: 'leave the window visible' },
|
|
174
|
-
},
|
|
175
|
-
['topology'],
|
|
176
|
-
),
|
|
177
|
-
async run(args) {
|
|
178
|
-
const topo = await readTopology(path.resolve(args.topology))
|
|
179
|
-
const hwnd = args.hwnd ?? locateWindow(enspPid())
|
|
180
|
-
if (!hwnd) throw new Error('eNSP window not found; call ensp_open first')
|
|
181
|
-
const result = await startAllDevices({
|
|
182
|
-
devices: topo.devices,
|
|
183
|
-
hwnd,
|
|
184
|
-
perDeviceTimeoutMs: args.perDeviceTimeoutMs ?? 240000,
|
|
185
|
-
minimise: !args.keepWindow,
|
|
186
|
-
})
|
|
187
|
-
return {
|
|
188
|
-
allReady: result.allReady,
|
|
189
|
-
ready: result.ready,
|
|
190
|
-
total: result.total,
|
|
191
|
-
anchors: result.anchors,
|
|
192
|
-
devices: result.devices.map((d) => ({ name: d.name, port: d.port, ready: d.ready, prompt: d.prompt, waitedMs: d.waitedMs })),
|
|
193
|
-
}
|
|
194
|
-
},
|
|
137
|
+
async ensp_stop({ hwnd }) {
|
|
138
|
+
const target = hwnd ?? locateWindow(enspPid())
|
|
139
|
+
if (!target) throw new Error('eNSP window not found')
|
|
140
|
+
const r = stopAllDevices(target)
|
|
141
|
+
return { clicked: r.ok, anchors: r.anchors, detail: r.detail }
|
|
195
142
|
},
|
|
196
|
-
{
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
143
|
+
async ensp_apply({ device, port, script, save }) {
|
|
144
|
+
const r = await pushConfig({ device, port, script, save: Boolean(save) })
|
|
145
|
+
return {
|
|
146
|
+
ok: r.ok,
|
|
147
|
+
applied: r.applied,
|
|
148
|
+
total: r.total,
|
|
149
|
+
saved: r.saved,
|
|
150
|
+
durationMs: r.durationMs,
|
|
151
|
+
failedAt: r.failedAt,
|
|
152
|
+
}
|
|
206
153
|
},
|
|
207
|
-
{
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
saved: r.saved,
|
|
229
|
-
durationMs: r.durationMs,
|
|
230
|
-
failedAt: r.failedAt,
|
|
231
|
-
}
|
|
232
|
-
},
|
|
154
|
+
async ensp_apply_dir(args) {
|
|
155
|
+
const topo = await readTopology(path.resolve(args.topology))
|
|
156
|
+
const consoles = new Map(topo.devices.filter((d) => d.comPort).map((d) => [d.name, d.comPort]))
|
|
157
|
+
const files = (await readdir(args.directory)).filter((f) => /\.(cfg|txt)$/i.test(f))
|
|
158
|
+
const targets = []
|
|
159
|
+
for (const file of files) {
|
|
160
|
+
const device = file.replace(/\.(cfg|txt)$/i, '')
|
|
161
|
+
const port = consoles.get(device)
|
|
162
|
+
if (!port) continue
|
|
163
|
+
targets.push({ device, port, script: await readFile(path.join(args.directory, file), 'utf8') })
|
|
164
|
+
}
|
|
165
|
+
const results = await pushConfigMany({
|
|
166
|
+
targets,
|
|
167
|
+
concurrency: args.concurrency ?? 3,
|
|
168
|
+
save: Boolean(args.save),
|
|
169
|
+
})
|
|
170
|
+
return {
|
|
171
|
+
configured: results.filter((r) => r.ok).length,
|
|
172
|
+
total: results.length,
|
|
173
|
+
results: results.map((r) => ({ device: r.device, ok: r.ok, applied: r.applied, total: r.total, failedAt: r.failedAt })),
|
|
174
|
+
}
|
|
233
175
|
},
|
|
234
|
-
{
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
'Push one .cfg per device from a directory, matching file names to device names. ' +
|
|
238
|
-
'Devices are configured concurrently; a failure on one does not stop the others.',
|
|
239
|
-
inputSchema: obj(
|
|
240
|
-
{
|
|
241
|
-
directory: { type: 'string' },
|
|
242
|
-
topology: { type: 'string' },
|
|
243
|
-
save: { type: 'boolean' },
|
|
244
|
-
concurrency: { type: 'number', description: 'default 3' },
|
|
245
|
-
},
|
|
246
|
-
['directory', 'topology'],
|
|
247
|
-
),
|
|
248
|
-
async run(args) {
|
|
249
|
-
const topo = await readTopology(path.resolve(args.topology))
|
|
250
|
-
const consoles = new Map(topo.devices.filter((d) => d.comPort).map((d) => [d.name, d.comPort]))
|
|
251
|
-
const files = (await readdir(args.directory)).filter((f) => /\.(cfg|txt)$/i.test(f))
|
|
252
|
-
const targets = []
|
|
253
|
-
for (const file of files) {
|
|
254
|
-
const device = file.replace(/\.(cfg|txt)$/i, '')
|
|
255
|
-
const port = consoles.get(device)
|
|
256
|
-
if (!port) continue
|
|
257
|
-
targets.push({ device, port, script: await readFile(path.join(args.directory, file), 'utf8') })
|
|
258
|
-
}
|
|
259
|
-
const results = await pushConfigMany({
|
|
260
|
-
targets,
|
|
261
|
-
concurrency: args.concurrency ?? 3,
|
|
262
|
-
save: Boolean(args.save),
|
|
263
|
-
})
|
|
264
|
-
return {
|
|
265
|
-
configured: results.filter((r) => r.ok).length,
|
|
266
|
-
total: results.length,
|
|
267
|
-
results: results.map((r) => ({ device: r.device, ok: r.ok, applied: r.applied, total: r.total, failedAt: r.failedAt })),
|
|
268
|
-
}
|
|
269
|
-
},
|
|
176
|
+
async ensp_query({ port, command }) {
|
|
177
|
+
const r = await queryDevice({ port, command })
|
|
178
|
+
return { ok: r.ok, output: r.output, error: r.error }
|
|
270
179
|
},
|
|
271
|
-
{
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const r = await queryDevice({ port, command })
|
|
277
|
-
return { ok: r.ok, output: r.output, error: r.error }
|
|
278
|
-
},
|
|
180
|
+
async ensp_probe({ hwnd }) {
|
|
181
|
+
const target = hwnd ?? locateWindow(enspPid())
|
|
182
|
+
if (!target) throw new Error('eNSP window not found')
|
|
183
|
+
const r = inspectAnchors(target)
|
|
184
|
+
return { window: r.window, green: r.green, red: r.red, resolved: r.resolved, defaults: DEFAULT_ANCHORS }
|
|
279
185
|
},
|
|
280
|
-
{
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
'display scaling; the coordinates are cached per window size otherwise.',
|
|
285
|
-
inputSchema: obj({ hwnd: { type: 'string' } }),
|
|
286
|
-
async run({ hwnd }) {
|
|
287
|
-
const target = hwnd ?? locateWindow(enspPid())
|
|
288
|
-
if (!target) throw new Error('eNSP window not found')
|
|
289
|
-
const r = inspectAnchors(target)
|
|
290
|
-
return { window: r.window, green: r.green, red: r.red, resolved: r.resolved, defaults: DEFAULT_ANCHORS }
|
|
291
|
-
},
|
|
186
|
+
async ensp_show({ hwnd }) {
|
|
187
|
+
const target = hwnd ?? locateWindow(enspPid())
|
|
188
|
+
if (!target) throw new Error('eNSP window not found')
|
|
189
|
+
return showEnsp(target)
|
|
292
190
|
},
|
|
293
|
-
{
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
return ids.map((id) => ({
|
|
311
|
-
id,
|
|
312
|
-
title: SCENARIOS[id].title,
|
|
313
|
-
level: SCENARIOS[id].level,
|
|
314
|
-
goal: SCENARIOS[id].goal,
|
|
315
|
-
devices: SCENARIOS[id].devices,
|
|
316
|
-
source: SCENARIOS[id].source,
|
|
317
|
-
}))
|
|
318
|
-
},
|
|
191
|
+
async ensp_scenarios({ search, level, syllabus }) {
|
|
192
|
+
if (syllabus) return searchCurriculum(syllabus)
|
|
193
|
+
const groups = byLevel()
|
|
194
|
+
let ids = level ? groups[level] ?? [] : SCENARIO_IDS
|
|
195
|
+
if (search) {
|
|
196
|
+
const matched = new Set(searchScenarios(search).map((h) => h.id))
|
|
197
|
+
ids = ids.filter((id) => matched.has(id))
|
|
198
|
+
}
|
|
199
|
+
return ids.map((id) => ({
|
|
200
|
+
id,
|
|
201
|
+
title: SCENARIOS[id].title,
|
|
202
|
+
level: SCENARIOS[id].level,
|
|
203
|
+
goal: SCENARIOS[id].goal,
|
|
204
|
+
devices: SCENARIOS[id].devices,
|
|
205
|
+
source: SCENARIOS[id].source,
|
|
206
|
+
}))
|
|
319
207
|
},
|
|
320
|
-
{
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
brief: renderScenario(id) + renderCurriculum(id),
|
|
332
|
-
data: SCENARIOS[id],
|
|
333
|
-
hasTopology: hasLab(id),
|
|
334
|
-
hasCuratedChecks: hasChecks(id),
|
|
335
|
-
...(hasLab(id) ? { devices: labDevices(id), addressing: labAddressing(id) } : {}),
|
|
336
|
-
knowledge: knowledgeFor(id),
|
|
337
|
-
curriculum: curriculumFor(id),
|
|
338
|
-
}
|
|
339
|
-
},
|
|
208
|
+
async ensp_scenario({ id }) {
|
|
209
|
+
if (!SCENARIOS[id]) throw new Error(`unknown scenario "${id}"`)
|
|
210
|
+
return {
|
|
211
|
+
id,
|
|
212
|
+
brief: renderScenario(id) + renderCurriculum(id),
|
|
213
|
+
data: SCENARIOS[id],
|
|
214
|
+
hasTopology: hasLab(id),
|
|
215
|
+
hasCuratedChecks: hasChecks(id),
|
|
216
|
+
...(hasLab(id) ? { devices: labDevices(id), addressing: labAddressing(id) } : {}),
|
|
217
|
+
curriculum: curriculumFor(id),
|
|
218
|
+
}
|
|
340
219
|
},
|
|
341
|
-
{
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
topology: { type: 'string', description: 'optional .topo path, so device images can be checked' },
|
|
350
|
-
deep: { type: 'boolean', description: 'run the slow input-injection probe (default true)' },
|
|
351
|
-
}),
|
|
352
|
-
async run({ topology, deep }) {
|
|
353
|
-
let models
|
|
354
|
-
if (topology) models = modelsOf(await readTopology(topology))
|
|
355
|
-
return doctor({ models, deep: deep !== false })
|
|
356
|
-
},
|
|
220
|
+
async ensp_images({ model, source, overwrite }) {
|
|
221
|
+
if (source) {
|
|
222
|
+
if (!model) throw new Error('model is required when installing an image')
|
|
223
|
+
return installPluginMedia(model, source, { overwrite: overwrite === true })
|
|
224
|
+
}
|
|
225
|
+
const models = model ? [model] : pluginModels()
|
|
226
|
+
const plans = models.map((name) => pluginMediaPlan(name))
|
|
227
|
+
return { models: plans, ready: plans.every((plan) => plan.ready) }
|
|
357
228
|
},
|
|
358
|
-
{
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
'scenarios assert on what each device reports (OSPF Full, BGP Established, VRRP Master); ' +
|
|
363
|
-
'the rest smoke-test every verification command from the scenario. Use this to decide ' +
|
|
364
|
-
'whether the lab works, not just whether configuration was sent.',
|
|
365
|
-
inputSchema: obj({
|
|
366
|
-
scenario: { type: 'string', description: 'scenario id, e.g. "ospf-single"' },
|
|
367
|
-
topology: { type: 'string', description: 'the .topo file that is running (for console ports)' },
|
|
368
|
-
timeout: { type: 'number', description: 'per-command timeout in ms (default 25000)' },
|
|
369
|
-
wait: {
|
|
370
|
-
type: 'number',
|
|
371
|
-
description: 'how long a failing assertion may keep being retried, in ms (default 60000)',
|
|
372
|
-
},
|
|
373
|
-
}),
|
|
374
|
-
async run({ scenario, topology, timeout, wait }) {
|
|
375
|
-
if (!scenario) throw new Error('scenario is required')
|
|
376
|
-
if (!topology) throw new Error('topology is required: pass the .topo file that is running')
|
|
377
|
-
return runAcceptance({ id: scenario, topology, timeoutMs: timeout, waitMs: wait })
|
|
378
|
-
},
|
|
229
|
+
async ensp_doctor({ topology, deep }) {
|
|
230
|
+
let models
|
|
231
|
+
if (topology) models = modelsOf(await readTopology(topology))
|
|
232
|
+
return doctor({ models, deep: deep !== false })
|
|
379
233
|
},
|
|
380
|
-
{
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
query: { type: 'string', description: 'keyword, e.g. "LSA" or "MED"' },
|
|
390
|
-
scenario: { type: 'string', description: 'scenario id, to list what that lab teaches' },
|
|
391
|
-
syllabus: { type: 'string', description: 'keyword to search the manual chapter index instead' },
|
|
392
|
-
}),
|
|
393
|
-
async run({ query, scenario, syllabus }) {
|
|
394
|
-
if (syllabus) {
|
|
395
|
-
return { syllabus, chapters: searchCurriculum(syllabus) }
|
|
396
|
-
}
|
|
397
|
-
if (scenario) {
|
|
398
|
-
if (!SCENARIOS[scenario]) throw new Error(`unknown scenario "${scenario}"`)
|
|
399
|
-
return {
|
|
400
|
-
scenario,
|
|
401
|
-
topics: knowledgeTopics(),
|
|
402
|
-
points: knowledgeFor(scenario),
|
|
403
|
-
curriculum: curriculumFor(scenario),
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
if (!query) throw new Error('pass query, scenario or syllabus')
|
|
407
|
-
return { query, points: searchKnowledge(query) }
|
|
408
|
-
},
|
|
234
|
+
async ensp_verify({ scenario, topology, timeout, wait }) {
|
|
235
|
+
if (!scenario) throw new Error('scenario is required')
|
|
236
|
+
if (!topology) throw new Error('topology is required: pass the .topo file that is running')
|
|
237
|
+
const report = await runAcceptance({ id: scenario, topology, timeoutMs: timeout, waitMs: wait })
|
|
238
|
+
return {
|
|
239
|
+
...report,
|
|
240
|
+
verified: report.ok !== null,
|
|
241
|
+
summary: formatAcceptance(report),
|
|
242
|
+
}
|
|
409
243
|
},
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
return { script: twoPcLabConfig({ vlan: vlan ?? 10 }) }
|
|
417
|
-
},
|
|
244
|
+
async ensp_preset_config({ vlan, scenario }) {
|
|
245
|
+
if (!scenario) return { script: twoPcLabConfig({ vlan: vlan ?? 10 }) }
|
|
246
|
+
if (!hasLabConfig(scenario)) {
|
|
247
|
+
throw new Error(`scenario "${scenario}" has no addressing plan; ${countLabConfigs()} of them have one`)
|
|
248
|
+
}
|
|
249
|
+
return { script: labConfigScript(scenario) }
|
|
418
250
|
},
|
|
419
|
-
|
|
251
|
+
async ensp_report({ scenario, topology, out, timeout, wait }) {
|
|
252
|
+
if (!scenario) throw new Error('scenario is required')
|
|
253
|
+
const result = await writeReport({ id: scenario, topology, out, timeoutMs: timeout, waitMs: wait })
|
|
254
|
+
return {
|
|
255
|
+
path: result.path,
|
|
256
|
+
bytes: result.bytes,
|
|
257
|
+
scenario: result.evidence.id,
|
|
258
|
+
title: result.evidence.title,
|
|
259
|
+
verdict: result.evidence.verdict,
|
|
260
|
+
devices: result.evidence.devices.length,
|
|
261
|
+
checks: result.evidence.checks.length,
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The published tool table: shared specs plus the local handlers.
|
|
268
|
+
*
|
|
269
|
+
* Exported so the tests can compare it with what the DSH plugin registers.
|
|
270
|
+
*/
|
|
271
|
+
export const TOOLS = TOOL_SPECS.map((spec) => ({
|
|
272
|
+
name: spec.name,
|
|
273
|
+
description: spec.description,
|
|
274
|
+
inputSchema: toInputSchema(spec.params),
|
|
275
|
+
run: HANDLERS[spec.name],
|
|
276
|
+
}))
|
|
420
277
|
|
|
421
278
|
// ---------------------------------------------------------------------------
|
|
422
279
|
// JSON-RPC plumbing
|