HostRebootMixin declares reboot_host(), mixed into DeviceTypeDriver so any device may be restartable. netOrk restarted hosts by sending /sbin/reboot through a driver's private _send_command; a driver talking to an API had no such method and the reboot was silently skipped. HypervisorDriver gains GUEST_AGENT_PACKAGES / GUEST_AGENT_RUNCMD, the agent cloud-init installs so the hypervisor can read a new VM's IP. The default stays qemu-guest-agent; VMware declares open-vm-tools. NetworkTargetDict.kind may be "portgroup": a VMware port group fixes its VLAN like an SDN vnet does, without being one.
328 lines
11 KiB
Markdown
328 lines
11 KiB
Markdown
# napalm-device-types
|
||
|
||
Abstract intermediate device-type base classes for [NAPALM](https://napalm.readthedocs.io/) drivers.
|
||
|
||
Instead of inheriting directly from `napalm.base.NetworkDriver`, a driver can inherit from one of the device-type classes here to gain a richer, type-specific contract:
|
||
|
||
```python
|
||
# without napalm-device-types
|
||
class OpenWrtDriver(NetworkDriver):
|
||
...
|
||
|
||
# with napalm-device-types
|
||
from napalm_device_types import AccessPointDriver
|
||
|
||
class OpenWrtDriver(AccessPointDriver):
|
||
...
|
||
```
|
||
|
||
## Why?
|
||
|
||
NAPALM's `NetworkDriver` defines a common interface for all network devices. In practice, devices fall into distinct categories with very different capabilities. A switch exposes spanning-tree and PoE data; a firewall exposes NAT tables and VPN tunnels; a NAS exposes disk pools and shares. Writing these methods directly in a concrete driver mixes concerns and makes drivers harder to discover and compare.
|
||
|
||
`napalm-device-types` sits in between: it adds one well-typed layer of abstract methods per device category, so every driver for the same category exposes the same interface.
|
||
|
||
## Roles: what a device *is*
|
||
|
||
A device is often several things at once. A QNAP NAS runs VMs on a Linux userland; an
|
||
OpenMediaVault box is a NAS built on Debian. So a driver inherits **one role base per
|
||
role its device fills**, and **the order it lists them in is the ranking**:
|
||
|
||
```python
|
||
class QnapQtsDriver(StorageDriver, HypervisorDriver, LinuxDriver):
|
||
... # primary_role_of(...) == "storage"
|
||
```
|
||
|
||
`roles_of(cls)`, `role_keys_of(cls)` and `primary_role_of(cls)` read that back. Nothing
|
||
restates the ranking: there is no precedence table and no attribute to override.
|
||
|
||
### Role bases declare; they never implement
|
||
|
||
**A role base must not contain a single runtime method — not even a
|
||
`NotImplementedError` placeholder.** Its methods are declared under `if TYPE_CHECKING`:
|
||
|
||
```python
|
||
class StorageDriver(DeviceTypeDriver):
|
||
"""Contract, not code."""
|
||
ROLE: str = "storage"
|
||
TYPE_LABEL: str = "Storage"
|
||
|
||
if TYPE_CHECKING: # nothing exists at runtime
|
||
def get_disks(self) -> List[PhysicalDiskDict]: ...
|
||
```
|
||
|
||
This is not a style preference. A placeholder on a base class is not neutral under
|
||
multiple inheritance: it wins the MRO against a sibling base's *working* implementation
|
||
and silently replaces it. Adding one stub to a base is therefore a breaking change for
|
||
every driver that mixes that base with another. It happened three times here before the
|
||
rule existed, and each time the fix was hand-written forwarding methods in the driver.
|
||
|
||
Two things follow, and both are improvements:
|
||
|
||
- **`hasattr` is truthful again.** A method exists on a driver class exactly when that
|
||
driver implemented it, which is how netOrk asks "can this driver list disks".
|
||
- **A method that was never implemented raises `AttributeError`, not
|
||
`NotImplementedError`.** Ask before calling.
|
||
|
||
## Function classes: what a device *can do*
|
||
|
||
Behaviour shared across roles lives in a function class, exactly once, and a role base
|
||
is a thin bundle over them — `PackageManagementMixin`, `HealthMetricsMixin`,
|
||
`ServiceControlMixin`, `UpdateMixin`, `NatVpnMixin`, `MacAclMixin`, `FirewallRuleMixin`,
|
||
`DhcpServerMixin`, `PingSweepMixin`, `ConfigLifecycleMixin`, `InterfaceFilterMixin`.
|
||
`HostRebootMixin` (`reboot_host`) is mixed into `DeviceTypeDriver` itself, since any
|
||
device may be restartable; like the others it only declares.
|
||
|
||
A function class may use the **template form** — public method concrete, the
|
||
device-specific part a `_hook` declared under `if TYPE_CHECKING` — *when the base
|
||
genuinely does work* on the result: normalising, sorting, validating, or orchestrating
|
||
several hooks. `ConfigLifecycleMixin.compare_config` over `_get_running_config` is the
|
||
model. Where the base would only pass the call through, declare the method directly;
|
||
two names for one pass-through is ceremony, not design.
|
||
|
||
NAPALM's own getters (`get_facts`, `get_interfaces`, `ping`, `get_config`) are never
|
||
wrapped in a template — they belong to NAPALM, and code outside this repo relies on
|
||
their contract.
|
||
|
||
## Design principle: generic vs. device-specific logic
|
||
|
||
When adding behavior to a device-type base class, split it along one line: **would
|
||
this exact logic work unchanged for a different vendor's driver of the same
|
||
device-type, if that driver only implemented the same abstract methods?**
|
||
|
||
- If yes, it's generic — implement it once as a **concrete** method on the
|
||
device-type base class (here, in this repo).
|
||
- If no — it talks to the device itself (a specific REST endpoint, a CLI command,
|
||
a vendor-specific payload format) — it belongs in the concrete driver as the
|
||
implementation of an **abstract** method the base class declares.
|
||
|
||
Concretely: matching/comparison/reconciliation algorithms, orchestration flows, and
|
||
generic data shapes belong here. Only the actual device communication belongs in
|
||
`vendor/napalm-<name>`.
|
||
|
||
**Worked example — firewall rule diff/apply** (`FirewallDriver`):
|
||
|
||
```python
|
||
class FirewallDriver(DeviceTypeDriver):
|
||
# Abstract — every driver implements its own device communication.
|
||
def get_firewall_rules(self) -> List[FirewallRuleDict]: raise NotImplementedError
|
||
def apply_firewall_rule(self, rule: FirewallRuleDict, *, uuid: Optional[str] = None) -> Dict[str, Any]: raise NotImplementedError
|
||
def commit_firewall_rules(self) -> Dict[str, Any]: raise NotImplementedError
|
||
|
||
# Concrete — the matching/comparison/orchestration algorithm is identical
|
||
# for every firewall vendor, so it lives here once.
|
||
def diff_firewall_rules(self, desired: List[FirewallRuleDict]) -> FirewallRuleDiffDict:
|
||
... # matches self.get_firewall_rules() against `desired` by description
|
||
|
||
def apply_firewall_ruleset(self, desired: List[FirewallRuleDict]):
|
||
... # computes the diff, calls apply_firewall_rule() per change, commits
|
||
```
|
||
|
||
The same split applies to `DhcpServerMixin`: `get_dhcp_reservations`/
|
||
`apply_dhcp_reservation`/`commit_dhcp_reservations` are abstract (Kea REST on
|
||
OPNsense, dnsmasq/odhcpd UCI on OpenWrt), while `diff_dhcp_reservations` and
|
||
`apply_dhcp_reservationset` are concrete — matching by normalised MAC and the
|
||
apply-then-commit orchestration are identical for every DHCP server.
|
||
|
||
A new driver (FortiGate, pfSense, …) gets `diff_firewall_rules`/
|
||
`apply_firewall_ruleset` for free the moment it implements the three abstract
|
||
methods — it never needs to reimplement the reconciliation logic itself.
|
||
|
||
**Second worked example — ping sweeps** (`PingSweepMixin`, mixed into
|
||
`DeviceTypeDriver`, so *every* device-type driver has it):
|
||
|
||
```python
|
||
class PingSweepMixin:
|
||
# Concrete — the loop, the reply parsing, the target cap and the progress
|
||
# reporting are the same for every device that can ping at all.
|
||
def ping_sweep(self, destinations, *, count=1, timeout=1, …) -> PingSweepResultDict:
|
||
... # calls NAPALM's standard ping() once per destination
|
||
```
|
||
|
||
A driver becomes a usable sweep source the moment it implements NAPALM's
|
||
`ping()` — nothing else is required, and `driver_supports_ping(cls)` reports
|
||
whether it did (introspection, not a hand-maintained list). A driver whose
|
||
device offers something genuinely faster overrides `ping_sweep` and keeps the
|
||
return shape: `napalm-opnsense` starts a batch of ping jobs over the
|
||
diagnostics API, waits once for all of them, and reads every result with a
|
||
single request — a per-host loop would be unusable there.
|
||
|
||
This mirrors a similar split already documented on the consumer side, in NetOrk's
|
||
`docs/ARCHITECTURE.md` ("Device Warnings — Trennung von Erkennung und
|
||
Präsentation"): drivers return raw signals, the higher layer gives them meaning.
|
||
Same shape of separation, different axis — device-specific vs. generic here,
|
||
detection vs. presentation there.
|
||
|
||
## Installation
|
||
|
||
```bash
|
||
pip install napalm-device-types
|
||
```
|
||
|
||
Requires Python ≥ 3.9 and NAPALM ≥ 4.0.
|
||
|
||
## Available base classes
|
||
|
||
| Class | Target devices | Example implementations |
|
||
|---|---|---|
|
||
| `AccessPointDriver` | Wireless access points | OpenWrt, Ubiquiti UniFi, Cisco Meraki AP |
|
||
| `SwitchDriver` | Ethernet switches | Cisco IOS, Arista EOS, Juniper EX |
|
||
| `FirewallDriver` | Firewalls & UTM appliances | pfSense, Fortinet FortiOS, Cisco ASA |
|
||
| `HypervisorDriver` | Hypervisors & virtualisation platforms | Proxmox VE, VMware ESXi, KVM/libvirt |
|
||
| `OSDriver` | General-purpose operating systems | Linux, BSD, macOS |
|
||
| `StorageDriver` | Storage appliances & NAS/SAN | TrueNAS, Synology DSM, QNAP QTS |
|
||
| `ResidentialGatewayDriver` | Router + firewall + AP in one box | OpenWrt, FritzBox |
|
||
|
||
Mixins mixed into the classes above rather than used on their own:
|
||
`ConfigLifecycleMixin` (config load/compare/commit/rollback), `PingSweepMixin`
|
||
(subnet sweeps), and `DhcpServerMixin` (static DHCP reservations — mixed into
|
||
`FirewallDriver` and `ResidentialGatewayDriver`, since both commonly run the
|
||
DHCP server for their networks).
|
||
|
||
## Usage
|
||
|
||
### Access Point
|
||
|
||
```python
|
||
from napalm_device_types import AccessPointDriver
|
||
|
||
class OpenWrtDriver(AccessPointDriver):
|
||
|
||
def get_wireless_clients(self):
|
||
# return List[WirelessClientDict]
|
||
...
|
||
|
||
def get_ssids(self):
|
||
# return Dict[str, SSIDDict]
|
||
...
|
||
```
|
||
|
||
### Switch
|
||
|
||
```python
|
||
from napalm_device_types import SwitchDriver
|
||
|
||
class CiscoIOSDriver(SwitchDriver):
|
||
|
||
def get_spanning_tree(self):
|
||
# return Dict[str, SpanningTreeDict]
|
||
...
|
||
|
||
def get_poe_status(self):
|
||
# return PoESummaryDict
|
||
...
|
||
```
|
||
|
||
### Firewall
|
||
|
||
```python
|
||
from napalm_device_types import FirewallDriver
|
||
|
||
class PfSenseDriver(FirewallDriver):
|
||
|
||
def get_nat_translations(self):
|
||
# return List[NATTranslationDict]
|
||
...
|
||
|
||
def get_vpn_tunnels(self):
|
||
# return Dict[str, VPNTunnelDict]
|
||
...
|
||
```
|
||
|
||
### Hypervisor
|
||
|
||
```python
|
||
from napalm_device_types import HypervisorDriver
|
||
|
||
class ProxmoxDriver(HypervisorDriver):
|
||
|
||
def get_vms(self):
|
||
# return List[VMDict]
|
||
...
|
||
|
||
def create_vm_snapshot(self, name, snapshot, description="", include_memory=False):
|
||
...
|
||
```
|
||
|
||
### OS / Linux
|
||
|
||
```python
|
||
from napalm_device_types import OSDriver
|
||
|
||
class LinuxDriver(OSDriver):
|
||
|
||
def get_packages(self):
|
||
# return List[PackageDict]
|
||
...
|
||
|
||
def get_services(self):
|
||
# return List[ServiceDict]
|
||
...
|
||
|
||
def get_users(self):
|
||
# return List[UserDict]
|
||
...
|
||
|
||
def get_processes(self):
|
||
# return List[ProcessDict]
|
||
...
|
||
|
||
def get_cron_jobs(self):
|
||
# return List[CronJobDict]
|
||
...
|
||
```
|
||
|
||
### Storage / NAS
|
||
|
||
```python
|
||
from napalm_device_types import StorageDriver
|
||
|
||
class TrueNASDriver(StorageDriver):
|
||
|
||
def get_disks(self):
|
||
# return List[PhysicalDiskDict]
|
||
...
|
||
|
||
def get_shares(self):
|
||
# return Dict[str, NASShareDict]
|
||
...
|
||
```
|
||
|
||
## Return types
|
||
|
||
All return types are `TypedDict` classes defined in `napalm_device_types.models`. Import them directly for type annotations in your driver:
|
||
|
||
```python
|
||
from napalm_device_types.models import (
|
||
PhysicalDiskDict,
|
||
DiskPoolDict,
|
||
NASShareDict,
|
||
VolumeSnapshotDict,
|
||
)
|
||
```
|
||
|
||
## Development
|
||
|
||
```bash
|
||
git clone https://github.com/chrismanivong/napalm-device-types.git
|
||
cd napalm-device-types
|
||
pip install -e ".[dev]"
|
||
```
|
||
|
||
Run type-checking:
|
||
|
||
```bash
|
||
mypy napalm_device_types
|
||
```
|
||
|
||
## Contributing
|
||
|
||
1. Fork the repository
|
||
2. Create a feature branch (`git checkout -b feature/router-driver`)
|
||
3. Implement your changes
|
||
4. Open a Pull Request
|
||
|
||
## License
|
||
|
||
Apache-2.0 – see [LICENSE](LICENSE) for details.
|