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,557 @@
|
|
|
1
|
+
"""Public synchronous read/write facade: SyncSwitch."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
6
|
+
|
|
7
|
+
from ._dispatch import (
|
|
8
|
+
build_sync_http_client,
|
|
9
|
+
build_sync_nsdp_client,
|
|
10
|
+
build_sync_snmp_client,
|
|
11
|
+
build_sync_snmp_write_client,
|
|
12
|
+
http_reads_supported,
|
|
13
|
+
require_mac_table,
|
|
14
|
+
)
|
|
15
|
+
from .errors import CredentialError, ProtectedPortError, UnsupportedCapabilityError
|
|
16
|
+
from .http_read import HttpReader
|
|
17
|
+
from .http_write import HttpWriter
|
|
18
|
+
from .models import SwitchData
|
|
19
|
+
from .nsdp_read import NsdpReader
|
|
20
|
+
from .nsdp_write import NsdpWriter
|
|
21
|
+
from .registry import Backend
|
|
22
|
+
from .snmp_read import SnmpReader, read_system_info
|
|
23
|
+
from .snmp_write import PoeCycleTimeouts, SnmpWriter
|
|
24
|
+
|
|
25
|
+
_DEFAULT_POE_TIMEOUTS = PoeCycleTimeouts()
|
|
26
|
+
|
|
27
|
+
_R = TypeVar("_R")
|
|
28
|
+
# Per-op backend preference: try SNMP, then NSDP, then HTTP; the first backend
|
|
29
|
+
# whose reader/writer serves an op wins. HTTP only ever fills the gaps the
|
|
30
|
+
# higher-priority backends raise UnsupportedCapabilityError for.
|
|
31
|
+
_BACKEND_PREFERENCE = (Backend.SNMP, Backend.NSDP, Backend.HTTP)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Unset:
|
|
35
|
+
"""Sentinel type for "write community not yet resolved" (see
|
|
36
|
+
SyncSwitch._resolved_write_community): a resolved value of None (no
|
|
37
|
+
community configured) must stay distinguishable from "never resolved"."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_UNSET = _Unset()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _LazyHttpSession:
|
|
44
|
+
"""Wraps ``SyncSwitch._http_session`` so building the real HttpSession
|
|
45
|
+
(which needs a resolved password) is deferred until an op that genuinely
|
|
46
|
+
reaches the wire (``login``/``get_page``/``post_form``) is called. Ops an
|
|
47
|
+
HttpReader/HttpWriter refuses honestly WITHOUT ever touching the session
|
|
48
|
+
(e.g. ``get_macs``, ``set_mgmt_ip``) must never trigger HTTP password
|
|
49
|
+
resolution or a live connection -- only per-op routing that HTTP actually
|
|
50
|
+
ends up serving should pay that cost."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, resolve: Callable[[], HttpSession]) -> None:
|
|
53
|
+
self._resolve = resolve
|
|
54
|
+
|
|
55
|
+
def login(self) -> None:
|
|
56
|
+
self._resolve().login()
|
|
57
|
+
|
|
58
|
+
def get_page(self, path: str) -> str:
|
|
59
|
+
return self._resolve().get_page(path)
|
|
60
|
+
|
|
61
|
+
def post_form(self, path: str, data: dict[str, str]) -> str:
|
|
62
|
+
return self._resolve().post_form(path, data)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if TYPE_CHECKING:
|
|
66
|
+
from collections.abc import Callable, Mapping
|
|
67
|
+
from types import TracebackType
|
|
68
|
+
from typing import Self
|
|
69
|
+
|
|
70
|
+
from .config import SwitchConfig
|
|
71
|
+
from .models import (
|
|
72
|
+
DetectedModel,
|
|
73
|
+
LLDPNeighbor,
|
|
74
|
+
MacEntry,
|
|
75
|
+
MgmtIpConfig,
|
|
76
|
+
PoEStatus,
|
|
77
|
+
PortStats,
|
|
78
|
+
PortStatus,
|
|
79
|
+
Sensor,
|
|
80
|
+
VLANInfo,
|
|
81
|
+
VlanMode,
|
|
82
|
+
)
|
|
83
|
+
from .protocols.http.session import HttpSession
|
|
84
|
+
from .protocols.nsdp.client import NsdpClient, NsdpWriteClient
|
|
85
|
+
from .protocols.nsdp.types import NsdpDevice
|
|
86
|
+
from .protocols.snmp.client import SnmpClient, SnmpWriteClient
|
|
87
|
+
from .registry import SwitchModel
|
|
88
|
+
from .transport.http.client import HttpClient
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def detect_model(
|
|
92
|
+
host: str, *, community: str | None = None, client: SnmpClient | None = None
|
|
93
|
+
) -> DetectedModel:
|
|
94
|
+
"""Identify a switch's model over SNMP, WITHOUT already knowing/hardcoding
|
|
95
|
+
it -- the discovery entry point a caller (e.g. gdoc2netcfg) uses BEFORE it
|
|
96
|
+
can construct a ``SyncSwitch`` at all: call this first, then
|
|
97
|
+
``registry.get_model(detected.key)`` + ``SyncSwitch(...)`` once
|
|
98
|
+
``detected.key`` is not ``None``. See ``models.DetectedModel`` /
|
|
99
|
+
``protocols.snmp.parse.detect_model_from_sysdescr`` for exactly how (and
|
|
100
|
+
why) an unmatched sysDescr honestly yields ``key=None`` rather than a
|
|
101
|
+
guess.
|
|
102
|
+
|
|
103
|
+
Builds the default net-snmp CLI client from ``host``/``community`` unless
|
|
104
|
+
``client`` is injected (tests, or an already-open connection).
|
|
105
|
+
"""
|
|
106
|
+
if client is None:
|
|
107
|
+
client = build_sync_snmp_client(host, community)
|
|
108
|
+
return read_system_info(client)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class SyncSwitch:
|
|
112
|
+
"""Synchronous, model-driven read/write facade over one switch."""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
model: SwitchModel,
|
|
117
|
+
host: str,
|
|
118
|
+
*,
|
|
119
|
+
snmp_community: str | None = None,
|
|
120
|
+
snmp_client: SnmpClient | None = None,
|
|
121
|
+
snmp_write_community: str | None = None,
|
|
122
|
+
snmp_write_client: SnmpWriteClient | None = None,
|
|
123
|
+
snmp_write_community_resolver: Callable[[], str | None] | None = None,
|
|
124
|
+
nsdp_interface: str | None = None,
|
|
125
|
+
nsdp_client: NsdpClient | None = None,
|
|
126
|
+
nsdp_write_client: NsdpWriteClient | None = None,
|
|
127
|
+
nsdp_password: str | None = None,
|
|
128
|
+
nsdp_password_resolver: Callable[[], str | None] | None = None,
|
|
129
|
+
http_client: HttpSession | None = None,
|
|
130
|
+
http_password: str | None = None,
|
|
131
|
+
http_password_resolver: Callable[[], str | None] | None = None,
|
|
132
|
+
protected_ports: frozenset[int] = frozenset(),
|
|
133
|
+
) -> None:
|
|
134
|
+
self.model = model
|
|
135
|
+
self.host = host
|
|
136
|
+
self._snmp_community = snmp_community
|
|
137
|
+
self._snmp_client = snmp_client
|
|
138
|
+
self._snmp_write_community = snmp_write_community
|
|
139
|
+
self._snmp_write_client = snmp_write_client
|
|
140
|
+
# Deferred write-community resolution: from_config stashes a closure here
|
|
141
|
+
# instead of resolving eagerly, so read-only construction never raises a
|
|
142
|
+
# CredentialError for an unresolvable write-community spec (review item 4).
|
|
143
|
+
self._snmp_write_community_resolver = snmp_write_community_resolver
|
|
144
|
+
# Sentinel meaning "not yet resolved"; distinct from a resolved value
|
|
145
|
+
# of None (no community configured) so we only ever resolve once.
|
|
146
|
+
self._resolved_write_community: str | None | _Unset = _UNSET
|
|
147
|
+
self._nsdp_interface = nsdp_interface
|
|
148
|
+
self._nsdp_client = nsdp_client
|
|
149
|
+
self._nsdp_write_client = nsdp_write_client
|
|
150
|
+
self._nsdp_password = nsdp_password
|
|
151
|
+
self._nsdp_password_resolver = nsdp_password_resolver
|
|
152
|
+
self._resolved_nsdp_password: str | None | _Unset = _UNSET
|
|
153
|
+
self._http_client = http_client
|
|
154
|
+
self._http_password = http_password
|
|
155
|
+
self._http_password_resolver = http_password_resolver
|
|
156
|
+
self._resolved_http_password: str | None | _Unset = _UNSET
|
|
157
|
+
# A self-built HttpClient is the ONLY backend that holds a persistent
|
|
158
|
+
# connection worth closing (SNMP/NSDP clients are built fresh per call
|
|
159
|
+
# and need no equivalent teardown). Tracked separately from
|
|
160
|
+
# `_http_client` so `close()` only ever tears down a client THIS facade
|
|
161
|
+
# built -- never one the caller injected and therefore owns.
|
|
162
|
+
self._built_http_client: HttpClient | None = None
|
|
163
|
+
self._reader_cache: dict[Backend, SnmpReader | NsdpReader | HttpReader] = {}
|
|
164
|
+
self._writer_cache: dict[Backend, SnmpWriter | NsdpWriter | HttpWriter] = {}
|
|
165
|
+
self.protected_ports = protected_ports
|
|
166
|
+
|
|
167
|
+
def __enter__(self) -> Self:
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
def __exit__(
|
|
171
|
+
self,
|
|
172
|
+
exc_type: type[BaseException] | None,
|
|
173
|
+
exc: BaseException | None,
|
|
174
|
+
tb: TracebackType | None,
|
|
175
|
+
) -> None:
|
|
176
|
+
self.close()
|
|
177
|
+
|
|
178
|
+
def close(self) -> None:
|
|
179
|
+
"""Release the HTTP client THIS facade built (never one injected by
|
|
180
|
+
the caller). Safe to call even when no HTTP op was ever dispatched."""
|
|
181
|
+
if self._built_http_client is not None:
|
|
182
|
+
self._built_http_client.close()
|
|
183
|
+
self._built_http_client = None
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def from_config(
|
|
187
|
+
cls, cfg: SwitchConfig, *, env: Mapping[str, str] | None = None
|
|
188
|
+
) -> SyncSwitch:
|
|
189
|
+
# Resolve the SNMP write community LAZILY (on first write), never here.
|
|
190
|
+
# A read-only consumer whose env lacks a resolvable write-community spec
|
|
191
|
+
# (e.g. ``${UNSET_VAR}``) must still be able to construct the facade and
|
|
192
|
+
# read; only an actual write attempt may raise CredentialError/ConfigError
|
|
193
|
+
# (review item 4). We stash a closure that reads the spec + env on demand.
|
|
194
|
+
_env = env if env is not None else os.environ
|
|
195
|
+
|
|
196
|
+
def _resolve_write_community() -> str | None:
|
|
197
|
+
return cfg.snmp_write_community(env=_env)
|
|
198
|
+
|
|
199
|
+
def _resolve_nsdp_password() -> str | None:
|
|
200
|
+
# Plus switches share ONE web-admin password across HTTP + NSDP, so
|
|
201
|
+
# reusing the http_password spec as the NSDP v1 auth password is
|
|
202
|
+
# intentional and correct. A dedicated ``nsdp.password`` config key is
|
|
203
|
+
# a trivial future follow-up (the facade already accepts a distinct
|
|
204
|
+
# nsdp_password/nsdp_password_resolver) if a deployment ever needs to
|
|
205
|
+
# split them; do NOT add a separate key now.
|
|
206
|
+
return cfg.http_password(env=_env)
|
|
207
|
+
|
|
208
|
+
def _resolve_http_password() -> str | None:
|
|
209
|
+
return cfg.http_password(env=_env)
|
|
210
|
+
|
|
211
|
+
return cls(
|
|
212
|
+
cfg.model, cfg.host,
|
|
213
|
+
snmp_community=cfg.snmp_community,
|
|
214
|
+
snmp_write_community_resolver=_resolve_write_community,
|
|
215
|
+
nsdp_interface=cfg.nsdp_interface,
|
|
216
|
+
nsdp_password_resolver=_resolve_nsdp_password,
|
|
217
|
+
http_password_resolver=_resolve_http_password,
|
|
218
|
+
protected_ports=cfg.protected_ports,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def _http_session(self) -> HttpSession:
|
|
222
|
+
if self._http_client is not None:
|
|
223
|
+
return self._http_client
|
|
224
|
+
if self._built_http_client is None:
|
|
225
|
+
self._built_http_client = build_sync_http_client(
|
|
226
|
+
self.host, self._resolve_http_password(), self.model
|
|
227
|
+
)
|
|
228
|
+
return self._built_http_client
|
|
229
|
+
|
|
230
|
+
def _resolve_http_password(self) -> str | None:
|
|
231
|
+
if not isinstance(self._resolved_http_password, _Unset):
|
|
232
|
+
return self._resolved_http_password
|
|
233
|
+
resolved: str | None
|
|
234
|
+
if self._http_password is not None:
|
|
235
|
+
resolved = self._http_password
|
|
236
|
+
elif self._http_password_resolver is not None:
|
|
237
|
+
resolved = self._http_password_resolver()
|
|
238
|
+
else:
|
|
239
|
+
resolved = None
|
|
240
|
+
self._resolved_http_password = resolved
|
|
241
|
+
return resolved
|
|
242
|
+
|
|
243
|
+
def _reader_for(self, backend: Backend) -> SnmpReader | NsdpReader | HttpReader:
|
|
244
|
+
cached = self._reader_cache.get(backend)
|
|
245
|
+
if cached is not None:
|
|
246
|
+
return cached
|
|
247
|
+
reader: SnmpReader | NsdpReader | HttpReader
|
|
248
|
+
if backend is Backend.SNMP:
|
|
249
|
+
client = self._snmp_client
|
|
250
|
+
if client is None:
|
|
251
|
+
client = build_sync_snmp_client(self.host, self._snmp_community)
|
|
252
|
+
reader = SnmpReader(client, self.model)
|
|
253
|
+
elif backend is Backend.NSDP:
|
|
254
|
+
nsdp = self._nsdp_client
|
|
255
|
+
if nsdp is None:
|
|
256
|
+
nsdp = build_sync_nsdp_client(self.host, self._nsdp_interface)
|
|
257
|
+
reader = NsdpReader(nsdp, self.model)
|
|
258
|
+
else: # Backend.HTTP
|
|
259
|
+
# UNVERIFIED-reads models (gs110emx Gambit, gsm7228ps cheetah) refuse
|
|
260
|
+
# HERE -- before any session build -- so the per-op loop sees a plain
|
|
261
|
+
# UnsupportedCapabilityError, NOT a CredentialError from resolving a
|
|
262
|
+
# web password this backend will never use.
|
|
263
|
+
if not http_reads_supported(self.model):
|
|
264
|
+
raise UnsupportedCapabilityError(
|
|
265
|
+
f"model {self.model.key!r} HTTP reads are "
|
|
266
|
+
"UNVERIFIED-pending-capture"
|
|
267
|
+
)
|
|
268
|
+
reader = HttpReader(_LazyHttpSession(self._http_session), self.model)
|
|
269
|
+
self._reader_cache[backend] = reader
|
|
270
|
+
return reader
|
|
271
|
+
|
|
272
|
+
def _writer_for(self, backend: Backend) -> SnmpWriter | NsdpWriter | HttpWriter:
|
|
273
|
+
cached = self._writer_cache.get(backend)
|
|
274
|
+
if cached is not None:
|
|
275
|
+
return cached
|
|
276
|
+
writer: SnmpWriter | NsdpWriter | HttpWriter
|
|
277
|
+
if backend is Backend.SNMP:
|
|
278
|
+
client = self._snmp_write_client
|
|
279
|
+
if client is None:
|
|
280
|
+
client = build_sync_snmp_write_client(
|
|
281
|
+
self.host, self._resolve_write_community()
|
|
282
|
+
)
|
|
283
|
+
writer = SnmpWriter(
|
|
284
|
+
client, self.model, protected_ports=self.protected_ports
|
|
285
|
+
)
|
|
286
|
+
elif backend is Backend.NSDP:
|
|
287
|
+
nsdp = self._nsdp_write_client
|
|
288
|
+
if nsdp is None:
|
|
289
|
+
nsdp = build_sync_nsdp_client(self.host, self._nsdp_interface)
|
|
290
|
+
password = self._resolve_nsdp_password()
|
|
291
|
+
if password is None:
|
|
292
|
+
raise CredentialError(
|
|
293
|
+
f"no NSDP admin password configured for {self.host!r}"
|
|
294
|
+
)
|
|
295
|
+
writer = NsdpWriter(
|
|
296
|
+
nsdp, self.model, password=password,
|
|
297
|
+
protected_ports=self.protected_ports,
|
|
298
|
+
)
|
|
299
|
+
else: # Backend.HTTP
|
|
300
|
+
if not http_reads_supported(self.model):
|
|
301
|
+
raise UnsupportedCapabilityError(
|
|
302
|
+
f"model {self.model.key!r} HTTP writes are "
|
|
303
|
+
"UNVERIFIED-pending-capture"
|
|
304
|
+
)
|
|
305
|
+
writer = HttpWriter(
|
|
306
|
+
_LazyHttpSession(self._http_session), self.model,
|
|
307
|
+
protected_ports=self.protected_ports,
|
|
308
|
+
)
|
|
309
|
+
self._writer_cache[backend] = writer
|
|
310
|
+
return writer
|
|
311
|
+
|
|
312
|
+
def _read(self, op: Callable[[SnmpReader | NsdpReader | HttpReader], _R]) -> _R:
|
|
313
|
+
# Try each backend the model has, in preference order (SNMP > NSDP >
|
|
314
|
+
# HTTP), returning the first whose reader serves the op. A backend that
|
|
315
|
+
# cannot be built (construction raises UnsupportedCapabilityError) OR
|
|
316
|
+
# whose method raises it is skipped; if none serve the op, re-raise the
|
|
317
|
+
# last UnsupportedCapabilityError. A CredentialError (e.g. a missing NSDP
|
|
318
|
+
# write password) is NOT swallowed -- it propagates.
|
|
319
|
+
last: UnsupportedCapabilityError | None = None
|
|
320
|
+
for backend in _BACKEND_PREFERENCE:
|
|
321
|
+
if backend not in self.model.backends:
|
|
322
|
+
continue
|
|
323
|
+
try:
|
|
324
|
+
reader = self._reader_for(backend)
|
|
325
|
+
except UnsupportedCapabilityError as exc:
|
|
326
|
+
last = exc
|
|
327
|
+
continue
|
|
328
|
+
try:
|
|
329
|
+
return op(reader)
|
|
330
|
+
except UnsupportedCapabilityError as exc:
|
|
331
|
+
last = exc
|
|
332
|
+
if last is not None:
|
|
333
|
+
raise last
|
|
334
|
+
raise UnsupportedCapabilityError(
|
|
335
|
+
f"model {self.model.key!r} has no backend supporting this operation"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def _write(
|
|
339
|
+
self, op: Callable[[SnmpWriter | NsdpWriter | HttpWriter], None]
|
|
340
|
+
) -> None:
|
|
341
|
+
last: UnsupportedCapabilityError | None = None
|
|
342
|
+
for backend in _BACKEND_PREFERENCE:
|
|
343
|
+
if backend not in self.model.backends:
|
|
344
|
+
continue
|
|
345
|
+
try:
|
|
346
|
+
writer = self._writer_for(backend)
|
|
347
|
+
except UnsupportedCapabilityError as exc:
|
|
348
|
+
last = exc
|
|
349
|
+
continue
|
|
350
|
+
try:
|
|
351
|
+
op(writer)
|
|
352
|
+
return
|
|
353
|
+
except UnsupportedCapabilityError as exc:
|
|
354
|
+
last = exc
|
|
355
|
+
if last is not None:
|
|
356
|
+
raise last
|
|
357
|
+
raise UnsupportedCapabilityError(
|
|
358
|
+
f"model {self.model.key!r} has no backend supporting this operation"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def get_ports(self) -> list[PortStatus]:
|
|
362
|
+
return self._read(lambda r: r.get_ports())
|
|
363
|
+
|
|
364
|
+
def get_stats(self) -> list[PortStats]:
|
|
365
|
+
return self._read(lambda r: r.get_stats())
|
|
366
|
+
|
|
367
|
+
def get_vlans(self) -> list[VLANInfo]:
|
|
368
|
+
return self._read(lambda r: r.get_vlans())
|
|
369
|
+
|
|
370
|
+
def get_pvids(self) -> list[tuple[int, int]]:
|
|
371
|
+
return self._read(lambda r: r.get_pvids())
|
|
372
|
+
|
|
373
|
+
def get_lldp(self) -> list[LLDPNeighbor]:
|
|
374
|
+
return self._read(lambda r: r.get_lldp())
|
|
375
|
+
|
|
376
|
+
def get_macs(self) -> list[MacEntry]:
|
|
377
|
+
require_mac_table(self.model)
|
|
378
|
+
return self._read(lambda r: r.get_macs())
|
|
379
|
+
|
|
380
|
+
def get_poe(self) -> list[PoEStatus]:
|
|
381
|
+
return self._read(lambda r: r.get_poe())
|
|
382
|
+
|
|
383
|
+
def get_sensors(self) -> list[Sensor]:
|
|
384
|
+
return self._read(lambda r: r.get_sensors())
|
|
385
|
+
|
|
386
|
+
def get_mgmt_ip(self) -> MgmtIpConfig:
|
|
387
|
+
return self._read(lambda r: r.get_mgmt_ip())
|
|
388
|
+
|
|
389
|
+
def nsdp_device(self) -> NsdpDevice:
|
|
390
|
+
"""Return the COMPLETE raw ``NsdpDevice`` for this switch: model, MAC,
|
|
391
|
+
hostname, mgmt IP, firmware, DHCP mode, port count, serial number,
|
|
392
|
+
VLAN engine, raw per-port status (speed byte NOT pre-converted to
|
|
393
|
+
Mbps -- see ``protocols.nsdp.types.NsdpPortStatus.speed``) and
|
|
394
|
+
statistics, VLAN membership, PVIDs, plus QoS engine/mirroring/IGMP
|
|
395
|
+
snooping/broadcast filtering/loop detection.
|
|
396
|
+
|
|
397
|
+
Unlike every other read op, this deliberately bypasses the
|
|
398
|
+
SNMP/NSDP/HTTP backend-preference dispatch (``_read``): NSDP is the
|
|
399
|
+
ONLY backend that can serve it, so a model without an NSDP backend
|
|
400
|
+
raises ``UnsupportedCapabilityError`` directly (mirroring
|
|
401
|
+
``identify()``'s bypass of that dispatch below, and
|
|
402
|
+
``NsdpReader.__init__``'s own ``_require_nsdp`` guard).
|
|
403
|
+
"""
|
|
404
|
+
reader = self._reader_for(Backend.NSDP)
|
|
405
|
+
assert isinstance(reader, NsdpReader)
|
|
406
|
+
return reader.get_device()
|
|
407
|
+
|
|
408
|
+
def identify(self) -> DetectedModel:
|
|
409
|
+
"""Detect this switch's ACTUAL model via SNMP sysDescr, independent of
|
|
410
|
+
``self.model``.
|
|
411
|
+
|
|
412
|
+
Unlike every other read/write op, this deliberately bypasses the
|
|
413
|
+
per-op SNMP/NSDP/HTTP backend-preference dispatch (``_read``) AND the
|
|
414
|
+
``self.model`` SNMP-backend gate entirely: it exists precisely to
|
|
415
|
+
confirm/discover a switch's real model when the caller does not yet
|
|
416
|
+
trust the model this facade happens to have been constructed with
|
|
417
|
+
(e.g. a placeholder used only to carry host/credentials). Reuses an
|
|
418
|
+
injected ``snmp_client``/``snmp_community`` exactly like
|
|
419
|
+
``_reader_for(Backend.SNMP)`` would, but never requires
|
|
420
|
+
``self.model.backends`` to include SNMP.
|
|
421
|
+
"""
|
|
422
|
+
client = self._snmp_client
|
|
423
|
+
if client is None:
|
|
424
|
+
client = build_sync_snmp_client(self.host, self._snmp_community)
|
|
425
|
+
return read_system_info(client)
|
|
426
|
+
|
|
427
|
+
def snapshot(self) -> SwitchData:
|
|
428
|
+
"""Aggregate every read op, routing each field to the first backend that
|
|
429
|
+
supports it (SNMP > NSDP > HTTP). A field NO backend can serve degrades
|
|
430
|
+
to ()/None; a field a backend DOES serve stays populated (gs305ep:
|
|
431
|
+
ports/stats/vlans/pvids/mgmt via NSDP, poe via HTTP)."""
|
|
432
|
+
|
|
433
|
+
def _opt(
|
|
434
|
+
op: Callable[[SnmpReader | NsdpReader | HttpReader], list[Any]],
|
|
435
|
+
) -> tuple[Any, ...]:
|
|
436
|
+
try:
|
|
437
|
+
return tuple(self._read(op))
|
|
438
|
+
except UnsupportedCapabilityError:
|
|
439
|
+
return ()
|
|
440
|
+
|
|
441
|
+
try:
|
|
442
|
+
mgmt: MgmtIpConfig | None = self._read(lambda r: r.get_mgmt_ip())
|
|
443
|
+
except UnsupportedCapabilityError:
|
|
444
|
+
mgmt = None
|
|
445
|
+
|
|
446
|
+
return SwitchData(
|
|
447
|
+
model=self.model.key,
|
|
448
|
+
host=self.host,
|
|
449
|
+
ports=_opt(lambda r: r.get_ports()),
|
|
450
|
+
stats=_opt(lambda r: r.get_stats()),
|
|
451
|
+
vlans=_opt(lambda r: r.get_vlans()),
|
|
452
|
+
pvids=_opt(lambda r: r.get_pvids()),
|
|
453
|
+
mgmt_ip=mgmt,
|
|
454
|
+
poe=_opt(lambda r: r.get_poe()),
|
|
455
|
+
lldp=_opt(lambda r: r.get_lldp()),
|
|
456
|
+
sensors=_opt(lambda r: r.get_sensors()),
|
|
457
|
+
macs=_opt(lambda r: r.get_macs()),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def _resolve_write_community(self) -> str | None:
|
|
461
|
+
# Resolved once on first write, then cached: an explicit community
|
|
462
|
+
# wins, else the stashed from_config resolver runs now (may raise),
|
|
463
|
+
# else None. Every subsequent write reuses the cached result instead
|
|
464
|
+
# of re-invoking the resolver (e.g. a ``!command`` spec must not
|
|
465
|
+
# re-exec its subprocess on every single write).
|
|
466
|
+
if not isinstance(self._resolved_write_community, _Unset):
|
|
467
|
+
return self._resolved_write_community
|
|
468
|
+
resolved: str | None
|
|
469
|
+
if self._snmp_write_community is not None:
|
|
470
|
+
resolved = self._snmp_write_community
|
|
471
|
+
elif self._snmp_write_community_resolver is not None:
|
|
472
|
+
resolved = self._snmp_write_community_resolver()
|
|
473
|
+
else:
|
|
474
|
+
resolved = None
|
|
475
|
+
self._resolved_write_community = resolved
|
|
476
|
+
return resolved
|
|
477
|
+
|
|
478
|
+
def _resolve_nsdp_password(self) -> str | None:
|
|
479
|
+
if not isinstance(self._resolved_nsdp_password, _Unset):
|
|
480
|
+
return self._resolved_nsdp_password
|
|
481
|
+
resolved: str | None
|
|
482
|
+
if self._nsdp_password is not None:
|
|
483
|
+
resolved = self._nsdp_password
|
|
484
|
+
elif self._nsdp_password_resolver is not None:
|
|
485
|
+
resolved = self._nsdp_password_resolver()
|
|
486
|
+
else:
|
|
487
|
+
resolved = None
|
|
488
|
+
self._resolved_nsdp_password = resolved
|
|
489
|
+
return resolved
|
|
490
|
+
|
|
491
|
+
def set_poe(self, port: int, on: bool, *, force: bool = False) -> None:
|
|
492
|
+
self._write(lambda w: w.set_poe(port, on, force=force))
|
|
493
|
+
|
|
494
|
+
def set_port_enabled(
|
|
495
|
+
self, port: int, enabled: bool, *, force: bool = False
|
|
496
|
+
) -> None:
|
|
497
|
+
self._write(lambda w: w.set_port_enabled(port, enabled, force=force))
|
|
498
|
+
|
|
499
|
+
def set_pvid(self, port: int, vlan: int, *, force: bool = False) -> None:
|
|
500
|
+
self._write(lambda w: w.set_pvid(port, vlan, force=force))
|
|
501
|
+
|
|
502
|
+
def set_vlan_membership(
|
|
503
|
+
self, vlan: int, port: int, mode: VlanMode, *, force: bool = False
|
|
504
|
+
) -> None:
|
|
505
|
+
self._write(lambda w: w.set_vlan_membership(vlan, port, mode, force=force))
|
|
506
|
+
|
|
507
|
+
def create_vlan(self, vlan: int, name: str, *, force: bool = False) -> None:
|
|
508
|
+
self._write(lambda w: w.create_vlan(vlan, name, force=force))
|
|
509
|
+
|
|
510
|
+
def delete_vlan(self, vlan: int, *, force: bool = False) -> None:
|
|
511
|
+
# SAFETY RAIL: HttpWriter.delete_vlan does NOT itself guard protected
|
|
512
|
+
# member ports (only its per-port ops carry an internal `_guard`; its
|
|
513
|
+
# own docstring defers VLAN-delete disruptiveness to be "guarded per-
|
|
514
|
+
# member elsewhere"). NsdpWriter.delete_vlan ALWAYS raises
|
|
515
|
+
# UnsupportedCapabilityError (NSDP has no VLAN lifecycle ops at all),
|
|
516
|
+
# so on any {NSDP, HTTP} model delete_vlan falls straight through to
|
|
517
|
+
# HTTP -- meaning nothing would otherwise stand between force=False
|
|
518
|
+
# and stripping a protected port's VLAN membership. Guard here,
|
|
519
|
+
# mirroring SnmpWriter.delete_vlan's own protected-port check, so
|
|
520
|
+
# EVERY backend gets the same safety rail regardless of which one
|
|
521
|
+
# actually ends up serving the delete.
|
|
522
|
+
self._guard_vlan_delete_members(vlan, force=force)
|
|
523
|
+
self._write(lambda w: w.delete_vlan(vlan, force=force))
|
|
524
|
+
|
|
525
|
+
def _guard_vlan_delete_members(self, vlan: int, *, force: bool) -> None:
|
|
526
|
+
if force:
|
|
527
|
+
return
|
|
528
|
+
try:
|
|
529
|
+
vlans = self._read(lambda r: r.get_vlans())
|
|
530
|
+
except UnsupportedCapabilityError:
|
|
531
|
+
return
|
|
532
|
+
for v in vlans:
|
|
533
|
+
if v.vlan_id == vlan:
|
|
534
|
+
clash = v.member_ports & self.protected_ports
|
|
535
|
+
if clash:
|
|
536
|
+
raise ProtectedPortError(
|
|
537
|
+
f"VLAN {vlan} includes protected port(s) {sorted(clash)}; "
|
|
538
|
+
f"pass force=True to delete it anyway"
|
|
539
|
+
)
|
|
540
|
+
return
|
|
541
|
+
|
|
542
|
+
def cycle_poe(
|
|
543
|
+
self, port: int, *, force: bool = False,
|
|
544
|
+
timeouts: PoeCycleTimeouts = _DEFAULT_POE_TIMEOUTS,
|
|
545
|
+
) -> None:
|
|
546
|
+
self._write(lambda w: w.cycle_poe(port, force=force, timeouts=timeouts))
|
|
547
|
+
|
|
548
|
+
def clear_poe_fault(
|
|
549
|
+
self, port: int, *, force: bool = False,
|
|
550
|
+
timeouts: PoeCycleTimeouts = _DEFAULT_POE_TIMEOUTS,
|
|
551
|
+
) -> None:
|
|
552
|
+
self._write(lambda w: w.clear_poe_fault(port, force=force, timeouts=timeouts))
|
|
553
|
+
|
|
554
|
+
def set_mgmt_ip(
|
|
555
|
+
self, address: str, netmask: str, gateway: str, *, force: bool = False
|
|
556
|
+
) -> None:
|
|
557
|
+
self._write(lambda w: w.set_mgmt_ip(address, netmask, gateway, force=force))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Transport implementations (sync net-snmp CLI, async pysnmp) for SNMP I/O."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Asynchronous SNMP transport implementations."""
|