dsh-ensp 0.1.9 → 0.2.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.md +58 -9
- package/docs/SCENARIOS.md +53 -3
- package/docs/plugin-detail.en.md +1 -0
- package/docs/plugin-detail.zh.md +1 -0
- package/package.json +70 -70
- package/scripts/gen-scenarios-doc.mjs +6 -1
- package/src/cli/ensp.mjs +95 -2
- package/src/core/acceptance.mjs +345 -0
- package/src/core/calibrate.mjs +354 -345
- package/src/core/doctor.mjs +350 -0
- package/src/core/index.mjs +23 -0
- package/src/core/launcher.mjs +22 -1
- package/src/dsh/index.mjs +105 -1
- package/src/mcp/server.mjs +42 -0
package/src/cli/ensp.mjs
CHANGED
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
waitForConsole,
|
|
32
32
|
} from '../core/launcher.mjs'
|
|
33
33
|
import { calibrateAnchors, inspectAnchors, DEFAULT_ANCHORS } from '../core/calibrate.mjs'
|
|
34
|
+
import { doctor, formatDoctor, modelsOf } from '../core/doctor.mjs'
|
|
35
|
+
import { runAcceptance, formatAcceptance, hasChecks, planFor } from '../core/acceptance.mjs'
|
|
34
36
|
import { pushConfig } from '../core/provision.mjs'
|
|
35
37
|
import { SCENARIOS, SCENARIO_IDS, byLevel, renderScenario, searchScenarios } from '../core/presets.mjs'
|
|
36
38
|
import {
|
|
@@ -58,6 +60,8 @@ ${C.bold('USAGE')}
|
|
|
58
60
|
ensp up <topology.topo> [--stop] [--keep-window]
|
|
59
61
|
ensp apply <config-dir> --topology <topology.topo> [--save] [--device NAME]
|
|
60
62
|
ensp status [--topology <topology.topo>]
|
|
63
|
+
ensp doctor [--topology <topology.topo>] [--json] [--shallow]
|
|
64
|
+
ensp verify <scenario-id> --topology <topology.topo> [--json] [--wait MS]
|
|
61
65
|
ensp probe [--json]
|
|
62
66
|
ensp stop [--topology <topology.topo>]
|
|
63
67
|
ensp preset [<id>] [--search <keyword>] [--topo <out.topo>] [--json]
|
|
@@ -69,12 +73,17 @@ ${C.bold('OPTIONS')}
|
|
|
69
73
|
--save run "save" on each device after its configuration
|
|
70
74
|
--device NAME apply only to one device
|
|
71
75
|
--timeout MS per-device readiness timeout (default 240000)
|
|
76
|
+
--wait MS verify: how long a failing assertion may keep being retried (default 60000)
|
|
72
77
|
--topo PATH write the scenario's topology blueprint to a .topo file
|
|
78
|
+
--topology PATH the .topo file that is running (doctor, verify)
|
|
79
|
+
--shallow doctor: skip the input-injection probe
|
|
73
80
|
--json machine-readable output
|
|
74
81
|
-h, --help show this help
|
|
75
82
|
|
|
76
83
|
${C.bold('EXAMPLES')}
|
|
77
84
|
ensp up lab.topo
|
|
85
|
+
ensp doctor --topology lab.topo
|
|
86
|
+
ensp verify ospf-single --topology lab.topo
|
|
78
87
|
ensp apply ./configs --topology lab.topo --save
|
|
79
88
|
ensp status --topology lab.topo
|
|
80
89
|
ensp probe
|
|
@@ -86,8 +95,10 @@ ${C.bold('EXAMPLES')}
|
|
|
86
95
|
${C.bold('NOTES')}
|
|
87
96
|
· eNSP must be installed and runnable; it requires administrator rights.
|
|
88
97
|
· Starting devices briefly focuses the eNSP window (it has to be clicked),
|
|
89
|
-
then the window is minimised again.
|
|
98
|
+
then the window is minimised again. Run doctor first if a start does nothing.
|
|
90
99
|
· Readiness is verified per device by polling its console, never by sleeping.
|
|
100
|
+
· Verification is separate from configuration: ensp verify asserts on what
|
|
101
|
+
each device actually reports.
|
|
91
102
|
`
|
|
92
103
|
|
|
93
104
|
/** Parse argv into { verb, positional, flags }. */
|
|
@@ -145,7 +156,9 @@ function deviceLine(event) {
|
|
|
145
156
|
|
|
146
157
|
// --------------------------------------------------------------------------- up
|
|
147
158
|
async function cmdUp({ positional, flags }) {
|
|
148
|
-
|
|
159
|
+
// `ensp stop --topology x.topo` is documented in the help text, so accept the flag
|
|
160
|
+
// as well as the positional form; both mean the same file.
|
|
161
|
+
const topoPath = positional[0] ?? flags.topology
|
|
149
162
|
if (!topoPath) {
|
|
150
163
|
console.error(C.red('error:') + ' missing topology path')
|
|
151
164
|
return 2
|
|
@@ -424,6 +437,82 @@ async function cmdProbe({ flags }) {
|
|
|
424
437
|
return r.resolved?.method === 'measured' ? 0 : 1
|
|
425
438
|
}
|
|
426
439
|
|
|
440
|
+
/**
|
|
441
|
+
* `ensp doctor [--topology t.topo] [--json] [--shallow]`
|
|
442
|
+
*
|
|
443
|
+
* Answers "why did nothing happen?" before a lab starts. Every failing check
|
|
444
|
+
* carries its own fix, because the failure modes here (missing image, covered
|
|
445
|
+
* window, dead input queue) all look the same from the outside.
|
|
446
|
+
*/
|
|
447
|
+
async function cmdDoctor({ positional, flags }) {
|
|
448
|
+
let models
|
|
449
|
+
if (flags.topology || positional[0]) {
|
|
450
|
+
const file = flags.topology || positional[0]
|
|
451
|
+
try {
|
|
452
|
+
models = modelsOf(await readTopology(file))
|
|
453
|
+
} catch (error) {
|
|
454
|
+
console.error(C.red('error:') + ' ' + error.message)
|
|
455
|
+
return 1
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const report = doctor({
|
|
459
|
+
exe: flags.exe,
|
|
460
|
+
vbox: flags.vbox,
|
|
461
|
+
models,
|
|
462
|
+
deep: !flags.shallow,
|
|
463
|
+
})
|
|
464
|
+
if (flags.json) {
|
|
465
|
+
console.log(JSON.stringify(report, null, 2))
|
|
466
|
+
return report.ok ? 0 : 1
|
|
467
|
+
}
|
|
468
|
+
console.log(formatDoctor(report))
|
|
469
|
+
return report.ok ? 0 : 1
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* `ensp verify <scenario-id> --topology t.topo [--json]`
|
|
474
|
+
*
|
|
475
|
+
* Runs the scenario's acceptance checks against a lab that is already up. This
|
|
476
|
+
* is the difference between "the commands were sent" and "the lab works": a
|
|
477
|
+
* curated check asserts on what the device reports, and a smoke check at least
|
|
478
|
+
* proves every verification command still parses.
|
|
479
|
+
*/
|
|
480
|
+
async function cmdVerify({ positional, flags }) {
|
|
481
|
+
const id = positional[0]
|
|
482
|
+
if (!id) {
|
|
483
|
+
console.error(C.red('error:') + ' usage: ensp verify <scenario-id> --topology <topology.topo>')
|
|
484
|
+
return 2
|
|
485
|
+
}
|
|
486
|
+
if (!SCENARIOS[id]) {
|
|
487
|
+
console.error(C.red('error:') + ` unknown scenario "${id}"`)
|
|
488
|
+
return 2
|
|
489
|
+
}
|
|
490
|
+
const topology = flags.topology
|
|
491
|
+
if (!topology) {
|
|
492
|
+
console.error(C.red('error:') + ' verify needs --topology <the .topo file that is running>')
|
|
493
|
+
return 2
|
|
494
|
+
}
|
|
495
|
+
const report = await runAcceptance({
|
|
496
|
+
id,
|
|
497
|
+
topology,
|
|
498
|
+
timeoutMs: flags.timeout ? Number(flags.timeout) : undefined,
|
|
499
|
+
waitMs: flags.wait ? Number(flags.wait) : undefined,
|
|
500
|
+
onProgress: flags.json
|
|
501
|
+
? undefined
|
|
502
|
+
: (e) => {
|
|
503
|
+
if (e.phase === 'check') process.stderr.write(C.dim(` checking ${e.id} on ${e.device}\n`))
|
|
504
|
+
else if (e.phase === 'retry')
|
|
505
|
+
process.stderr.write(C.dim(` waiting ${Math.round(e.waited / 1000)}s for ${e.id} on ${e.device} (${e.why})\n`))
|
|
506
|
+
},
|
|
507
|
+
})
|
|
508
|
+
if (flags.json) {
|
|
509
|
+
console.log(JSON.stringify(report, null, 2))
|
|
510
|
+
return report.ok ? 0 : 1
|
|
511
|
+
}
|
|
512
|
+
console.log(formatAcceptance(report))
|
|
513
|
+
return report.ok ? 0 : 1
|
|
514
|
+
}
|
|
515
|
+
|
|
427
516
|
// ------------------------------------------------------------------------- main
|
|
428
517
|
async function main() {
|
|
429
518
|
const { verb, positional, flags } = parseArgs(process.argv.slice(2))
|
|
@@ -441,6 +530,10 @@ async function main() {
|
|
|
441
530
|
return cmdStatus({ positional, flags })
|
|
442
531
|
case 'probe':
|
|
443
532
|
return cmdProbe({ positional, flags })
|
|
533
|
+
case 'doctor':
|
|
534
|
+
return cmdDoctor({ positional, flags })
|
|
535
|
+
case 'verify':
|
|
536
|
+
return cmdVerify({ positional, flags })
|
|
444
537
|
case 'stop':
|
|
445
538
|
return cmdUp({ positional, flags: { ...flags, stop: true } })
|
|
446
539
|
case 'preset':
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acceptance checks: what "this lab works" actually means, expressed as facts.
|
|
3
|
+
*
|
|
4
|
+
* A scenario's `steps[].verify` field names a command to look at, which is a
|
|
5
|
+
* hint for a human, not a pass/fail test. This module turns the important ones
|
|
6
|
+
* into assertions: run a command on a device, then decide from the output
|
|
7
|
+
* whether the lab reached the intended state.
|
|
8
|
+
*
|
|
9
|
+
* Assertions are deliberately small and textual:
|
|
10
|
+
*
|
|
11
|
+
* { contains: 'Full' } output must contain the literal text
|
|
12
|
+
* { notContains: 'Init' } output must not contain it
|
|
13
|
+
* { matches: /^\\s*Up/m } output must match the pattern
|
|
14
|
+
* { minLines: 2 } output must have at least N non-empty lines
|
|
15
|
+
*
|
|
16
|
+
* Textual checks are honest about their limits: they prove the device reports a
|
|
17
|
+
* state, not that packets flow. Checks that really need packets use `ping` and
|
|
18
|
+
* assert on its summary line, which is the only place VRP reports loss.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-ensp/acceptance
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { queryDevice } from './provision.mjs'
|
|
24
|
+
import { SCENARIOS } from './presets.mjs'
|
|
25
|
+
import { readTopology } from './topology.mjs'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Per-scenario assertions. `on` is the device name from the blueprint, so a
|
|
29
|
+
* console port can be resolved from the running topology.
|
|
30
|
+
*
|
|
31
|
+
* Only scenarios whose outcome is unambiguous from a single `display` command
|
|
32
|
+
* get curated checks. Everything else falls back to smoke checks built from
|
|
33
|
+
* `steps[].verify` (see `planFor`).
|
|
34
|
+
*/
|
|
35
|
+
export const EXPECTATIONS = {
|
|
36
|
+
'vlan-access': [
|
|
37
|
+
{ id: 'vlans-exist', on: 'SW1', command: 'display vlan', expect: { minLines: 3 } },
|
|
38
|
+
{ id: 'port-in-vlan', on: 'SW1', command: 'display port vlan active', expect: { contains: 'U' } },
|
|
39
|
+
],
|
|
40
|
+
'trunk-interconnect': [
|
|
41
|
+
{ id: 'trunk-up', on: 'SW1', command: 'display interface brief | include up', expect: { minLines: 2 } },
|
|
42
|
+
],
|
|
43
|
+
'vlanif-routing': [
|
|
44
|
+
{ id: 'vlanif-up', on: 'SW1', command: 'display ip interface brief', expect: { matches: /Vlanif\d+\s+up/ } },
|
|
45
|
+
{ id: 'route-present', on: 'SW1', command: 'display ip routing-table', expect: { matches: /\/2[0-9]\s+Static|Direct/ } },
|
|
46
|
+
],
|
|
47
|
+
'stp-rstp': [
|
|
48
|
+
{ id: 'stp-running', on: 'SW1', command: 'display stp brief', expect: { minLines: 2 } },
|
|
49
|
+
{ id: 'one-root', on: 'SW1', command: 'display stp brief', expect: { matches: /ROOT/ } },
|
|
50
|
+
],
|
|
51
|
+
'link-aggregation': [
|
|
52
|
+
{ id: 'eth-trunk-up', on: 'SW1', command: 'display eth-trunk', expect: { matches: /Up/ } },
|
|
53
|
+
],
|
|
54
|
+
'static-route': [
|
|
55
|
+
{ id: 'static-in-table', on: 'R1', command: 'display ip routing-table protocol static', expect: { minLines: 2 } },
|
|
56
|
+
],
|
|
57
|
+
'rip': [
|
|
58
|
+
{ id: 'rip-neighbour', on: 'R1', command: 'display rip 1 neighbor', expect: { minLines: 2 } },
|
|
59
|
+
{ id: 'rip-route', on: 'R1', command: 'display ip routing-table protocol rip', expect: { minLines: 2 } },
|
|
60
|
+
],
|
|
61
|
+
'ospf-single': [
|
|
62
|
+
{ id: 'neighbour-full', on: 'R1', command: 'display ospf peer brief', expect: { contains: 'Full' } },
|
|
63
|
+
{ id: 'ospf-route', on: 'R1', command: 'display ip routing-table protocol ospf', expect: { minLines: 2 } },
|
|
64
|
+
],
|
|
65
|
+
'ospf-multi-area': [
|
|
66
|
+
{ id: 'neighbour-full', on: 'R1', command: 'display ospf peer brief', expect: { contains: 'Full' } },
|
|
67
|
+
{ id: 'inter-area-route', on: 'R1', command: 'display ip routing-table protocol ospf', expect: { matches: /OSPF_ASE|OSPF|\/2[0-9]/ } },
|
|
68
|
+
],
|
|
69
|
+
'ospf-stub-nssa': [
|
|
70
|
+
{ id: 'neighbour-full', on: 'R1', command: 'display ospf peer brief', expect: { contains: 'Full' } },
|
|
71
|
+
{ id: 'stub-area', on: 'R1', command: 'display ospf brief', expect: { matches: /Stub|NSSA/i } },
|
|
72
|
+
],
|
|
73
|
+
'bgp-ebgp-ibgp': [
|
|
74
|
+
{ id: 'bgp-established', on: 'R1', command: 'display bgp peer', expect: { contains: 'Established' } },
|
|
75
|
+
{ id: 'bgp-route', on: 'R1', command: 'display bgp routing-table', expect: { minLines: 3 } },
|
|
76
|
+
],
|
|
77
|
+
'bgp-route-reflector': [
|
|
78
|
+
{ id: 'bgp-established', on: 'RR', command: 'display bgp peer', expect: { contains: 'Established' } },
|
|
79
|
+
{ id: 'reflected-route', on: 'RR', command: 'display bgp routing-table', expect: { minLines: 3 } },
|
|
80
|
+
],
|
|
81
|
+
'dhcp': [
|
|
82
|
+
{ id: 'pool-exists', on: 'R1', command: 'display ip pool', expect: { minLines: 2 } },
|
|
83
|
+
],
|
|
84
|
+
'nat': [
|
|
85
|
+
{ id: 'nat-enabled', on: 'R1', command: 'display nat outbound', expect: { matches: /ACL|acl/i } },
|
|
86
|
+
],
|
|
87
|
+
'acl-filter': [
|
|
88
|
+
{ id: 'acl-defined', on: 'R1', command: 'display acl all', expect: { minLines: 2 } },
|
|
89
|
+
],
|
|
90
|
+
'aaa-local': [
|
|
91
|
+
{ id: 'user-exists', on: 'R1', command: 'display aaa local-user', expect: { minLines: 3 } },
|
|
92
|
+
],
|
|
93
|
+
'isis-multi-level': [
|
|
94
|
+
{ id: 'isis-neighbour', on: 'R1', command: 'display isis peer', expect: { contains: 'Up' } },
|
|
95
|
+
{ id: 'isis-route', on: 'R1', command: 'display isis route', expect: { minLines: 3 } },
|
|
96
|
+
],
|
|
97
|
+
'ipv6': [
|
|
98
|
+
{ id: 'ipv6-address', on: 'R1', command: 'display ipv6 interface brief', expect: { contains: '2001' } },
|
|
99
|
+
{ id: 'ipv6-route', on: 'R1', command: 'display ipv6 routing-table', expect: { minLines: 3 } },
|
|
100
|
+
],
|
|
101
|
+
'vrrp-basics': [
|
|
102
|
+
{ id: 'vrrp-master', on: 'R1', command: 'display vrrp', expect: { contains: 'Master' } },
|
|
103
|
+
{ id: 'vrrp-backup', on: 'R2', command: 'display vrrp', expect: { contains: 'Backup' } },
|
|
104
|
+
],
|
|
105
|
+
'ospfv3-ipv6': [
|
|
106
|
+
{ id: 'ospfv3-neighbour', on: 'R1', command: 'display ospfv3 peer', expect: { contains: 'Full' } },
|
|
107
|
+
],
|
|
108
|
+
'multicast-pim-sm': [
|
|
109
|
+
{ id: 'pim-neighbour', on: 'R1', command: 'display pim neighbor', expect: { minLines: 2 } },
|
|
110
|
+
{ id: 'rp-set', on: 'R1', command: 'display pim rp-info', expect: { minLines: 2 } },
|
|
111
|
+
],
|
|
112
|
+
'mpls-basic': [
|
|
113
|
+
{ id: 'mpls-enabled', on: 'PE1', command: 'display mpls ldp session', expect: { minLines: 2 } },
|
|
114
|
+
{ id: 'ldp-peer', on: 'PE1', command: 'display mpls ldp peer', expect: { minLines: 2 } },
|
|
115
|
+
],
|
|
116
|
+
'mpls-l3vpn': [
|
|
117
|
+
{ id: 'vpnv4-peer', on: 'PE1', command: 'display bgp vpnv4 all peer', expect: { contains: 'Established' } },
|
|
118
|
+
{ id: 'vrf-route', on: 'PE1', command: 'display ip routing-table vpn-instance vpn1', expect: { minLines: 3 } },
|
|
119
|
+
],
|
|
120
|
+
'stack-istack': [
|
|
121
|
+
{ id: 'stack-members', on: 'SW1', command: 'display stack', expect: { minLines: 3 } },
|
|
122
|
+
],
|
|
123
|
+
'dhcp-snooping': [
|
|
124
|
+
{ id: 'snooping-on', on: 'SW1', command: 'display dhcp snooping', expect: { matches: /enable|Enable/ } },
|
|
125
|
+
],
|
|
126
|
+
'qos-basics': [
|
|
127
|
+
{ id: 'qos-policy', on: 'R1', command: 'display traffic policy user-defined', expect: { minLines: 3 } },
|
|
128
|
+
],
|
|
129
|
+
'port-security': [
|
|
130
|
+
{ id: 'port-security-on', on: 'SW1', command: 'display port-security', expect: { matches: /enable|Enable/ } },
|
|
131
|
+
],
|
|
132
|
+
'super-vlan': [
|
|
133
|
+
{ id: 'super-vlan', on: 'SW1', command: 'display vlan', expect: { minLines: 4 } },
|
|
134
|
+
],
|
|
135
|
+
'network-management': [
|
|
136
|
+
{ id: 'snmp-agent', on: 'R1', command: 'display snmp-agent sys-info', expect: { minLines: 2 } },
|
|
137
|
+
],
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Longest useful slice of a command's output kept for a report. */
|
|
141
|
+
const SNIPPET = 400
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Decide one assertion against a command's output.
|
|
145
|
+
* @returns {{pass: boolean, why: string}}
|
|
146
|
+
*/
|
|
147
|
+
export function evaluateAssertion(output, expect = {}) {
|
|
148
|
+
const text = String(output || '')
|
|
149
|
+
const checks = []
|
|
150
|
+
if (expect.contains !== undefined) {
|
|
151
|
+
checks.push({ ok: text.includes(expect.contains), why: `contains "${expect.contains}"` })
|
|
152
|
+
}
|
|
153
|
+
if (expect.notContains !== undefined) {
|
|
154
|
+
checks.push({ ok: !text.includes(expect.notContains), why: `does not contain "${expect.notContains}"` })
|
|
155
|
+
}
|
|
156
|
+
if (expect.matches !== undefined) {
|
|
157
|
+
const re = expect.matches instanceof RegExp ? expect.matches : new RegExp(expect.matches, 'm')
|
|
158
|
+
checks.push({ ok: re.test(text), why: `matches ${re}` })
|
|
159
|
+
}
|
|
160
|
+
if (expect.minLines !== undefined) {
|
|
161
|
+
const n = text.split('\n').filter((l) => l.trim()).length
|
|
162
|
+
checks.push({ ok: n >= expect.minLines, why: `at least ${expect.minLines} non-empty lines (got ${n})` })
|
|
163
|
+
}
|
|
164
|
+
if (!checks.length) return { pass: true, why: 'no assertion (command must merely succeed)' }
|
|
165
|
+
const failed = checks.filter((c) => !c.ok)
|
|
166
|
+
return {
|
|
167
|
+
pass: failed.length === 0,
|
|
168
|
+
why: (failed.length ? failed : checks).map((c) => (c.ok ? '' : 'not ') + c.why).join('; '),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Build the acceptance plan for a scenario.
|
|
174
|
+
*
|
|
175
|
+
* Curated expectations win. Otherwise the scenario's `steps[].verify` commands
|
|
176
|
+
* become smoke checks: the command has to run without a VRP error, which still
|
|
177
|
+
* catches a lab that was never configured.
|
|
178
|
+
*/
|
|
179
|
+
export function planFor(id, { devices } = {}) {
|
|
180
|
+
const scenario = SCENARIOS[id]
|
|
181
|
+
if (!scenario) throw new Error(`unknown scenario: ${id}`)
|
|
182
|
+
const curated = EXPECTATIONS[id]
|
|
183
|
+
if (curated && curated.length) {
|
|
184
|
+
return {
|
|
185
|
+
scenario: id,
|
|
186
|
+
title: scenario.title,
|
|
187
|
+
source: 'curated',
|
|
188
|
+
checks: curated.map((c) => ({ ...c, kind: 'assert' })),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const first = (devices && devices.length && devices[0]) || scenario.devices?.split(/[、,,\s]+/)[0]
|
|
192
|
+
const commands = [...new Set((scenario.steps || []).map((s) => s.verify).filter(Boolean))]
|
|
193
|
+
const checks = commands.flatMap((raw) =>
|
|
194
|
+
String(raw)
|
|
195
|
+
.split('/')
|
|
196
|
+
.map((c) => c.trim())
|
|
197
|
+
.filter(Boolean)
|
|
198
|
+
.map((command, i) => ({
|
|
199
|
+
id: `smoke-${i + 1}`,
|
|
200
|
+
on: first,
|
|
201
|
+
command,
|
|
202
|
+
kind: 'smoke',
|
|
203
|
+
expect: {},
|
|
204
|
+
})),
|
|
205
|
+
)
|
|
206
|
+
return { scenario: id, title: scenario.title, source: 'smoke', checks }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Does this scenario have curated (strict) assertions rather than smoke checks? */
|
|
210
|
+
export function hasChecks(id) {
|
|
211
|
+
return Boolean(EXPECTATIONS[id] && EXPECTATIONS[id].length)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Map device name → console port from a parsed topology. */
|
|
215
|
+
export function consolePorts(topology) {
|
|
216
|
+
const map = new Map()
|
|
217
|
+
for (const d of topology?.devices ?? []) {
|
|
218
|
+
if (d.comPort) map.set(d.name, Number(d.comPort))
|
|
219
|
+
}
|
|
220
|
+
return map
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Run a scenario's acceptance plan against a live lab.
|
|
225
|
+
*
|
|
226
|
+
* @param {object} options
|
|
227
|
+
* @param {string} options.id scenario id
|
|
228
|
+
* @param {string} [options.topology] path to the running .topo (for ports)
|
|
229
|
+
* @param {Map<string,number>} [options.ports] explicit device → port map
|
|
230
|
+
* @param {number} [options.timeoutMs] per-command timeout
|
|
231
|
+
* @param {number} [options.waitMs] how long a failing assertion may keep being retried
|
|
232
|
+
* @param {number} [options.intervalMs] delay between those retries
|
|
233
|
+
* @param {Function} [options.query] override the console query (tests)
|
|
234
|
+
* @param {Function} [options.onProgress]
|
|
235
|
+
*/
|
|
236
|
+
export async function runAcceptance(options = {}) {
|
|
237
|
+
const { id, topology, timeoutMs = 25000, waitMs = 60000, intervalMs = 3000, onProgress } = options
|
|
238
|
+
// Injectable so the convergence loop can be tested without a live lab.
|
|
239
|
+
const query = options.query ?? queryDevice
|
|
240
|
+
let ports = options.ports
|
|
241
|
+
let parsed
|
|
242
|
+
if (!ports) {
|
|
243
|
+
if (!topology) throw new Error('runAcceptance needs either a topology path or a ports map')
|
|
244
|
+
parsed = await readTopology(topology)
|
|
245
|
+
ports = consolePorts(parsed)
|
|
246
|
+
}
|
|
247
|
+
const plan = planFor(id, { devices: parsed ? [...ports.keys()] : [...(ports?.keys() ?? [])] })
|
|
248
|
+
const results = []
|
|
249
|
+
for (const check of plan.checks) {
|
|
250
|
+
const port = ports.get(check.on)
|
|
251
|
+
onProgress?.({ phase: 'check', id: check.id, device: check.on })
|
|
252
|
+
if (!port) {
|
|
253
|
+
results.push({
|
|
254
|
+
...check,
|
|
255
|
+
device: check.on,
|
|
256
|
+
pass: false,
|
|
257
|
+
why: `no console port for device "${check.on}"`,
|
|
258
|
+
snippet: '',
|
|
259
|
+
})
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
let res
|
|
263
|
+
try {
|
|
264
|
+
res = await query({ port, command: check.command, timeoutMs })
|
|
265
|
+
} catch (err) {
|
|
266
|
+
results.push({ ...check, device: check.on, pass: false, why: `command failed: ${err.message}`, snippet: '' })
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
const output = res?.output || ''
|
|
270
|
+
// A console that never opened is a different failure from a command that ran and
|
|
271
|
+
// printed the wrong thing. Saying "not contains Full" when nothing answered sends
|
|
272
|
+
// the reader looking at OSPF instead of at the device that is not running.
|
|
273
|
+
const unreachable = res?.ok === false
|
|
274
|
+
const errored = /(^|\n)\s*(Error|Unrecognized command|Incomplete command|Wrong parameter|Too many parameters)/.test(output)
|
|
275
|
+
let verdict = unreachable
|
|
276
|
+
? { pass: false, why: `console unreachable: ${res.error || 'no answer'}` }
|
|
277
|
+
: errored
|
|
278
|
+
? { pass: false, why: 'VRP rejected the command' }
|
|
279
|
+
: evaluateAssertion(output, check.expect)
|
|
280
|
+
|
|
281
|
+
// Routing protocols converge after the command returns, not before it. A single
|
|
282
|
+
// snapshot taken two seconds after `ensp apply` reports "not contains Full" for a
|
|
283
|
+
// session that reaches Full ten seconds later, which reads as a broken lab. Retry
|
|
284
|
+
// the assertion itself, never a rejected command and never a dead console.
|
|
285
|
+
let waited = 0
|
|
286
|
+
let lastOutput = output
|
|
287
|
+
while (!verdict.pass && !unreachable && !errored && waited < waitMs) {
|
|
288
|
+
onProgress?.({ phase: 'retry', id: check.id, device: check.on, waited, why: verdict.why })
|
|
289
|
+
const nap = Math.min(intervalMs, waitMs - waited)
|
|
290
|
+
await new Promise((r) => setTimeout(r, nap))
|
|
291
|
+
waited += nap
|
|
292
|
+
let again
|
|
293
|
+
try {
|
|
294
|
+
again = await query({ port, command: check.command, timeoutMs })
|
|
295
|
+
} catch (err) {
|
|
296
|
+
verdict = { pass: false, why: `command failed: ${err.message}` }
|
|
297
|
+
break
|
|
298
|
+
}
|
|
299
|
+
if (again?.ok === false) {
|
|
300
|
+
verdict = { pass: false, why: `console unreachable: ${again.error || 'no answer'}` }
|
|
301
|
+
break
|
|
302
|
+
}
|
|
303
|
+
lastOutput = again?.output || ''
|
|
304
|
+
verdict = evaluateAssertion(lastOutput, check.expect)
|
|
305
|
+
}
|
|
306
|
+
results.push({
|
|
307
|
+
...check,
|
|
308
|
+
device: check.on,
|
|
309
|
+
pass: verdict.pass,
|
|
310
|
+
why: verdict.why,
|
|
311
|
+
waitedMs: waited,
|
|
312
|
+
snippet: lastOutput.trim().slice(0, SNIPPET),
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
const failed = results.filter((r) => !r.pass)
|
|
316
|
+
return {
|
|
317
|
+
scenario: plan.scenario,
|
|
318
|
+
title: plan.title,
|
|
319
|
+
source: plan.source,
|
|
320
|
+
ok: failed.length === 0,
|
|
321
|
+
total: results.length,
|
|
322
|
+
passed: results.length - failed.length,
|
|
323
|
+
results,
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Render an acceptance report for a terminal. */
|
|
328
|
+
export function formatAcceptance(report) {
|
|
329
|
+
const lines = [`${report.scenario} — ${report.title}`, `(${report.source} checks)`]
|
|
330
|
+
for (const r of report.results) {
|
|
331
|
+
lines.push(
|
|
332
|
+
`[${r.pass ? 'pass' : 'FAIL'}] ${r.id} on ${r.on}: ${r.command}` +
|
|
333
|
+
(r.pass && r.waitedMs ? ` (converged after ${Math.round(r.waitedMs / 1000)}s)` : '') +
|
|
334
|
+
(r.pass ? '' : `\n ${r.why}`) +
|
|
335
|
+
(process.env.ENSP_VERBOSE && r.snippet ? `\n ${r.snippet.replace(/\n/g, '\n ')}` : ''),
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
lines.push('')
|
|
339
|
+
lines.push(
|
|
340
|
+
report.ok
|
|
341
|
+
? `accepted: ${report.passed}/${report.total} checks passed`
|
|
342
|
+
: `rejected: ${report.total - report.passed}/${report.total} checks failed`,
|
|
343
|
+
)
|
|
344
|
+
return lines.join('\n')
|
|
345
|
+
}
|