Skip to content

Commit 1571648

Browse files
committed
test: add SNMP MIB coverage test
The set of MIBs the agent serves comes from a module list written by hand in four defconfigs. Nothing checks it, so trimming that list, or a net-snmp release renaming a module, quietly shrinks what is served while the smoke test and the documentation both carry on as before. Walk every MIB doc/snmp.md promises and require each to answer with at least one object. Values are not compared: counters and load move on their own, and which rows exist follows the interfaces, mounts and processes of the device, so a recorded walk would only produce false failures. Also check what must hold on any device, that every interface in the operational datastore answers under its own if-index, that no ifTable row lacks its ifXTable row, and that ifNumber agrees with both. LLDP-MIB gets its own step. It arrives over AgentX from lldpd rather than from the agent itself, and is the only thing here that covers that path. Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
1 parent 5e269a6 commit 1571648

6 files changed

Lines changed: 322 additions & 0 deletions

File tree

‎test/case/snmp/all.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
---
22
- name: Basic
33
case: basic/test.py
4+
5+
- name: MIB Coverage
6+
case: coverage/test.py
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
test.adoc

‎test/case/snmp/coverage/test.adoc‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
=== MIB Coverage
2+
3+
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/snmp/coverage]
4+
5+
==== Description
6+
7+
Walk every MIB the agent is documented to serve and verify each one
8+
answers with at least one object. The set of MIBs the agent serves is
9+
decided by a hand written module list in the defconfigs, so trimming it,
10+
or a net-snmp release renaming a module, silently shrinks what is served
11+
while everything else still passes.
12+
13+
Values are deliberately not compared. Counters, uptime and load move on
14+
their own, and which rows exist depends on the interfaces, mounts and
15+
processes of the device under test, so a recorded walk could only ever
16+
be a source of false failures. A subtree that answers at all is the
17+
property worth guarding.
18+
19+
On top of that, check the relations that must hold whatever the device
20+
looks like: every interface in the operational datastore is reachable
21+
over SNMP under its own if-index, every ifTable row has the matching
22+
ifXTable row, and ifNumber agrees with both.
23+
24+
LLDP-MIB is checked separately because it arrives by a different route,
25+
lldpd registering it with the master agent over AgentX, and is the only
26+
thing here that exercises that path.
27+
28+
==== Topology
29+
30+
image::topology.svg[MIB Coverage topology, align=center, scaledwidth=75%]
31+
32+
==== Sequence
33+
34+
. Set up topology and attach to target DUT
35+
. Set IPv4 address 10.0.0.10/24 on target:data
36+
. Enable SNMP agent with a read-only community
37+
. Verify the host has the net-snmp client tools
38+
. Wait for the agent to answer
39+
. Verify every documented MIB answers
40+
. Verify LLDP-MIB is served over AgentX
41+
. Verify every interface in operational is in ifXTable
42+
. Verify every ifTable row has an ifXTable row
43+
. Verify ifNumber agrees with the ifTable row count
44+
45+

‎test/case/snmp/coverage/test.py‎

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
#!/usr/bin/env python3
2+
"""SNMP MIB coverage
3+
4+
Walk every MIB the agent is documented to serve and verify each one
5+
answers with at least one object. The set of MIBs the agent serves is
6+
decided by a hand written module list in the defconfigs, so trimming it,
7+
or a net-snmp release renaming a module, silently shrinks what is served
8+
while everything else still passes.
9+
10+
Values are deliberately not compared. Counters, uptime and load move on
11+
their own, and which rows exist depends on the interfaces, mounts and
12+
processes of the device under test, so a recorded walk could only ever
13+
be a source of false failures. A subtree that answers at all is the
14+
property worth guarding.
15+
16+
On top of that, check the relations that must hold whatever the device
17+
looks like: every interface in the operational datastore is reachable
18+
over SNMP under its own if-index, every ifTable row has the matching
19+
ifXTable row, and ifNumber agrees with both.
20+
21+
LLDP-MIB is checked separately because it arrives by a different route,
22+
lldpd registering it with the master agent over AgentX, and is the only
23+
thing here that exercises that path.
24+
25+
"""
26+
27+
import time
28+
29+
import infamy
30+
31+
SYSTEM = ".1.3.6.1.2.1.1"
32+
33+
# The MIBs doc/snmp.md promises, as the narrowest subtree that proves
34+
# the module behind each one is built in and answering. HOST-RESOURCES
35+
# is split rather than walked whole: hrSWRunTable rereads all of /proc
36+
# per request, which is slow on a device with real work to do.
37+
SUBTREES = [
38+
("SNMPv2-MIB system", SYSTEM),
39+
("IF-MIB ifTable", ".1.3.6.1.2.1.2.2"),
40+
("IF-MIB ifXTable", ".1.3.6.1.2.1.31.1.1"),
41+
("HOST-RESOURCES hrStorage", ".1.3.6.1.2.1.25.2.3"),
42+
("HOST-RESOURCES hrProcessor", ".1.3.6.1.2.1.25.3.3"),
43+
("UCD-SNMP-MIB laTable", ".1.3.6.1.4.1.2021.10"),
44+
("UCD-SNMP-MIB memory", ".1.3.6.1.4.1.2021.4"),
45+
("UCD-SNMP-MIB dskTable", ".1.3.6.1.4.1.2021.9"),
46+
]
47+
48+
IFINDEX = ".1.3.6.1.2.1.2.2.1.1"
49+
IFNUMBER = ".1.3.6.1.2.1.2.1.0"
50+
IFNAME = ".1.3.6.1.2.1.31.1.1.1.1"
51+
52+
# lldpd serves this one as an AgentX subagent, see doc/snmp.md.
53+
LLDP_LOCAL = ".1.0.8802.1.1.2.1.3"
54+
55+
COMMUNITY = "infix-test"
56+
TARGET_IP = "10.0.0.10"
57+
58+
59+
def retry(fn, attempts=10, interval=3):
60+
"""Poll fn until it returns something truthy, None on timeout.
61+
62+
infamy.until() raises instead, which would report a quiet subtree as
63+
a traceback rather than a step saying what was missing.
64+
"""
65+
for _ in range(attempts):
66+
val = fn()
67+
if val:
68+
return val
69+
time.sleep(interval)
70+
71+
return None
72+
73+
74+
def snmpwalk(netns, oid):
75+
"""Varbinds under oid, empty when the agent has nothing there."""
76+
rc = netns.runsh(f"snmpwalk -v2c -c {COMMUNITY} -r 1 -t 3 -On "
77+
f"{TARGET_IP} {oid}")
78+
if rc.returncode != 0:
79+
return []
80+
81+
out = []
82+
for line in rc.stdout.splitlines():
83+
line = line.strip()
84+
# An unimplemented subtree is reported, not an error, so the
85+
# exit code above does not catch it.
86+
if not line or "No Such" in line or "No more variables" in line:
87+
continue
88+
out.append(line)
89+
90+
return out
91+
92+
93+
def varbind(line):
94+
"""Split '.1.3.6.1... = STRING: eth0' into its OID and value."""
95+
oid, _, val = line.partition(" = ")
96+
return oid.strip(), val.split(":", 1)[-1].strip()
97+
98+
99+
with infamy.Test() as test:
100+
with test.step("Set up topology and attach to target DUT"):
101+
env = infamy.Env()
102+
target = env.attach("target", "mgmt")
103+
_, hport = env.ltop.xlate("host", "data")
104+
105+
if not target.has_model("ietf-snmp"):
106+
test.skip()
107+
108+
with test.step("Set IPv4 address 10.0.0.10/24 on target:data"):
109+
target.put_config_dicts({
110+
"ietf-interfaces": {
111+
"interfaces": {
112+
"interface": [{
113+
"name": target["data"],
114+
"enabled": True,
115+
"ipv4": {
116+
"address": [{
117+
"ip": TARGET_IP,
118+
"prefix-length": 24
119+
}]
120+
}
121+
}]
122+
}
123+
}
124+
})
125+
126+
with test.step("Enable SNMP agent with a read-only community"):
127+
target.put_config_dicts({
128+
"ietf-snmp": {
129+
"snmp": {
130+
"engine": {"enabled": True},
131+
"community": [{
132+
"index": "monitor",
133+
"text-name": COMMUNITY,
134+
"security-name": "monitor"
135+
}]
136+
}
137+
}
138+
})
139+
140+
with infamy.IsolatedMacVlan(hport) as netns:
141+
netns.addip("10.0.0.1")
142+
143+
with test.step("Verify the host has the net-snmp client tools"):
144+
if netns.runsh("command -v snmpwalk").returncode != 0:
145+
print("host: snmpwalk not found, the test container image "
146+
"needs the net-snmp-tools package")
147+
test.fail()
148+
149+
with test.step("Wait for the agent to answer"):
150+
# Everything below reads an empty walk as a missing MIB, so
151+
# settle this first rather than blame the module list.
152+
if not retry(lambda: snmpwalk(netns, SYSTEM)):
153+
print("host: agent never answered")
154+
test.fail()
155+
156+
with test.step("Verify every documented MIB answers"):
157+
missing = []
158+
for label, oid in SUBTREES:
159+
num = len(snmpwalk(netns, oid))
160+
print(f"host: {label} {oid} {num} objects")
161+
if not num:
162+
missing.append(label)
163+
if missing:
164+
print(f"host: no objects served under: {', '.join(missing)}")
165+
test.fail()
166+
167+
with test.step("Verify LLDP-MIB is served over AgentX"):
168+
# Enabling SNMP restarts lldpd with -x, and net-snmp's
169+
# subagent retries the master every agentxPingInterval,
170+
# 15s by default, so allow a couple of rounds.
171+
lldp = retry(lambda: len(snmpwalk(netns, LLDP_LOCAL)))
172+
print(f"host: LLDP-MIB lldpLocalSystemData {lldp} objects")
173+
if not lldp:
174+
print("host: lldpd did not register LLDP-MIB with the agent")
175+
test.fail()
176+
177+
with test.step("Verify every interface in operational is in ifXTable"):
178+
names = {}
179+
for line in snmpwalk(netns, IFNAME):
180+
oid, name = varbind(line)
181+
names[oid[len(IFNAME) + 1:]] = name.strip('"')
182+
183+
oper = target.get_data("/ietf-interfaces:interfaces")
184+
for entry in oper["interfaces"]["interface"]:
185+
idx = str(entry.get("if-index", ""))
186+
print(f"target: {entry['name']} if-index {idx}"
187+
f" -> ifName {names.get(idx)}")
188+
if names.get(idx) != entry["name"]:
189+
test.fail()
190+
191+
with test.step("Verify every ifTable row has an ifXTable row"):
192+
indices = [varbind(ln)[1] for ln in snmpwalk(netns, IFINDEX)]
193+
orphans = [i for i in indices if i not in names]
194+
print(f"host: ifTable has {len(indices)} rows,"
195+
f" ifXTable has {len(names)}")
196+
if orphans:
197+
print(f"host: no ifXTable row for ifIndex: {orphans}")
198+
test.fail()
199+
200+
with test.step("Verify ifNumber agrees with the ifTable row count"):
201+
num = snmpwalk(netns, IFNUMBER)
202+
print(f"host: ifNumber = {varbind(num[0])[1] if num else None}")
203+
if not num or varbind(num[0])[1] != str(len(indices)):
204+
test.fail()
205+
206+
test.succeed()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
graph "1x2" {
2+
layout="neato";
3+
overlap="false";
4+
esep="+80";
5+
6+
node [shape=record, fontname="DejaVu Sans Mono, Book"];
7+
edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"];
8+
9+
host [
10+
label="host | { <mgmt> mgmt | <data> data }",
11+
pos="0,12!",
12+
requires="controller",
13+
];
14+
15+
target [
16+
label="{ <mgmt> mgmt | <data> data } | target",
17+
pos="10,12!",
18+
requires="infix",
19+
];
20+
21+
host:mgmt -- target:mgmt [requires="mgmt", color="lightgrey"]
22+
host:data -- target:data [color=black, fontcolor=black, fontsize=12, taillabel="10.0.0.1/24", headlabel="10.0.0.10/24"]
23+
}
Lines changed: 44 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)