Source code for netgear_switch.capabilities

"""Which operation each model can perform over each backend, and why not.

This module answers one question -- *can model M do operation O over backend
B?* -- without touching a switch. It exists because that question has three
consumers who must never disagree:

* the **documentation**, whose model/backend support tables are generated from
  :func:`matrix` at build time (see ``docs/_ext/support_tables.py``) rather than
  hand-maintained, so a table can never quietly drift from the code;
* **callers**, who can ask ahead of a run whether an op is worth attempting
  (e.g. an inventory sweep that skips PoE on non-PSE models instead of
  collecting exceptions);
* the **test suite**, which pins every verdict here against what actually
  happens when the op is driven for real against the virtual switch (see
  ``tests/test_capabilities.py``).

**It derives, it does not duplicate.** Every verdict is read out of the same
objects the dispatch path consults -- ``SwitchModel`` fields, ``HTTP_SPECS``
endpoint paths, ``CLI_SPECS`` verification flags, ``scp_cert_profile`` -- and
several refusal reasons are the *literal* message constants the readers raise
(``nsdp_read._NO_POE`` and friends). Re-deriving support with a parallel
hand-written rule set would recreate exactly the failure mode principle 5 warns
about: two things that agree with each other while both disagree with the
device.

The verdicts mirror the facade's own resolution order (see
``_dispatch.resolve_backend``): a backend the model does not have is
:attr:`Support.NO_BACKEND` *before* any per-operation question is asked, because
that is the error ``SyncSwitch`` raises first.
"""

from __future__ import annotations

import enum
from dataclasses import dataclass
from types import MappingProxyType
from typing import TYPE_CHECKING

from .registry import MODELS, Backend, get_model

if TYPE_CHECKING:
    from collections.abc import Iterator, Mapping

    from .protocols.http.endpoints import HttpModelSpec
    from .registry import SwitchModel

__all__ = [
    "OPERATIONS",
    "READ_OPERATIONS",
    "WRITE_OPERATIONS",
    "Capability",
    "Operation",
    "OperationKind",
    "Support",
    "backends_for",
    "matrix",
    "operation",
    "support",
]


[docs] class Support(enum.Enum): """How a (model, backend, operation) triple is served -- or refused.""" #: The backend implements this operation for this model. SUPPORTED = "supported" #: The model does not have this backend at all. This is what #: ``_dispatch.resolve_backend`` raises, before the operation is considered. NO_BACKEND = "no-backend" #: The model has the backend, but that backend cannot serve this operation #: -- either the protocol has no such notion (NSDP has no PoE tag) or the #: device genuinely lacks the hardware (no PSE ports). Never a stand-in for #: "not implemented yet": see principle 2 in ``CLAUDE.md``. UNSUPPORTED = "unsupported" #: Implemented, but gated off because the backend's per-model spec is not #: yet cross-verified against live hardware (``HttpModelSpec.reads_verified`` #: / ``CliModelSpec.reads_verified`` / ``writes_verified``). The facade #: refuses to dispatch rather than return output nobody has checked. UNVERIFIED = "unverified"
[docs] class OperationKind(enum.Enum): READ = "read" WRITE = "write"
[docs] @dataclass(frozen=True) class Operation: """One facade operation, as exposed by ``SyncSwitch``/``AsyncSwitch``.""" #: The method name on the facade, e.g. ``"get_ports"``. name: str kind: OperationKind #: One-line description, reused as the table row label in the docs. summary: str #: Backends that can *ever* serve this op, for the few that bypass normal #: backend dispatch (``nsdp_device`` is NSDP-only; certificate upload is #: HTTP or CLI-over-SCP). ``None`` means "any backend the model has". backends: frozenset[Backend] | None = None
_CLI_BACKENDS = frozenset({Backend.SSH, Backend.TELNET, Backend.CONSOLE}) #: HTTP writes implemented by scraping the Plus dialect's CSRF token. A dialect #: without that token cannot serve any of them -- see #: ``endpoints.dialect_has_csrf_hash`` for the measurement. #: #: The XML-API dialect is exempt, and not as a special case: its writer posts an #: XML body and never scrapes a token, so "does this UI carry an <input #: name='hash'>" is not a question about it. Both ops are LIVE-VERIFIED there #: (GS728TPP 10.2.5.10, VLAN 4001 created with its name, then deleted). _CSRF_HTTP_WRITES = frozenset({"create_vlan", "delete_vlan"}) READ_OPERATIONS: tuple[Operation, ...] = ( Operation("get_ports", OperationKind.READ, "Per-port link/admin status"), Operation("get_stats", OperationKind.READ, "Per-port octet/packet counters"), Operation( "get_vlans", OperationKind.READ, "VLAN list with tagged/untagged members" ), Operation("get_pvids", OperationKind.READ, "Per-port PVID"), Operation("get_lldp", OperationKind.READ, "LLDP neighbour table"), Operation("get_macs", OperationKind.READ, "MAC/FDB forwarding table"), Operation("get_poe", OperationKind.READ, "Per-port PoE status and power draw"), Operation("get_sensors", OperationKind.READ, "Fan/PSU/temperature sensors"), Operation("get_mgmt_ip", OperationKind.READ, "Management IP configuration"), Operation("get_hostname", OperationKind.READ, "The switch's host name"), Operation( "get_users", OperationKind.READ, "Local login accounts and their access level", # CLI and HTTP. SNMP stays out, deliberately: the S3300's vendor SNMP # user table holds ONE account where its own CLI lists two, so those two # backends do not report the same set, and claiming SNMP serves this # would assert an equivalence the hardware contradicts. Leaving it out # makes no cross-backend claim at all rather than a wrong one. # # HTTP was added 2026-08-03 off userManagement.html, live-read on # gsm7252ps and m4300-24x and cross-checked against each switch's own # `show users` (same two accounts, same order). Models whose UI has no # such page located -- every Plus SKU, the GS728TPP, and gsm7228ps, # whose host 404s that URL -- are filtered out by _http_path_for on a # null users_path, so the support table shows the hole instead of # claiming coverage. backends=frozenset({Backend.HTTP}) | _CLI_BACKENDS, ), Operation( "get_services", OperationKind.READ, "Which management services (http/https/telnet/ssh) are enabled", # CLI and HTTP; the SNMP/NSDP equivalents remain unlocated rather than # absent. HTTP was added 2026-08-03 off the four per-service config # pages, live cross-checked against `show ip http` / `show ip ssh` / # `show telnetcon` on gsm7252ps and m4300-24x -- every state agrees, and # so does every port the pages print. # # A model is offered this only when ALL FOUR pages are located # (_http_path_for defers to http_read._service_paths). gsm7228ps is the # reason: two of its four pages parse, the other two cannot be asked, and # reporting the two would read as "this switch has no SSH". backends=frozenset({Backend.HTTP}) | _CLI_BACKENDS, ), Operation( "get_syslog", OperationKind.READ, "Remote-logging configuration and collectors", # SNMP, HTTP and the CLI all READ this, and all three agree # field-for-field on live hardware. # # HTTP was added on 2026-08-03, replacing this comment's own claim that # "no dialect's syslog page has been located or captured yet" -- which # was true only of the search, not of the devices. Every managed switch # publishes ``syslogConfiguration.html`` in its nav JS, and all four # answered it live; the models WITHOUT it (the Plus SKUs and the # GS728TPP) are filtered out by _http_path_for on a null syslog_path. # NSDP genuinely has no logging tag -- that came from an exhaustive tag # sweep of a live GS110EMX, so it is measured absence, not an unsearched # one. backends=frozenset({Backend.SNMP, Backend.HTTP}) | _CLI_BACKENDS, ), Operation( "nsdp_device", OperationKind.READ, "Full NSDP device record", backends=frozenset({Backend.NSDP}), ), ) WRITE_OPERATIONS: tuple[Operation, ...] = ( Operation("set_port_enabled", OperationKind.WRITE, "Bring a port up or down"), Operation("set_poe", OperationKind.WRITE, "Enable or disable PoE on a port"), Operation("cycle_poe", OperationKind.WRITE, "Power-cycle a PoE port"), Operation("clear_poe_fault", OperationKind.WRITE, "Clear a latched PoE fault"), Operation( "set_port_description", OperationKind.WRITE, "Set or clear a port's description", # Every backend serves this, each grounded separately: # SNMP ifAlias -- writable, confirmed on a GS728TPP (a SET was # accepted and read straight back) # NSDP tag 0xB000 PORT_NAME -- the READ encoding is measured on three # GS110EMX units and the write is that same shape; the write # itself is unexercised (those units were powered off), so # verify-after-write is what makes it safe to offer # CLI `description '<text>'` / `no description` -- the quoted form is # read off a live GSM7252PS's own running-config # HTTP interfaceDescription on the GoAhead ports page. Only that # dialect: the FASTPATH XUI port pages have the column but its # cell id was never captured, and the writer refuses by name # rather than posting into a guessed cell. ), Operation( "set_port_speed", OperationKind.WRITE, "Force a port's speed/duplex, or restore auto-negotiation", # CLI and HTTP. The other two refuse BY NAME rather than being quietly # absent (see each writer's set_port_speed): # SNMP ifSpeed/ifHighSpeed report the NEGOTIATED rate; the MAU-MIB # columns that would carry the setting have not been walked, so # their presence is unknown rather than absent # NSDP the per-port speed byte is a LINK-STATE code (0x00 means DOWN) # # The CLI grammar is proven by execution on gsm7252ps 10.1.5.22 port # 1/0/8 (2026-08-03), including the switch REFUSING a forced 1000. HTTP # is the GoAhead XML API only, transcribed from the ports page's own # submit JS; _http_path_for filters every other dialect out, because the # FASTPATH XUI Speed control's cell id was never captured. # # The two backends deliberately DISAGREE about a forced 1000 -- the CLI # grammar omits it, the GoAhead dropdown offers it -- and each says so # for its own measured reason. That is what per-backend grounding looks # like when it is done properly rather than harmonised into a guess. backends=frozenset({Backend.HTTP}) | _CLI_BACKENDS, ), Operation( "set_flow_control", OperationKind.WRITE, "Turn IEEE 802.3x flow control on or off for a port", # CLI only, and the other three refuse by name for reasons that differ: # SNMP dot3PauseAdminMode is READ on the one model that publishes it, # but no SET has ever been issued against it here # NSDP the flow-control byte is read; no write tag is identified # HTTP MEASURED absence -- the GoAhead ports page publishes # flowControlAdminType/OperType but has NO control for either, # and its submit builder emits no flow-control field at all # # The CLI form is a bare `flowcontrol` / `no flowcontrol` toggle, # round-tripped on gsm7252ps 10.1.5.22 port 1/0/8 (2026-08-03). backends=_CLI_BACKENDS, ), Operation("set_pvid", OperationKind.WRITE, "Set a port's PVID"), Operation( "set_vlan_membership", OperationKind.WRITE, "Set a port tagged/untagged/excluded on a VLAN", ), Operation("create_vlan", OperationKind.WRITE, "Create a VLAN"), Operation("delete_vlan", OperationKind.WRITE, "Delete a VLAN"), Operation("set_mgmt_ip", OperationKind.WRITE, "Set the management IP/mask/gateway"), Operation( "set_hostname", OperationKind.WRITE, "Set the switch's host name", # SNMP (sysName), NSDP (tag 0x0003) and the FASTPATH CLI (`hostname`), # each confirmed writable against real hardware: # # SNMP all five reachable switches accepted a SET of the value they # already held -- a zero-impact writability probe # NSDP live round trip on gs110emx3 (10.1.5.27): recorded the prior # name, wrote a throwaway, read it back, restored, re-read # CLI round-trips on all four CLI models # # That closes the Plus-model hole this restriction used to expose: every # registered model now has at least one backend that can rename it. # # HTTP joined them for the GoAhead XML API only (2026-08-03), where # DeviceBasicInfo/deviceName IS the host name -- measured reading # byte-for-byte what SNMP reports through sysName. The other dialects # are filtered out by _http_path_for. # # CORRECTION 2026-08-05: this used to say the gs110emx and gs105pe # identity pages carry "a switch_name field but no captured write # form". That is wrong -- both committed fixtures contain a complete # POST form around it: # # gs110emx_sysinfo.html <form method="post" # ACTION="/iss/specific/sysInfo.html"> switch_name, dhcp_mode, # IP_ADDRESS, SUBNET_MASK, GATEWAY_ADDRESS, Gambit, refreshFlag # gs105pe_switch_info.html <form method="post" # action="/switch_info.cgi"> switch_name, dhcpMode, ip_address, # subnet_mask, gateway_address, hash # # It is still NOT offered, for a different and better reason: that one # form submits the host name TOGETHER with the management IP, so a # rename must read-modify-write every other field back verbatim, and a # mistake strands the switch on an address nobody can reach. Both units # are powered off, so it cannot be proven -- and this is the one write # where shipping unproven is not acceptable. See task #67. backends=frozenset({Backend.SNMP, Backend.NSDP, Backend.HTTP}) | _CLI_BACKENDS, ), Operation( "set_syslog_enabled", OperationKind.WRITE, "Turn remote logging on or off", # SNMP only, and deliberately narrow. The vendor logging admin-mode # column was confirmed WRITABLE on m4300-24x, gsm7252ps and gsm7228ps # by SETting each the value it already held -- a probe that cannot # change device state but still separates a writable column from a # read-only one. A model with no 4526 vendor subtree (gs728tpp) has # nothing to write, and _snmp_support refuses it there by name. # # This op existed on the facade with NO entry here at all, so # ``support(model, backend, "set_syslog_enabled")`` raised KeyError and # the published support matrix simply omitted it -- an operation the # library offers and the capability table did not know about. # # The CLI joined it on 2026-08-05, closing a parity gap: `logging # syslog` is printed VERBATIM in every FASTPATH switch's own # running-config, so the command form was learned read-only rather than # probed. (The `no` negation is the standard FASTPATH form and is # inferred; a wrong one is rejected loudly by CliWriter._run.) backends=frozenset({Backend.SNMP}) | _CLI_BACKENDS, ), Operation( "add_syslog_collector", OperationKind.WRITE, "Add a remote syslog collector", # CLI only. The command form is VERBATIM from `show running-config` on # all four FASTPATH models (2026-08-05, read-only): # logging host "10.1.5.1" ipv4 514 info # LIVE-VERIFIED on all four by adding and removing a TEST-NET-1 address. # CLI ONLY, and both other candidates refuse for MEASURED reasons: # SNMP the agent will not create a row -- five mechanisms, all # refused with captured SMI errors (see SnmpWriter) # HTTP the M4300 page's template row is reachable and the body can be # built, but the firmware answers "Failed to Set 'Host Address'" # and the table does not change (see HttpWriter). Its DELETE on # the same page works and IS offered. # NSDP no logging surface at all. backends=_CLI_BACKENDS, ), Operation( "remove_syslog_collector", OperationKind.WRITE, "Remove a remote syslog collector", # CLI *and* SNMP, and the asymmetry with add is the AGENT's, measured # rather than assumed: it refuses to CREATE a syslog host row through # every mechanism but honours RowStatus destroy(6) on an existing one # (live on m4300-24x 10.1.5.13, 2026-08-05). # # CLI: `logging host remove <index>` -- a SUBCOMMAND, not the negation # it looks like; `no logging host ...` is rejected in every spelling. # Both backends address the table's OWN Index, which is SPARSE, so both # read it fresh rather than counting rows. # # HTTP (M4300 XUI) needs no index at all: it marks the target row's own # write-only row-status cell "Delete" and clicks the page's Delete # button, addressing the row by its rendered fields. backends=frozenset({Backend.SNMP, Backend.HTTP}) | _CLI_BACKENDS, ), Operation( "upload_certificate", OperationKind.WRITE, "Upload an HTTPS certificate over the web UI", backends=frozenset({Backend.HTTP}), ), Operation( "upload_certificate_scp", OperationKind.WRITE, "Deploy an HTTPS certificate via FASTPATH ``copy scp://``", backends=_CLI_BACKENDS, ), ) OPERATIONS: tuple[Operation, ...] = READ_OPERATIONS + WRITE_OPERATIONS _BY_NAME: Mapping[str, Operation] = MappingProxyType({o.name: o for o in OPERATIONS})
[docs] def operation(name: str) -> Operation: """Look an :class:`Operation` up by facade method name.""" try: return _BY_NAME[name] except KeyError: raise KeyError(f"unknown operation: {name!r}") from None
[docs] @dataclass(frozen=True) class Capability: """The verdict for one (model, backend, operation) triple.""" model_key: str backend: Backend operation: Operation support: Support #: Empty when :attr:`support` is :attr:`Support.SUPPORTED`; otherwise the #: reason, phrased the way the corresponding reader/writer phrases it. reason: str = "" @property def supported(self) -> bool: return self.support is Support.SUPPORTED
# --- per-backend derivations ------------------------------------------------- # # Each returns (Support, reason). They read the SAME spec objects the readers # and writers read; nothing here re-states a rule that lives elsewhere. _POE_OPS = frozenset({"get_poe", "set_poe", "cycle_poe", "clear_poe_fault"}) def _no_pse(model: SwitchModel) -> tuple[Support, str]: return ( Support.UNSUPPORTED, f"{model.display_name} has no PSE ports, so it has no PoE to report or set", ) def _snmp_support(model: SwitchModel, op: Operation) -> tuple[Support, str]: # SnmpReader/SnmpWriter serve almost everything from standard MIBs; the # model-dependent refusals are the guards they raise themselves. from .protocols.snmp import oids if op.name in _POE_OPS and model.poe_port_count == 0: return _no_pse(model) if op.name == "set_mgmt_ip" and not oids.has_vendor_oids(model): # SnmpWriter.set_mgmt_ip writes the vendor mgmt-IP columns, so a model # whose agent registers no 4526 subtree at all (the GS728TPP -- a walk # of 1.3.6.1.4.1.4526 answers noSuchObject) has nothing to write. The # READ path has a standard-MIB fallback (ipAddrTable); the write does # not, because no standard writable equivalent was found. return ( Support.UNSUPPORTED, f"model {model.key!r} registers no Netgear vendor OID subtree, and " "the management-IP write columns are vendor-only", ) if op.name in ( "get_syslog", "set_syslog_enabled", # The RowStatus destroy writes <vendor base>.14.1.4.5.1.7, so a model # with no vendor subtree (gs728tpp) cannot serve it either -- and # SnmpWriter.remove_syslog_collector refuses it by name for that reason. "remove_syslog_collector", ) and not oids.has_vendor_oids(model): # Logging lives at <vendor base>.14 on both vendor families, so a model # whose agent registers no 4526 subtree at all (gs728tpp -- a walk of # 1.3.6.1.4.1.4526 answers noSuchObject) has nothing to read OR write. # SnmpReader.get_syslog and SnmpWriter.set_syslog_enabled both refuse by # name for the same reason: an empty result would be indistinguishable # from a switch with no collectors, and a write has no column to land in. return ( Support.UNSUPPORTED, f"model {model.key!r} registers no Netgear vendor OID subtree, and " "the logging columns are vendor-only", ) if op.name == "create_vlan" and not model.snmp_can_create_vlan: # Reuse the writer's own refusal text so the table and the code that # enforces it cannot drift apart. from .snmp_write import _NO_VLAN_CREATE return Support.UNSUPPORTED, f"model {model.key!r}: {_NO_VLAN_CREATE}" if op.name == "get_macs" and not model.has_mac_table: # pragma: no cover # Unreachable today: has_mac_table IS "has an SNMP backend". Kept so the # rule tracks the property rather than assuming its current definition. return Support.UNSUPPORTED, f"model {model.key!r} has no MAC/FDB table" return Support.SUPPORTED, "" def _nsdp_support(model: SwitchModel, op: Operation) -> tuple[Support, str]: # Reasons are the reader's/writer's own message constants, so a change to # what NSDP refuses updates this table in the same edit. from . import nsdp_read, nsdp_write refusals = { "get_macs": nsdp_read._NO_MACS, "get_lldp": nsdp_read._NO_LLDP, "get_sensors": nsdp_read._NO_SENSORS, "get_poe": nsdp_read._NO_POE, "set_poe": nsdp_write._NO_POE, "cycle_poe": nsdp_write._NO_POE, "clear_poe_fault": nsdp_write._NO_POE, "set_port_enabled": nsdp_write._NO_PORT_ADMIN, } reason = refusals.get(op.name) if reason is not None: return Support.UNSUPPORTED, reason return Support.SUPPORTED, "" #: Writes the XML-API (GoAhead ``wcd``) writer actually implements, each with a #: body builder GROUNDED in the page's own JavaScript. An op absent here is #: honestly unsupported on this dialect -- not "probably works": the endpoint #: is shared, so a missing entry means nobody has established what body that #: operation sends, and guessing one would write something unintended. _XML_API_WRITES = { "set_vlan_membership": True, "set_port_enabled": True, "set_poe": True, "set_pvid": True, "create_vlan": True, "delete_vlan": True, # No reset control exists on this UI (its PoE page has only Refresh/Cancel/ # Apply, and Behaviour/UnitsPoe.js has no reset action), so these are an # admin off/on re-arm of the same field -- the mechanism SnmpWriter already # uses on agents with no reset column. "cycle_poe": True, "clear_poe_fault": True, "set_port_description": True, "set_hostname": True, # Standard802_3List's autoNegotiationAdminEnabled/speedAdmin/ # duplexAdminMode, encoded exactly as the ports page's own submit JS does. "set_port_speed": True, } def _http_path_for(spec: HttpModelSpec, op: Operation) -> str | None: """The endpoint ``op`` needs, or ``None`` if this model's UI has no such page. The mapping mirrors ``http_read``/``http_write`` one line at a time; the two ops with composite conditions defer to the reader's own helpers so there is exactly one definition of "this UI can answer that". """ from .http_read import _has_sysinfo_hostname, _mgmt_ip_path, _supports_sensors from .http_write import _is_xml_api_dialect from .protocols.http.endpoints import HtmlDialect if _is_xml_api_dialect(spec) and op.kind is OperationKind.WRITE: # On an XML-API UI every write POSTs to one endpoint and the BODY # selects the operation, so "is there a page for this op" is the wrong # question -- there is no per-op page, and answering it with the op's # READ path would claim support for any write whose data can be read. # That is not hypothetical: set_pvid was reported SUPPORTED on the # GS728TPP purely because pvid_path exists, while the writer would have # posted a Plus-class CGI form at a wcd query string. # # Certificate upload keeps its own path: it is a distinct XML flow with # its own grounding and its own response check. if op.name == "upload_certificate": return spec.cert_upload_path return spec.xml_write_path if _XML_API_WRITES.get(op.name) else None simple: dict[str, str | None] = { "get_ports": spec.dashboard_path, "get_stats": spec.stats_path, "get_poe": spec.poe_status_path, "get_pvids": spec.pvid_path, "get_vlans": spec.vlan_config_path, "get_macs": spec.mac_table_path, "get_lldp": spec.lldp_path, "get_syslog": spec.syslog_path, "get_users": spec.users_path, "set_poe": spec.poe_config_path, "cycle_poe": spec.poe_config_path, "clear_poe_fault": spec.poe_config_path, "set_pvid": spec.pvid_path, "set_vlan_membership": spec.vlan_membership_path, "create_vlan": spec.vlan_config_path, "delete_vlan": spec.vlan_config_path, "set_port_enabled": spec.port_config_path, # Only the XML-API dialect has a grounded description write; every other # dialect is handled by the branch above returning None for it. "set_port_description": None, # Same shape: the FASTPATH XUI Speed control's cell id was never # captured, so only the XML-API branch above answers for this op. "set_port_speed": None, # No dialect has a captured flow-control write form -- including the # XML-API one, whose ports page reports the field but offers no control # for it (the _XML_API_WRITES entry is absent for the same reason). "set_flow_control": None, # Only the M4300 XUI pages render the v_g_* template row AND inline the # cell metadata the write depends on; the other dialects' syslog pages # do neither, so they are refused rather than posted at on an # assumption. HttpWriter._syslog_page enforces the same rule, so there # is one definition of "this UI can be written". # The add is refused on every dialect -- the M4300 firmware rejects the # body (see HttpWriter.add_syslog_collector) and the others render no # usable template row at all. "add_syslog_collector": None, "remove_syslog_collector": ( spec.syslog_path if spec.html_dialect is HtmlDialect.M4300 else None ), # The GS110EMX sysInfo form carries switch_name, so that dialect has a # grounded (and live-verified) host-name write; every other non-XML-API # dialect is None. gs105pe's switch_info.cgi has the same field but its # own CSRF-hash envelope, which has not been driven. "set_hostname": ( spec.sysinfo_path if spec.html_dialect is HtmlDialect.GS110EMX else None ), "upload_certificate": spec.cert_upload_path, } if op.name == "get_sensors": return spec.sysinfo_path if _supports_sensors(spec) else None if op.name == "get_services": # All four pages or none -- the reader's own predicate decides, so # there is one definition of "this UI can be asked" rather than two. from .http_read import _service_paths paths = _service_paths(spec) return paths[0][1] if paths else None if op.name == "get_hostname": # Only two identity pages carry the field (gs110emx's sysInfo.html and # gs105pe's switch_info.cgi); the reader's own predicate decides, so # there is one definition rather than two that can drift. return spec.sysinfo_path if _has_sysinfo_hostname(spec) else None if op.name == "get_mgmt_ip": return _mgmt_ip_path(spec) if op.name == "set_mgmt_ip": # The XUI write needs the field map as well as the page. return spec.mgmt_ip_path if spec.mgmt_ip_fields is not None else None return simple[op.name] def _http_support(model: SwitchModel, op: Operation) -> tuple[Support, str]: from .http_write import CERT_UPLOAD_KNOWN_UNIMPLEMENTED, _is_xml_api_dialect from .protocols.http.endpoints import dialect_has_csrf_hash, http_spec spec = http_spec(model) if not spec.reads_verified: # The facade gates BOTH reads and writes on reads_verified (see # sync_api._reader_for/_writer_for): output nobody has cross-verified # against hardware is not dispatched at all. return ( Support.UNVERIFIED, f"model {model.key!r} HTTP reads are UNVERIFIED-pending-capture", ) if op.name == "upload_certificate": # These models CAN take a certificate -- just not over HTTP. The facade # raises NotImplementedError naming the real mechanism rather than # UnsupportedCapabilityError, precisely so the difference is visible; # the table says the same thing and points at the op that does work. mechanism = CERT_UPLOAD_KNOWN_UNIMPLEMENTED.get(model.key) if mechanism is not None: return ( Support.UNSUPPORTED, f"this model takes a certificate by {mechanism}, not over the " "web UI -- use upload_certificate_scp", ) if ( op.name in _CSRF_HTTP_WRITES and not _is_xml_api_dialect(spec) and not dialect_has_csrf_hash(spec.html_dialect) ): # These writers scrape an <input name="hash"> before posting, and this # dialect's pages do not carry one -- MEASURED on gsm7252ps and # gs110emx, see endpoints.dialect_has_csrf_hash. Driving them raises # HttpUnexpectedPageError on real hardware, so claiming support here # would publish a support table that contradicts the device. return ( Support.UNSUPPORTED, f"model {model.key!r} web UI carries no CSRF 'hash' token, which " f"the HTTP {op.name} writer requires", ) path = _http_path_for(spec, op) if path is None: return ( Support.UNSUPPORTED, f"model {model.key!r} web UI has no page for {op.name} ({op.summary})", ) return Support.SUPPORTED, "" def _cli_support(model: SwitchModel, op: Operation) -> tuple[Support, str]: from ._dispatch import cli_reads_supported, cli_writes_supported from .errors import UnsupportedCapabilityError from .protocols.cli.commands import scp_cert_profile if op.kind is OperationKind.READ and not cli_reads_supported(model): return ( Support.UNVERIFIED, f"model {model.key!r} CLI reads are UNVERIFIED-pending cross-verify", ) if op.kind is OperationKind.WRITE and not cli_writes_supported(model): return ( Support.UNVERIFIED, f"model {model.key!r} CLI writes are UNVERIFIED-pending a live write run", ) if op.name == "upload_certificate_scp": # The facade dispatches on this exact call, so ask it rather than # re-listing which models have a copy-scp profile. try: scp_cert_profile(model) except UnsupportedCapabilityError as exc: return Support.UNSUPPORTED, str(exc) return Support.SUPPORTED, "" if op.name in _POE_OPS and model.poe_port_count == 0: return _no_pse(model) if op.name == "get_macs" and not model.has_mac_table: # pragma: no cover return Support.UNSUPPORTED, f"model {model.key!r} CLI has no MAC/FDB table" return Support.SUPPORTED, ""
[docs] def support( model: SwitchModel | str, backend: Backend, op: Operation | str ) -> Capability: """The verdict for one triple. ``model``/``op`` accept keys or objects.""" m = get_model(model) if isinstance(model, str) else model o = operation(op) if isinstance(op, str) else op if backend not in m.backends: have = ", ".join(sorted(b.name for b in m.backends)) reason = f"model {m.key!r} has no {backend.name} backend (it has: {have})" if backend is Backend.CONSOLE: # Never registered on a model: the serial console is a TRANSPORT for # the CLI backend, not a network-reachable backend of its own (see # registry.Backend). Say so, rather than implying the CLI is absent. reason = ( "CONSOLE is a serial transport for the CLI backend, not a " "network backend; a model's CLI support is its SSH/TELNET entry" ) return Capability(m.key, backend, o, Support.NO_BACKEND, reason) if o.backends is not None and backend not in o.backends: allowed = ", ".join(sorted(b.name for b in o.backends)) return Capability( m.key, backend, o, Support.UNSUPPORTED, f"{o.name} is served only over {allowed}", ) if backend is Backend.SNMP: verdict, reason = _snmp_support(m, o) elif backend is Backend.NSDP: verdict, reason = _nsdp_support(m, o) elif backend is Backend.HTTP: verdict, reason = _http_support(m, o) else: verdict, reason = _cli_support(m, o) return Capability(m.key, backend, o, verdict, reason)
[docs] def backends_for(model: SwitchModel | str) -> tuple[Backend, ...]: """The model's backends in the facade's default-preference order.""" m = get_model(model) if isinstance(model, str) else model order = ( Backend.SNMP, Backend.NSDP, Backend.HTTP, Backend.SSH, Backend.TELNET, Backend.CONSOLE, ) return tuple(b for b in order if b in m.backends)
[docs] def matrix( models: Iterator[str] | tuple[str, ...] | None = None, operations: tuple[Operation, ...] = OPERATIONS, ) -> tuple[Capability, ...]: """Every verdict for ``models`` x their backends x ``operations``. Defaults to every registered model. Only backends a model actually has are included, so the result never carries :attr:`Support.NO_BACKEND` rows. """ keys = tuple(MODELS) if models is None else tuple(models) return tuple( support(key, backend, op) for key in keys for backend in backends_for(key) for op in operations )