python-netgear-switch-library 0.0.post154__py3-none-any.whl
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.
- netgear_switch/__init__.py +132 -0
- netgear_switch/_dispatch.py +178 -0
- netgear_switch/_version.py +24 -0
- netgear_switch/aio_api.py +529 -0
- netgear_switch/cli/__init__.py +1 -0
- netgear_switch/cli/capture.py +131 -0
- netgear_switch/cli/context.py +39 -0
- netgear_switch/cli/format.py +201 -0
- netgear_switch/cli/main.py +484 -0
- netgear_switch/cli/resolve.py +108 -0
- netgear_switch/cli/safety.py +71 -0
- netgear_switch/config.py +184 -0
- netgear_switch/errors.py +52 -0
- netgear_switch/http_read.py +174 -0
- netgear_switch/http_write.py +420 -0
- netgear_switch/models.py +156 -0
- netgear_switch/nsdp_read.py +221 -0
- netgear_switch/nsdp_write.py +315 -0
- netgear_switch/protocols/__init__.py +1 -0
- netgear_switch/protocols/http/__init__.py +1 -0
- netgear_switch/protocols/http/crypt.py +29 -0
- netgear_switch/protocols/http/endpoints.py +165 -0
- netgear_switch/protocols/http/forms.py +77 -0
- netgear_switch/protocols/http/parse.py +238 -0
- netgear_switch/protocols/http/session.py +29 -0
- netgear_switch/protocols/nsdp/__init__.py +7 -0
- netgear_switch/protocols/nsdp/auth.py +33 -0
- netgear_switch/protocols/nsdp/client.py +67 -0
- netgear_switch/protocols/nsdp/parsers.py +209 -0
- netgear_switch/protocols/nsdp/protocol.py +201 -0
- netgear_switch/protocols/nsdp/types.py +137 -0
- netgear_switch/protocols/nsdp/write.py +98 -0
- netgear_switch/protocols/snmp/__init__.py +1 -0
- netgear_switch/protocols/snmp/client.py +88 -0
- netgear_switch/protocols/snmp/oids.py +125 -0
- netgear_switch/protocols/snmp/parse.py +777 -0
- netgear_switch/protocols/snmp/write.py +112 -0
- netgear_switch/py.typed +0 -0
- netgear_switch/registry.py +227 -0
- netgear_switch/snmp_read.py +226 -0
- netgear_switch/snmp_write.py +625 -0
- netgear_switch/sync_api.py +557 -0
- netgear_switch/transport/__init__.py +1 -0
- netgear_switch/transport/aio/__init__.py +1 -0
- netgear_switch/transport/aio/nsdp_udp.py +152 -0
- netgear_switch/transport/aio/snmp_pysnmp.py +247 -0
- netgear_switch/transport/http/__init__.py +1 -0
- netgear_switch/transport/http/client.py +217 -0
- netgear_switch/transport/sync/__init__.py +1 -0
- netgear_switch/transport/sync/nsdp_udp.py +109 -0
- netgear_switch/transport/sync/snmp_netsnmp_cli.py +257 -0
- netgear_switch/virtual/__init__.py +8 -0
- netgear_switch/virtual/faces/__init__.py +2 -0
- netgear_switch/virtual/faces/http.py +164 -0
- netgear_switch/virtual/faces/mibview.py +92 -0
- netgear_switch/virtual/faces/nsdp.py +124 -0
- netgear_switch/virtual/faces/snmp.py +412 -0
- netgear_switch/virtual/seed.py +220 -0
- netgear_switch/virtual/server.py +106 -0
- netgear_switch/virtual/state.py +615 -0
- netgear_switch/virtual/web.py +210 -0
- python_netgear_switch_library-0.0.post154.dist-info/METADATA +85 -0
- python_netgear_switch_library-0.0.post154.dist-info/RECORD +66 -0
- python_netgear_switch_library-0.0.post154.dist-info/WHEEL +4 -0
- python_netgear_switch_library-0.0.post154.dist-info/entry_points.txt +2 -0
- python_netgear_switch_library-0.0.post154.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
"""Model-driven SNMP write/control over a write-capable sync or async client.
|
|
2
|
+
|
|
3
|
+
Parallel to ``snmp_read.py``. Every write performs the SET then re-reads and
|
|
4
|
+
verifies (``WriteVerificationError`` with before/after on mismatch — a real
|
|
5
|
+
``commitFailed`` surfaces as an ``SnmpError`` from the transport first).
|
|
6
|
+
Disruptive writes to a ``protected_ports`` port are refused unless ``force=True``
|
|
7
|
+
(design spec §6).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from .errors import (
|
|
17
|
+
ProtectedPortError,
|
|
18
|
+
UnsupportedCapabilityError,
|
|
19
|
+
WriteVerificationError,
|
|
20
|
+
)
|
|
21
|
+
from .models import PoEDetect
|
|
22
|
+
from .protocols.snmp import oids
|
|
23
|
+
from .protocols.snmp.client import SnmpError
|
|
24
|
+
from .protocols.snmp.parse import decode_port_bitmap
|
|
25
|
+
from .protocols.snmp.write import (
|
|
26
|
+
SetVarbind,
|
|
27
|
+
encode_port_bitmap,
|
|
28
|
+
membership_bitmaps,
|
|
29
|
+
vlan_bitmap_width,
|
|
30
|
+
)
|
|
31
|
+
from .registry import Backend
|
|
32
|
+
from .snmp_read import AsyncSnmpReader, SnmpReader
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import Awaitable, Callable
|
|
36
|
+
|
|
37
|
+
from .models import PoEStatus, PortStatus, VLANInfo, VlanMode
|
|
38
|
+
from .protocols.snmp.client import AsyncSnmpWriteClient, SnmpWriteClient
|
|
39
|
+
from .registry import SwitchModel
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _require_snmp(model: SwitchModel) -> None:
|
|
43
|
+
if Backend.SNMP not in model.backends:
|
|
44
|
+
raise UnsupportedCapabilityError(f"model {model.key!r} has no SNMP backend")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _poe_admin_oid(port: int) -> str:
|
|
48
|
+
return f"{oids.PETH_PSE_PORT_TABLE}.3.1.{port}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class PoeCycleTimeouts:
|
|
53
|
+
"""Injectable PoE-cycle deadlines (seconds). Defaults match design spec §6;
|
|
54
|
+
tests pass tiny values so cycles run fast against the coherent mock."""
|
|
55
|
+
|
|
56
|
+
off_timeout: float = 30.0
|
|
57
|
+
on_timeout: float = 60.0
|
|
58
|
+
poll_interval: float = 2.0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_DEFAULT_POE_TIMEOUTS = PoeCycleTimeouts()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _poe_is_off(status: PoEStatus | None, port_up: bool) -> bool:
|
|
65
|
+
return (
|
|
66
|
+
status is not None
|
|
67
|
+
and status.detect in (PoEDetect.DISABLED, PoEDetect.SEARCHING)
|
|
68
|
+
and not port_up
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _poe_recovered(status: PoEStatus | None) -> bool:
|
|
73
|
+
"""True once detect has left FAULT and settled to delivering/searching."""
|
|
74
|
+
return status is not None and status.detect in (
|
|
75
|
+
PoEDetect.DELIVERING,
|
|
76
|
+
PoEDetect.SEARCHING,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SnmpWriter:
|
|
81
|
+
"""Synchronous SNMP write facade over one switch."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
client: SnmpWriteClient,
|
|
86
|
+
model: SwitchModel,
|
|
87
|
+
*,
|
|
88
|
+
protected_ports: frozenset[int] = frozenset(),
|
|
89
|
+
) -> None:
|
|
90
|
+
_require_snmp(model)
|
|
91
|
+
self.client = client
|
|
92
|
+
self.model = model
|
|
93
|
+
self.protected_ports = protected_ports
|
|
94
|
+
self._reader = SnmpReader(client, model)
|
|
95
|
+
|
|
96
|
+
def _guard(self, port: int, force: bool) -> None:
|
|
97
|
+
if port in self.protected_ports and not force:
|
|
98
|
+
raise ProtectedPortError(
|
|
99
|
+
f"port {port} is protected; pass force=True to override"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _poe_status(self, port: int) -> PoEStatus | None:
|
|
103
|
+
return next((p for p in self._reader.get_poe() if p.port == port), None)
|
|
104
|
+
|
|
105
|
+
def _port_status(self, port: int) -> PortStatus | None:
|
|
106
|
+
return next((p for p in self._reader.get_ports() if p.port == port), None)
|
|
107
|
+
|
|
108
|
+
def _vlan(self, vlan: int) -> VLANInfo | None:
|
|
109
|
+
return next((v for v in self._reader.get_vlans() if v.vlan_id == vlan), None)
|
|
110
|
+
|
|
111
|
+
def set_poe(self, port: int, on: bool, *, force: bool = False) -> None:
|
|
112
|
+
if not on:
|
|
113
|
+
self._guard(port, force) # turning PoE off is disruptive
|
|
114
|
+
before = self._poe_status(port)
|
|
115
|
+
self.client.set(SetVarbind(_poe_admin_oid(port), 1 if on else 2, "i"))
|
|
116
|
+
after = self._poe_status(port)
|
|
117
|
+
if after is None or after.admin_enabled != on:
|
|
118
|
+
raise WriteVerificationError(
|
|
119
|
+
f"PoE admin for port {port} did not read back as {on}",
|
|
120
|
+
before=before, after=after,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def _poe_rearm(
|
|
124
|
+
self,
|
|
125
|
+
port: int,
|
|
126
|
+
*,
|
|
127
|
+
timeouts: PoeCycleTimeouts,
|
|
128
|
+
sleep: Callable[[float], None],
|
|
129
|
+
clock: Callable[[], float],
|
|
130
|
+
on_recovered: Callable[[PoEStatus | None], bool],
|
|
131
|
+
on_timeout_message: str,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Re-arm PoE on ``port``: TWO SEPARATE sequential SETs (off, then on)
|
|
134
|
+
each polled to completion -- never a single duplicate-OID ``set_many``
|
|
135
|
+
PDU. Per-varbind ordering within one PDU carrying the same OID twice
|
|
136
|
+
is undefined on real hardware (RFC 3416); a real agent may reject it
|
|
137
|
+
or collapse it (last-wins), silently defeating the off->on re-arm.
|
|
138
|
+
Shared by ``cycle_poe`` (recovery = delivering) and ``clear_poe_fault``
|
|
139
|
+
(recovery = delivering OR searching, i.e. detect has left FAULT)."""
|
|
140
|
+
before = self._poe_status(port)
|
|
141
|
+
# Phase 1: off, poll until unused/searching + link down.
|
|
142
|
+
self.client.set(SetVarbind(_poe_admin_oid(port), 2, "i"))
|
|
143
|
+
deadline = clock() + timeouts.off_timeout
|
|
144
|
+
while not _poe_is_off(self._poe_status(port), self._port_up(port)):
|
|
145
|
+
if clock() >= deadline:
|
|
146
|
+
raise WriteVerificationError(
|
|
147
|
+
f"PoE port {port} did not turn off within {timeouts.off_timeout}s",
|
|
148
|
+
before=before, after=self._poe_status(port))
|
|
149
|
+
sleep(timeouts.poll_interval)
|
|
150
|
+
# Phase 2: on, poll until the caller's recovery predicate is met.
|
|
151
|
+
self.client.set(SetVarbind(_poe_admin_oid(port), 1, "i"))
|
|
152
|
+
deadline = clock() + timeouts.on_timeout
|
|
153
|
+
while not on_recovered(self._poe_status(port)):
|
|
154
|
+
if clock() >= deadline:
|
|
155
|
+
raise WriteVerificationError(
|
|
156
|
+
on_timeout_message.format(timeout=timeouts.on_timeout),
|
|
157
|
+
before=before, after=self._poe_status(port))
|
|
158
|
+
sleep(timeouts.poll_interval)
|
|
159
|
+
|
|
160
|
+
def cycle_poe(
|
|
161
|
+
self,
|
|
162
|
+
port: int,
|
|
163
|
+
*,
|
|
164
|
+
force: bool = False,
|
|
165
|
+
timeouts: PoeCycleTimeouts = _DEFAULT_POE_TIMEOUTS,
|
|
166
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
167
|
+
clock: Callable[[], float] = time.monotonic,
|
|
168
|
+
) -> None:
|
|
169
|
+
self._guard(port, force)
|
|
170
|
+
self._poe_rearm(
|
|
171
|
+
port, timeouts=timeouts, sleep=sleep, clock=clock,
|
|
172
|
+
on_recovered=lambda st: bool(st and st.delivering),
|
|
173
|
+
on_timeout_message=(
|
|
174
|
+
f"PoE port {port} did not return to delivering within "
|
|
175
|
+
"{timeout}s"
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def clear_poe_fault(
|
|
180
|
+
self,
|
|
181
|
+
port: int,
|
|
182
|
+
*,
|
|
183
|
+
force: bool = False,
|
|
184
|
+
timeouts: PoeCycleTimeouts = _DEFAULT_POE_TIMEOUTS,
|
|
185
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
186
|
+
clock: Callable[[], float] = time.monotonic,
|
|
187
|
+
) -> None:
|
|
188
|
+
self._guard(port, force)
|
|
189
|
+
# Re-arm detection: disable then enable as TWO SEPARATE SETs (never a
|
|
190
|
+
# single duplicate-OID set_many -- see _poe_rearm), then POLL for
|
|
191
|
+
# detect to leave FAULT. An immediate single re-read false-negatives
|
|
192
|
+
# on real hardware because detect transitions take seconds (review
|
|
193
|
+
# item 5); tests inject tiny timeouts so this is fast against the
|
|
194
|
+
# coherent mock.
|
|
195
|
+
self._poe_rearm(
|
|
196
|
+
port, timeouts=timeouts, sleep=sleep, clock=clock,
|
|
197
|
+
on_recovered=_poe_recovered,
|
|
198
|
+
on_timeout_message=(
|
|
199
|
+
f"PoE port {port} still in FAULT after clear within {{timeout}}s"
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _port_up(self, port: int) -> bool:
|
|
204
|
+
status = self._port_status(port)
|
|
205
|
+
return bool(status and status.link_up)
|
|
206
|
+
|
|
207
|
+
def set_port_enabled(
|
|
208
|
+
self, port: int, enabled: bool, *, force: bool = False
|
|
209
|
+
) -> None:
|
|
210
|
+
if not enabled:
|
|
211
|
+
self._guard(port, force) # disabling a port is disruptive
|
|
212
|
+
before = self._port_status(port)
|
|
213
|
+
self.client.set(
|
|
214
|
+
SetVarbind(f"{oids.IF_ADMIN_STATUS}.{port}", 1 if enabled else 2, "i")
|
|
215
|
+
)
|
|
216
|
+
after = self._port_status(port)
|
|
217
|
+
if after is None or after.admin_enabled != enabled:
|
|
218
|
+
raise WriteVerificationError(
|
|
219
|
+
f"admin state for port {port} did not read back as {enabled}",
|
|
220
|
+
before=before, after=after,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def set_pvid(self, port: int, vlan: int, *, force: bool = False) -> None:
|
|
224
|
+
self._guard(port, force) # changing a port's PVID is disruptive
|
|
225
|
+
before = self._reader.get_pvids()
|
|
226
|
+
self.client.set(SetVarbind(f"{oids.DOT1Q_PVID}.{port}", vlan, "u"))
|
|
227
|
+
after = self._reader.get_pvids()
|
|
228
|
+
if (port, vlan) not in after:
|
|
229
|
+
raise WriteVerificationError(
|
|
230
|
+
f"PVID for port {port} did not read back as {vlan}",
|
|
231
|
+
before=before, after=after,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def set_vlan_membership(
|
|
235
|
+
self, vlan: int, port: int, mode: VlanMode, *, force: bool = False
|
|
236
|
+
) -> None:
|
|
237
|
+
self._guard(port, force)
|
|
238
|
+
before = self._vlan(vlan)
|
|
239
|
+
if before is None:
|
|
240
|
+
# Precondition failure: no SET has been attempted, so this is NOT a
|
|
241
|
+
# verification divergence (review item 9).
|
|
242
|
+
raise SnmpError(f"VLAN {vlan} does not exist")
|
|
243
|
+
new_egress, new_untagged = membership_bitmaps(
|
|
244
|
+
mode=mode, port=port,
|
|
245
|
+
egress=encode_port_bitmap(before.member_ports),
|
|
246
|
+
untagged=encode_port_bitmap(before.untagged_ports),
|
|
247
|
+
width_bytes=vlan_bitmap_width(self.model),
|
|
248
|
+
)
|
|
249
|
+
self.client.set_many([
|
|
250
|
+
SetVarbind(f"{oids.DOT1Q_VLAN_STATIC_EGRESS}.{vlan}", new_egress, "x"),
|
|
251
|
+
SetVarbind(f"{oids.DOT1Q_VLAN_STATIC_UNTAGGED}.{vlan}", new_untagged, "x"),
|
|
252
|
+
])
|
|
253
|
+
after = self._vlan(vlan)
|
|
254
|
+
# Verify BOTH columns this op wrote: egress membership AND the untagged
|
|
255
|
+
# set. A mock/device that accepts the egress SET but silently drops the
|
|
256
|
+
# untagged SET must be caught (review item 1).
|
|
257
|
+
want_egress = frozenset(decode_port_bitmap(new_egress))
|
|
258
|
+
want_untagged = frozenset(decode_port_bitmap(new_untagged))
|
|
259
|
+
if after is None:
|
|
260
|
+
raise WriteVerificationError(
|
|
261
|
+
f"VLAN {vlan} disappeared while setting membership for port {port}",
|
|
262
|
+
before=before, after=after,
|
|
263
|
+
)
|
|
264
|
+
if after.member_ports != want_egress:
|
|
265
|
+
raise WriteVerificationError(
|
|
266
|
+
f"VLAN {vlan} egress (member_ports) for port {port} did not "
|
|
267
|
+
f"verify: wanted {sorted(want_egress)}, "
|
|
268
|
+
f"got {sorted(after.member_ports)}",
|
|
269
|
+
before=before, after=after,
|
|
270
|
+
)
|
|
271
|
+
if after.untagged_ports != want_untagged:
|
|
272
|
+
raise WriteVerificationError(
|
|
273
|
+
f"VLAN {vlan} untagged_ports for port {port} did not verify: "
|
|
274
|
+
f"wanted {sorted(want_untagged)}, got {sorted(after.untagged_ports)}",
|
|
275
|
+
before=before, after=after,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def create_vlan(self, vlan: int, name: str, *, force: bool = False) -> None:
|
|
279
|
+
# Creating an EMPTY VLAN adds no port membership, so it is
|
|
280
|
+
# non-disruptive and does NOT require force. ``force`` exists only for
|
|
281
|
+
# signature symmetry with delete_vlan (review item 3).
|
|
282
|
+
before = self._vlan(vlan)
|
|
283
|
+
self.client.set_many([
|
|
284
|
+
SetVarbind(f"{oids.DOT1Q_VLAN_STATIC_ROW_STATUS}.{vlan}",
|
|
285
|
+
oids.ROW_STATUS_CREATE_AND_GO, "i"),
|
|
286
|
+
SetVarbind(f"{oids.DOT1Q_VLAN_STATIC_NAME}.{vlan}", name, "s"),
|
|
287
|
+
])
|
|
288
|
+
after = self._vlan(vlan)
|
|
289
|
+
if after is None or (after.name or "") != name:
|
|
290
|
+
raise WriteVerificationError(
|
|
291
|
+
f"VLAN {vlan} was not created with name {name!r}",
|
|
292
|
+
before=before, after=after,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def delete_vlan(self, vlan: int, *, force: bool = False) -> None:
|
|
296
|
+
before = self._vlan(vlan)
|
|
297
|
+
if before is None:
|
|
298
|
+
# Precondition failure: no SET has been attempted, so this is NOT a
|
|
299
|
+
# verification divergence (review item 9, mirrors
|
|
300
|
+
# set_vlan_membership's missing-VLAN precondition). The mock
|
|
301
|
+
# accepts destroy(6) on an absent row as a silent no-op, so
|
|
302
|
+
# skipping this check would let a delete of a non-existent VLAN
|
|
303
|
+
# pass verification vacuously instead of surfacing as an error.
|
|
304
|
+
raise SnmpError(f"VLAN {vlan} does not exist")
|
|
305
|
+
# Destroying a VLAN strips membership from EVERY member port; if any is a
|
|
306
|
+
# protected (uplink/mgmt) port, refuse without force (review item 3).
|
|
307
|
+
if not force:
|
|
308
|
+
clash = before.member_ports & self.protected_ports
|
|
309
|
+
if clash:
|
|
310
|
+
raise ProtectedPortError(
|
|
311
|
+
f"VLAN {vlan} includes protected port(s) {sorted(clash)}; "
|
|
312
|
+
f"pass force=True to delete it anyway"
|
|
313
|
+
)
|
|
314
|
+
self.client.set(SetVarbind(
|
|
315
|
+
f"{oids.DOT1Q_VLAN_STATIC_ROW_STATUS}.{vlan}",
|
|
316
|
+
oids.ROW_STATUS_DESTROY, "i",
|
|
317
|
+
))
|
|
318
|
+
after = self._vlan(vlan)
|
|
319
|
+
if after is not None:
|
|
320
|
+
raise WriteVerificationError(
|
|
321
|
+
f"VLAN {vlan} still exists after destroy", before=before, after=after,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def set_mgmt_ip(
|
|
325
|
+
self, address: str, netmask: str, gateway: str, *, force: bool = False
|
|
326
|
+
) -> None:
|
|
327
|
+
"""Set the switch's own management IP (address/netmask/gateway).
|
|
328
|
+
|
|
329
|
+
UNVERIFIED write path (see oids.VendorOids mgmt_write_* fields): the
|
|
330
|
+
exact writable OIDs are placeholders pending Slice 7 hardware capture,
|
|
331
|
+
so this is force-gated (a wrong mgmt-IP write can strand the switch —
|
|
332
|
+
design spec §11.1). DHCP-mode switching is intentionally NOT offered
|
|
333
|
+
here because even its read OID is unverified; do not fabricate it.
|
|
334
|
+
"""
|
|
335
|
+
if not force:
|
|
336
|
+
raise ProtectedPortError(
|
|
337
|
+
"set_mgmt_ip can strand the switch and uses UNVERIFIED OIDs; "
|
|
338
|
+
"pass force=True to proceed"
|
|
339
|
+
)
|
|
340
|
+
vo = oids.vendor_oids(self.model)
|
|
341
|
+
before = self._reader.get_mgmt_ip()
|
|
342
|
+
self.client.set_many([
|
|
343
|
+
SetVarbind(vo.mgmt_write_addr_unverified, address, "a"),
|
|
344
|
+
SetVarbind(vo.mgmt_write_netmask_unverified, netmask, "a"),
|
|
345
|
+
SetVarbind(vo.mgmt_write_gateway_unverified, gateway, "a"),
|
|
346
|
+
])
|
|
347
|
+
after = self._reader.get_mgmt_ip()
|
|
348
|
+
# Highest strand-risk op: verify EVERY field written (address, netmask,
|
|
349
|
+
# AND gateway), naming whichever diverged (review item 2).
|
|
350
|
+
for field, want, got in (
|
|
351
|
+
("address", address, after.address),
|
|
352
|
+
("netmask", netmask, after.netmask),
|
|
353
|
+
("gateway", gateway, after.gateway),
|
|
354
|
+
):
|
|
355
|
+
if got != want:
|
|
356
|
+
raise WriteVerificationError(
|
|
357
|
+
f"management {field} did not read back as {want!r} (got {got!r})",
|
|
358
|
+
before=before, after=after)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class AsyncSnmpWriter:
|
|
362
|
+
"""Asynchronous SNMP write facade (mirror of SnmpWriter)."""
|
|
363
|
+
|
|
364
|
+
def __init__(
|
|
365
|
+
self,
|
|
366
|
+
client: AsyncSnmpWriteClient,
|
|
367
|
+
model: SwitchModel,
|
|
368
|
+
*,
|
|
369
|
+
protected_ports: frozenset[int] = frozenset(),
|
|
370
|
+
) -> None:
|
|
371
|
+
_require_snmp(model)
|
|
372
|
+
self.client = client
|
|
373
|
+
self.model = model
|
|
374
|
+
self.protected_ports = protected_ports
|
|
375
|
+
self._reader = AsyncSnmpReader(client, model)
|
|
376
|
+
|
|
377
|
+
def _guard(self, port: int, force: bool) -> None:
|
|
378
|
+
if port in self.protected_ports and not force:
|
|
379
|
+
raise ProtectedPortError(
|
|
380
|
+
f"port {port} is protected; pass force=True to override"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
async def _poe_status(self, port: int) -> PoEStatus | None:
|
|
384
|
+
return next((p for p in await self._reader.get_poe() if p.port == port), None)
|
|
385
|
+
|
|
386
|
+
async def _port_status(self, port: int) -> PortStatus | None:
|
|
387
|
+
return next((p for p in await self._reader.get_ports() if p.port == port), None)
|
|
388
|
+
|
|
389
|
+
async def _vlan(self, vlan: int) -> VLANInfo | None:
|
|
390
|
+
vlans = await self._reader.get_vlans()
|
|
391
|
+
return next((v for v in vlans if v.vlan_id == vlan), None)
|
|
392
|
+
|
|
393
|
+
async def set_poe(self, port: int, on: bool, *, force: bool = False) -> None:
|
|
394
|
+
if not on:
|
|
395
|
+
self._guard(port, force)
|
|
396
|
+
before = await self._poe_status(port)
|
|
397
|
+
await self.client.set(SetVarbind(_poe_admin_oid(port), 1 if on else 2, "i"))
|
|
398
|
+
after = await self._poe_status(port)
|
|
399
|
+
if after is None or after.admin_enabled != on:
|
|
400
|
+
raise WriteVerificationError(
|
|
401
|
+
f"PoE admin for port {port} did not read back as {on}",
|
|
402
|
+
before=before, after=after,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
async def _port_up(self, port: int) -> bool:
|
|
406
|
+
status = await self._port_status(port)
|
|
407
|
+
return bool(status and status.link_up)
|
|
408
|
+
|
|
409
|
+
async def _poe_rearm(
|
|
410
|
+
self,
|
|
411
|
+
port: int,
|
|
412
|
+
*,
|
|
413
|
+
timeouts: PoeCycleTimeouts,
|
|
414
|
+
sleep: Callable[[float], Awaitable[None]],
|
|
415
|
+
clock: Callable[[], float],
|
|
416
|
+
on_recovered: Callable[[PoEStatus | None], bool],
|
|
417
|
+
on_timeout_message: str,
|
|
418
|
+
) -> None:
|
|
419
|
+
"""Async twin of ``SnmpWriter._poe_rearm``: TWO SEPARATE sequential
|
|
420
|
+
SETs (off, then on) each polled to completion -- never a single
|
|
421
|
+
duplicate-OID ``set_many`` PDU (RFC 3416 per-varbind ordering is
|
|
422
|
+
undefined for a repeated OID, so a real agent may reject it or
|
|
423
|
+
collapse it and silently defeat the off->on re-arm)."""
|
|
424
|
+
before = await self._poe_status(port)
|
|
425
|
+
# Phase 1: off, poll until unused/searching + link down.
|
|
426
|
+
await self.client.set(SetVarbind(_poe_admin_oid(port), 2, "i"))
|
|
427
|
+
deadline = clock() + timeouts.off_timeout
|
|
428
|
+
while not _poe_is_off(await self._poe_status(port), await self._port_up(port)):
|
|
429
|
+
if clock() >= deadline:
|
|
430
|
+
raise WriteVerificationError(
|
|
431
|
+
f"PoE port {port} did not turn off within {timeouts.off_timeout}s",
|
|
432
|
+
before=before, after=await self._poe_status(port))
|
|
433
|
+
await sleep(timeouts.poll_interval)
|
|
434
|
+
# Phase 2: on, poll until the caller's recovery predicate is met.
|
|
435
|
+
await self.client.set(SetVarbind(_poe_admin_oid(port), 1, "i"))
|
|
436
|
+
deadline = clock() + timeouts.on_timeout
|
|
437
|
+
while not on_recovered(await self._poe_status(port)):
|
|
438
|
+
if clock() >= deadline:
|
|
439
|
+
raise WriteVerificationError(
|
|
440
|
+
on_timeout_message.format(timeout=timeouts.on_timeout),
|
|
441
|
+
before=before, after=await self._poe_status(port))
|
|
442
|
+
await sleep(timeouts.poll_interval)
|
|
443
|
+
|
|
444
|
+
async def cycle_poe(
|
|
445
|
+
self,
|
|
446
|
+
port: int,
|
|
447
|
+
*,
|
|
448
|
+
force: bool = False,
|
|
449
|
+
timeouts: PoeCycleTimeouts = _DEFAULT_POE_TIMEOUTS,
|
|
450
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
451
|
+
clock: Callable[[], float] = time.monotonic,
|
|
452
|
+
) -> None:
|
|
453
|
+
self._guard(port, force)
|
|
454
|
+
await self._poe_rearm(
|
|
455
|
+
port, timeouts=timeouts, sleep=sleep, clock=clock,
|
|
456
|
+
on_recovered=lambda st: bool(st and st.delivering),
|
|
457
|
+
on_timeout_message=(
|
|
458
|
+
f"PoE port {port} did not return to delivering within "
|
|
459
|
+
"{timeout}s"
|
|
460
|
+
),
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
async def clear_poe_fault(
|
|
464
|
+
self,
|
|
465
|
+
port: int,
|
|
466
|
+
*,
|
|
467
|
+
force: bool = False,
|
|
468
|
+
timeouts: PoeCycleTimeouts = _DEFAULT_POE_TIMEOUTS,
|
|
469
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
470
|
+
clock: Callable[[], float] = time.monotonic,
|
|
471
|
+
) -> None:
|
|
472
|
+
self._guard(port, force)
|
|
473
|
+
# Re-arm as TWO SEPARATE SETs (never a single duplicate-OID set_many
|
|
474
|
+
# -- see _poe_rearm), then poll for detect to leave FAULT (review item
|
|
475
|
+
# 5); tiny timeouts in tests.
|
|
476
|
+
await self._poe_rearm(
|
|
477
|
+
port, timeouts=timeouts, sleep=sleep, clock=clock,
|
|
478
|
+
on_recovered=_poe_recovered,
|
|
479
|
+
on_timeout_message=(
|
|
480
|
+
f"PoE port {port} still in FAULT after clear within {{timeout}}s"
|
|
481
|
+
),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
async def set_port_enabled(
|
|
485
|
+
self, port: int, enabled: bool, *, force: bool = False
|
|
486
|
+
) -> None:
|
|
487
|
+
if not enabled:
|
|
488
|
+
self._guard(port, force)
|
|
489
|
+
before = await self._port_status(port)
|
|
490
|
+
await self.client.set(
|
|
491
|
+
SetVarbind(f"{oids.IF_ADMIN_STATUS}.{port}", 1 if enabled else 2, "i")
|
|
492
|
+
)
|
|
493
|
+
after = await self._port_status(port)
|
|
494
|
+
if after is None or after.admin_enabled != enabled:
|
|
495
|
+
raise WriteVerificationError(
|
|
496
|
+
f"admin state for port {port} did not read back as {enabled}",
|
|
497
|
+
before=before, after=after,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
async def set_pvid(self, port: int, vlan: int, *, force: bool = False) -> None:
|
|
501
|
+
self._guard(port, force)
|
|
502
|
+
before = await self._reader.get_pvids()
|
|
503
|
+
await self.client.set(SetVarbind(f"{oids.DOT1Q_PVID}.{port}", vlan, "u"))
|
|
504
|
+
after = await self._reader.get_pvids()
|
|
505
|
+
if (port, vlan) not in after:
|
|
506
|
+
raise WriteVerificationError(
|
|
507
|
+
f"PVID for port {port} did not read back as {vlan}",
|
|
508
|
+
before=before, after=after,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
async def set_vlan_membership(
|
|
512
|
+
self, vlan: int, port: int, mode: VlanMode, *, force: bool = False
|
|
513
|
+
) -> None:
|
|
514
|
+
self._guard(port, force)
|
|
515
|
+
before = await self._vlan(vlan)
|
|
516
|
+
if before is None:
|
|
517
|
+
# Precondition failure (review item 9): no SET attempted.
|
|
518
|
+
raise SnmpError(f"VLAN {vlan} does not exist")
|
|
519
|
+
new_egress, new_untagged = membership_bitmaps(
|
|
520
|
+
mode=mode, port=port,
|
|
521
|
+
egress=encode_port_bitmap(before.member_ports),
|
|
522
|
+
untagged=encode_port_bitmap(before.untagged_ports),
|
|
523
|
+
width_bytes=vlan_bitmap_width(self.model),
|
|
524
|
+
)
|
|
525
|
+
await self.client.set_many([
|
|
526
|
+
SetVarbind(f"{oids.DOT1Q_VLAN_STATIC_EGRESS}.{vlan}", new_egress, "x"),
|
|
527
|
+
SetVarbind(f"{oids.DOT1Q_VLAN_STATIC_UNTAGGED}.{vlan}", new_untagged, "x"),
|
|
528
|
+
])
|
|
529
|
+
after = await self._vlan(vlan)
|
|
530
|
+
# Verify BOTH written columns (egress AND untagged) — review item 1.
|
|
531
|
+
want_egress = frozenset(decode_port_bitmap(new_egress))
|
|
532
|
+
want_untagged = frozenset(decode_port_bitmap(new_untagged))
|
|
533
|
+
if after is None:
|
|
534
|
+
raise WriteVerificationError(
|
|
535
|
+
f"VLAN {vlan} disappeared while setting membership for port {port}",
|
|
536
|
+
before=before, after=after,
|
|
537
|
+
)
|
|
538
|
+
if after.member_ports != want_egress:
|
|
539
|
+
raise WriteVerificationError(
|
|
540
|
+
f"VLAN {vlan} egress (member_ports) for port {port} did not "
|
|
541
|
+
f"verify: wanted {sorted(want_egress)}, "
|
|
542
|
+
f"got {sorted(after.member_ports)}",
|
|
543
|
+
before=before, after=after,
|
|
544
|
+
)
|
|
545
|
+
if after.untagged_ports != want_untagged:
|
|
546
|
+
raise WriteVerificationError(
|
|
547
|
+
f"VLAN {vlan} untagged_ports for port {port} did not verify: "
|
|
548
|
+
f"wanted {sorted(want_untagged)}, got {sorted(after.untagged_ports)}",
|
|
549
|
+
before=before, after=after,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
async def create_vlan(self, vlan: int, name: str, *, force: bool = False) -> None:
|
|
553
|
+
# Empty VLAN creation is non-disruptive; force is for symmetry only.
|
|
554
|
+
before = await self._vlan(vlan)
|
|
555
|
+
await self.client.set_many([
|
|
556
|
+
SetVarbind(f"{oids.DOT1Q_VLAN_STATIC_ROW_STATUS}.{vlan}",
|
|
557
|
+
oids.ROW_STATUS_CREATE_AND_GO, "i"),
|
|
558
|
+
SetVarbind(f"{oids.DOT1Q_VLAN_STATIC_NAME}.{vlan}", name, "s"),
|
|
559
|
+
])
|
|
560
|
+
after = await self._vlan(vlan)
|
|
561
|
+
if after is None or (after.name or "") != name:
|
|
562
|
+
raise WriteVerificationError(
|
|
563
|
+
f"VLAN {vlan} was not created with name {name!r}",
|
|
564
|
+
before=before, after=after,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
async def delete_vlan(self, vlan: int, *, force: bool = False) -> None:
|
|
568
|
+
before = await self._vlan(vlan)
|
|
569
|
+
if before is None:
|
|
570
|
+
# Precondition failure (review item 9): no SET attempted. Mirrors
|
|
571
|
+
# the sync path -- destroy(6) on an absent row is a silent no-op
|
|
572
|
+
# in the mock, so this must be raised before issuing any SET.
|
|
573
|
+
raise SnmpError(f"VLAN {vlan} does not exist")
|
|
574
|
+
# Refuse if a member port is protected, unless force (review item 3).
|
|
575
|
+
if not force:
|
|
576
|
+
clash = before.member_ports & self.protected_ports
|
|
577
|
+
if clash:
|
|
578
|
+
raise ProtectedPortError(
|
|
579
|
+
f"VLAN {vlan} includes protected port(s) {sorted(clash)}; "
|
|
580
|
+
f"pass force=True to delete it anyway"
|
|
581
|
+
)
|
|
582
|
+
await self.client.set(SetVarbind(
|
|
583
|
+
f"{oids.DOT1Q_VLAN_STATIC_ROW_STATUS}.{vlan}",
|
|
584
|
+
oids.ROW_STATUS_DESTROY, "i",
|
|
585
|
+
))
|
|
586
|
+
after = await self._vlan(vlan)
|
|
587
|
+
if after is not None:
|
|
588
|
+
raise WriteVerificationError(
|
|
589
|
+
f"VLAN {vlan} still exists after destroy", before=before, after=after,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
async def set_mgmt_ip(
|
|
593
|
+
self, address: str, netmask: str, gateway: str, *, force: bool = False
|
|
594
|
+
) -> None:
|
|
595
|
+
"""Set the switch's own management IP (address/netmask/gateway).
|
|
596
|
+
|
|
597
|
+
UNVERIFIED write path (see oids.VendorOids mgmt_write_* fields): the
|
|
598
|
+
exact writable OIDs are placeholders pending Slice 7 hardware capture,
|
|
599
|
+
so this is force-gated (a wrong mgmt-IP write can strand the switch —
|
|
600
|
+
design spec §11.1). DHCP-mode switching is intentionally NOT offered
|
|
601
|
+
here because even its read OID is unverified; do not fabricate it.
|
|
602
|
+
"""
|
|
603
|
+
if not force:
|
|
604
|
+
raise ProtectedPortError(
|
|
605
|
+
"set_mgmt_ip can strand the switch and uses UNVERIFIED OIDs; "
|
|
606
|
+
"pass force=True to proceed"
|
|
607
|
+
)
|
|
608
|
+
vo = oids.vendor_oids(self.model)
|
|
609
|
+
before = await self._reader.get_mgmt_ip()
|
|
610
|
+
await self.client.set_many([
|
|
611
|
+
SetVarbind(vo.mgmt_write_addr_unverified, address, "a"),
|
|
612
|
+
SetVarbind(vo.mgmt_write_netmask_unverified, netmask, "a"),
|
|
613
|
+
SetVarbind(vo.mgmt_write_gateway_unverified, gateway, "a"),
|
|
614
|
+
])
|
|
615
|
+
after = await self._reader.get_mgmt_ip()
|
|
616
|
+
# Verify EVERY field written (address, netmask, AND gateway) — item 2.
|
|
617
|
+
for field, want, got in (
|
|
618
|
+
("address", address, after.address),
|
|
619
|
+
("netmask", netmask, after.netmask),
|
|
620
|
+
("gateway", gateway, after.gateway),
|
|
621
|
+
):
|
|
622
|
+
if got != want:
|
|
623
|
+
raise WriteVerificationError(
|
|
624
|
+
f"management {field} did not read back as {want!r} (got {got!r})",
|
|
625
|
+
before=before, after=after)
|