chore: adopt the NAPALM driver standard (pilot) #52

Merged
christianmanivong merged 4 commits from chore/driver-standard into main 2026-10-22 22:08:14 +00:00
77 changed files with 1329 additions and 1074 deletions
Showing only changes of commit 08296d0d2e - Show all commits

No files matched your search

+30 -21
View File
@@ -6,14 +6,14 @@ Instead of inheriting directly from `napalm.base.NetworkDriver`, a driver can in
```python
# without napalm-device-types
class OpenWrtDriver(NetworkDriver):
...
class OpenWrtDriver(NetworkDriver): ...
# with napalm-device-types
from napalm_device_types import AccessPointDriver
class OpenWrtDriver(AccessPointDriver):
...
class OpenWrtDriver(AccessPointDriver): ...
```
## Why?
@@ -29,8 +29,9 @@ OpenMediaVault box is a NAS built on Debian. So a driver inherits **one role bas
role its device fills**, and **the order it lists them in is the ranking**:
```python
class QnapQtsDriver(StorageDriver, HypervisorDriver, LinuxDriver):
... # primary_role_of(...) == "storage"
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
@@ -54,10 +55,12 @@ host OS's load; it has no `HealthMetricsMixin` (SNMP) and no command channel.
```python
class StorageDriver(DeviceTypeDriver):
"""Contract, not code."""
ROLE: str = "storage"
TYPE_LABEL: str = "Storage"
if TYPE_CHECKING: # nothing exists at runtime
if TYPE_CHECKING: # nothing exists at runtime
def get_disks(self) -> List[PhysicalDiskDict]: ...
```
@@ -312,20 +315,27 @@ generic data shapes belong here. Only the actual device communication belongs in
**Worked example — firewall rule diff/apply** (`FirewallRuleMixin`, in `FirewallDriver`):
```python
class FirewallRuleMixin: # mixed into FirewallDriver
class FirewallRuleMixin: # mixed into FirewallDriver
# Declared only — every driver implements its own device communication.
if TYPE_CHECKING:
def get_firewall_rules(self) -> List[FirewallRuleDict]: ...
def apply_firewall_rule(self, rule: FirewallRuleDict, *, uuid: Optional[str] = None) -> Dict[str, Any]: ...
def apply_firewall_rule(
self, rule: FirewallRuleDict, *, uuid: Optional[str] = None
) -> Dict[str, Any]: ...
def commit_firewall_rules(self) -> Dict[str, Any]: ...
# 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 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
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`/
@@ -421,8 +431,8 @@ HTTP_FINGERPRINT = [
```python
from napalm_device_types import AccessPointDriver
class OpenWrtDriver(AccessPointDriver):
class OpenWrtDriver(AccessPointDriver):
def get_wireless_clients(self):
# return List[WirelessClientDict]
...
@@ -437,8 +447,8 @@ class OpenWrtDriver(AccessPointDriver):
```python
from napalm_device_types import SwitchDriver
class CiscoIOSDriver(SwitchDriver):
class CiscoIOSDriver(SwitchDriver):
def get_spanning_tree(self):
# return Dict[str, SpanningTreeDict]
...
@@ -453,8 +463,8 @@ class CiscoIOSDriver(SwitchDriver):
```python
from napalm_device_types import FirewallDriver
class PfSenseDriver(FirewallDriver):
class PfSenseDriver(FirewallDriver):
def get_nat_translations(self):
# return List[NATTranslationDict]
...
@@ -474,14 +484,13 @@ class PfSenseDriver(FirewallDriver):
```python
from napalm_device_types import HypervisorDriver
class ProxmoxDriver(HypervisorDriver):
class ProxmoxDriver(HypervisorDriver):
def get_vms(self):
# return List[VMDict]
...
def create_vm_snapshot(self, name, snapshot, description="", include_memory=False):
...
def create_vm_snapshot(self, name, snapshot, description="", include_memory=False): ...
def get_vm_cpu_types(self):
# optional — return List[VMCpuTypeDict]: the CPU models a new VM may get
@@ -505,8 +514,8 @@ unpacked; both are the same for every hypervisor.
```python
from napalm_device_types import OSDriver
class LinuxDriver(OSDriver):
class LinuxDriver(OSDriver):
def get_packages(self):
# return List[PackageDict]
...
@@ -533,8 +542,8 @@ class LinuxDriver(OSDriver):
```python
from napalm_device_types import StorageDriver
class TrueNASDriver(StorageDriver):
class TrueNASDriver(StorageDriver):
def get_disks(self):
# return List[PhysicalDiskDict]
...
+63 -59
View File
@@ -64,8 +64,8 @@ Introspection -- :func:`~napalm_device_types.roles.roles_of`,
:func:`~napalm_device_types.roles.primary_role_of`.
"""
from napalm_device_types.base import DeviceTypeDriver, FingerprintRule, PortSpec, rules_max_weight
from napalm_device_types.access_point import AccessPointDriver
from napalm_device_types.base import DeviceTypeDriver, FingerprintRule, PortSpec, rules_max_weight
from napalm_device_types.channel import (
ByteStream,
CommandChannelMixin,
@@ -82,10 +82,10 @@ from napalm_device_types.container_engine import (
ContainerEngineUnavailable,
)
from napalm_device_types.dhcp import DhcpServerMixin, normalize_cidr, normalize_mac
from napalm_device_types.dns_forward import DnsForwardMixin
from napalm_device_types.firewall import FirewallDriver
from napalm_device_types.hypervisor import HypervisorDriver
from napalm_device_types.os import OSDriver
from napalm_device_types.firewall_rules import FirewallRuleMixin
from napalm_device_types.firmware import FirmwareOfferMixin
from napalm_device_types.fixes import (
EnsurePackageMixin,
PackageProxyMixin,
@@ -93,24 +93,18 @@ from napalm_device_types.fixes import (
run_fix_steps,
)
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.dns_forward import DnsForwardMixin
from napalm_device_types.firmware import FirmwareOfferMixin
from napalm_device_types.ospf import OspfMixin
from napalm_device_types.licenses import LicenseMixin
from napalm_device_types.host_reboot import HostRebootMixin
from napalm_device_types.interface_filter import InterfaceFilterMixin
from napalm_device_types.kernel import KERNEL_FACTS_COMMAND, KernelFactsMixin, parse_kernel_facts
from napalm_device_types.service_packages import (
SERVICE_PACKAGES_COMMAND,
ServicePackagesMixin,
parse_service_packages,
)
from napalm_device_types.lag import add_lag_interfaces
from napalm_device_types.host_firewall import (
HostFirewallMixin,
HostFirewallUnavailable,
parse_host_firewall,
)
from napalm_device_types.host_reboot import HostRebootMixin
from napalm_device_types.host_status import HOST_STATUS_COMMAND, HostStatusMixin, parse_host_status
from napalm_device_types.hypervisor import HypervisorDriver
from napalm_device_types.interface_filter import InterfaceFilterMixin
from napalm_device_types.kernel import KERNEL_FACTS_COMMAND, KernelFactsMixin, parse_kernel_facts
from napalm_device_types.lag import add_lag_interfaces
from napalm_device_types.licenses import LicenseMixin
from napalm_device_types.listening import (
LISTENING_SOCKETS_COMMAND,
ListeningSocketsMixin,
@@ -125,67 +119,66 @@ from napalm_device_types.models import (
ConfigSyncDict,
DhcpHaDict,
DhcpHaPeerDict,
DnsForwardDict,
EventLogEntryDict,
FirmwareDict,
HardwareInventoryDict,
ManagementProtocolsDict,
PowerAction,
PowerStateDict,
HypervisorHostDict,
DnsForwardDict,
FirmwareOfferDict,
OspfDict,
OspfInterfaceDict,
UpdateProgressDict,
LicenseDict,
FixResultDict,
FixStepDict,
FixStepOutcomeDict,
HardwareInventoryDict,
HypervisorHostDict,
LicenseDict,
ManagementProtocolsDict,
OspfDict,
OspfInterfaceDict,
PowerAction,
PowerStateDict,
SnmpAgentSettingsDict,
SnmpHealthProfile,
SnmpInterfacesSpec,
SnmpMemorySpec,
SnmpValueSpec,
UpdateProgressDict,
)
from napalm_device_types.nat_vpn import NatVpnMixin
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.phone import PhoneDriver
from napalm_device_types.ping_sweep import PingSweepMixin, driver_supports_ping
from napalm_device_types.roles import primary_role_of, role_keys_of, roles_of
from napalm_device_types.host_status import HOST_STATUS_COMMAND, HostStatusMixin, parse_host_status
from napalm_device_types.os import OSDriver
from napalm_device_types.ospf import OspfMixin
from napalm_device_types.package_updates import (
APT_UPGRADABLE_COMMAND,
DNF_SECURITY_COMMAND,
parse_apt_upgradable,
parse_dnf_security,
)
from napalm_device_types.services import ServiceControlMixin
from napalm_device_types.terminal import strip_terminal_codes
from napalm_device_types.systemd import (
SYSTEMD_SERVICES_COMMAND,
SystemdServicesMixin,
SystemdUnavailable,
parse_systemd_services,
)
from napalm_device_types.updates import UpdateMixin
from napalm_device_types.system_identity import (
SYSTEM_IDENTITY_COMMAND,
SystemIdentityMixin,
is_placeholder_identity,
parse_system_identity,
)
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.phone import PhoneDriver
from napalm_device_types.ping_sweep import PingSweepMixin, driver_supports_ping
from napalm_device_types.residential_gateway import ResidentialGatewayDriver
from napalm_device_types.roles import primary_role_of, role_keys_of, roles_of
from napalm_device_types.service_clusters import (
SERVICE_CLUSTERS_COMMAND,
ServiceClustersMixin,
parse_service_clusters,
service_clusters_command,
)
from napalm_device_types.virtual_ips import (
VIRTUAL_IPS_COMMAND,
VirtualIpsMixin,
parse_virtual_ips,
from napalm_device_types.service_packages import (
SERVICE_PACKAGES_COMMAND,
ServicePackagesMixin,
parse_service_packages,
)
from napalm_device_types.services import ServiceControlMixin
from napalm_device_types.ssh_host_keys import (
KNOWN_KEYS_ARG as SSH_HOST_KEYS_ARG,
)
from napalm_device_types.ssh_host_keys import (
OBSERVER_ARG as SSH_HOST_KEY_OBSERVER_ARG,
)
from napalm_device_types.ssh_host_keys import (
HostKeyCheck,
HostKeyObservation,
SshHostKey,
SshHostKeyMismatch,
)
from napalm_device_types.residential_gateway import ResidentialGatewayDriver
from napalm_device_types.ssh_server import (
SSH_ALGORITHM_CLASSES,
OpenSshServerMixin,
@@ -193,16 +186,27 @@ from napalm_device_types.ssh_server import (
SshHardeningResult,
plan_ssh_hardening,
)
from napalm_device_types.ssh_host_keys import (
KNOWN_KEYS_ARG as SSH_HOST_KEYS_ARG,
OBSERVER_ARG as SSH_HOST_KEY_OBSERVER_ARG,
HostKeyCheck,
HostKeyObservation,
SshHostKey,
SshHostKeyMismatch,
)
from napalm_device_types.storage import StorageDriver
from napalm_device_types.switch import SwitchDriver
from napalm_device_types.system_identity import (
SYSTEM_IDENTITY_COMMAND,
SystemIdentityMixin,
is_placeholder_identity,
parse_system_identity,
)
from napalm_device_types.systemd import (
SYSTEMD_SERVICES_COMMAND,
SystemdServicesMixin,
SystemdUnavailable,
parse_systemd_services,
)
from napalm_device_types.terminal import strip_terminal_codes
from napalm_device_types.updates import UpdateMixin
from napalm_device_types.virtual_ips import (
VIRTUAL_IPS_COMMAND,
VirtualIpsMixin,
parse_virtual_ips,
)
__all__ = [
"HypervisorHostDict",
+7 -7
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Keying live and desired entries for a diff, refusing what cannot be matched.
A diff matches a desired entry to a live one by a key (a rule's description, a
@@ -8,18 +7,19 @@ name a single entry, and guessing would overwrite one the caller never meant.
from __future__ import annotations
from typing import Callable, Dict, Iterable, List, Set, Tuple, TypeVar
from collections.abc import Callable, Iterable
from typing import TypeVar
T = TypeVar("T")
def index_live(entries: Iterable[T], key: Callable[[T], str]) -> Tuple[Dict[str, T], Set[str]]:
def index_live(entries: Iterable[T], key: Callable[[T], str]) -> tuple[dict[str, T], set[str]]:
"""Live entries by key, and the keys more than one of them holds.
Entries without a key are left out: they are not the caller's to match.
"""
by_key: Dict[str, T] = {}
shared: Set[str] = set()
by_key: dict[str, T] = {}
shared: set[str] = set()
for entry in entries:
k = key(entry)
if not k:
@@ -32,10 +32,10 @@ def index_live(entries: Iterable[T], key: Callable[[T], str]) -> Tuple[Dict[str,
return by_key, shared
def check_desired(entries: List[T], key: Callable[[T], str], shared: Set[str], what: str) -> None:
def check_desired(entries: list[T], key: Callable[[T], str], shared: set[str], what: str) -> None:
"""``ValueError`` for a desired entry with no key, a key given twice, or a
key that names more than one live entry."""
seen: Set[str] = set()
seen: set[str] = set()
for entry in entries:
k = key(entry)
if not k:
+87 -56
View File
@@ -1,41 +1,43 @@
"""Shared SNMP metric collection helpers for UCD-MIB and IF-MIB."""
from __future__ import annotations
import asyncio
import re
from typing import Any, Awaitable, Callable, Dict, Optional
from collections.abc import Awaitable, Callable
from typing import Any
# ── UCD-MIB OIDs ──────────────────────────────────────────────────────────────
OID_SYS_UPTIME = "1.3.6.1.2.1.25.1.1.0" # hrSystemUptime (centiseconds)
OID_CPU_IDLE = "1.3.6.1.4.1.2021.11.11.0" # UCD ssCpuIdle (%)
OID_MEM_TOTAL = "1.3.6.1.4.1.2021.4.5.0" # memTotalReal (kB)
OID_MEM_FREE = "1.3.6.1.4.1.2021.4.6.0" # memAvailReal (kB)
OID_MEM_BUFFER = "1.3.6.1.4.1.2021.4.14.0" # memBuffer (kB)
OID_MEM_CACHED = "1.3.6.1.4.1.2021.4.15.0" # memCached (kB)
OID_SWAP_TOTAL = "1.3.6.1.4.1.2021.4.3.0" # memTotalSwap (kB)
OID_SWAP_AVAIL = "1.3.6.1.4.1.2021.4.4.0" # memAvailSwap (kB)
OID_LOAD_1 = "1.3.6.1.4.1.2021.10.1.3.1" # laLoad 1-min
OID_LOAD_5 = "1.3.6.1.4.1.2021.10.1.3.2" # laLoad 5-min
OID_LOAD_15 = "1.3.6.1.4.1.2021.10.1.3.3" # laLoad 15-min
OID_SYS_UPTIME = "1.3.6.1.2.1.25.1.1.0" # hrSystemUptime (centiseconds)
OID_CPU_IDLE = "1.3.6.1.4.1.2021.11.11.0" # UCD ssCpuIdle (%)
OID_MEM_TOTAL = "1.3.6.1.4.1.2021.4.5.0" # memTotalReal (kB)
OID_MEM_FREE = "1.3.6.1.4.1.2021.4.6.0" # memAvailReal (kB)
OID_MEM_BUFFER = "1.3.6.1.4.1.2021.4.14.0" # memBuffer (kB)
OID_MEM_CACHED = "1.3.6.1.4.1.2021.4.15.0" # memCached (kB)
OID_SWAP_TOTAL = "1.3.6.1.4.1.2021.4.3.0" # memTotalSwap (kB)
OID_SWAP_AVAIL = "1.3.6.1.4.1.2021.4.4.0" # memAvailSwap (kB)
OID_LOAD_1 = "1.3.6.1.4.1.2021.10.1.3.1" # laLoad 1-min
OID_LOAD_5 = "1.3.6.1.4.1.2021.10.1.3.2" # laLoad 5-min
OID_LOAD_15 = "1.3.6.1.4.1.2021.10.1.3.3" # laLoad 15-min
# ── IF-MIB OIDs ───────────────────────────────────────────────────────────────
OID_IF_DESCR = "1.3.6.1.2.1.2.2.1.2"
OID_IF_SPEED = "1.3.6.1.2.1.2.2.1.5"
OID_IF_IN_OCT = "1.3.6.1.2.1.2.2.1.10"
OID_IF_DESCR = "1.3.6.1.2.1.2.2.1.2"
OID_IF_SPEED = "1.3.6.1.2.1.2.2.1.5"
OID_IF_IN_OCT = "1.3.6.1.2.1.2.2.1.10"
OID_IF_OUT_OCT = "1.3.6.1.2.1.2.2.1.16"
OID_IF_IN_ERR = "1.3.6.1.2.1.2.2.1.14"
OID_IF_IN_ERR = "1.3.6.1.2.1.2.2.1.14"
OID_IF_OUT_ERR = "1.3.6.1.2.1.2.2.1.20"
# ifXTable: 64-bit octets and Mbps. The 32-bit counters wrap every ~34 s at
# 1 Gbit/s and ifSpeed stops at 4294 Mbps; an agent without ifXTable (SNMPv1)
# falls back to them.
OID_IF_HC_IN_OCT = "1.3.6.1.2.1.31.1.1.1.6"
OID_IF_HC_IN_OCT = "1.3.6.1.2.1.31.1.1.1.6"
OID_IF_HC_OUT_OCT = "1.3.6.1.2.1.31.1.1.1.10"
OID_IF_HIGH_SPEED = "1.3.6.1.2.1.31.1.1.1.15"
IF_SKIP_DEFAULT = re.compile(r'^(lo|sit\d|tun\d|docker|veth|br-|virbr)', re.IGNORECASE)
IF_SKIP_DEFAULT = re.compile(r"^(lo|sit\d|tun\d|docker|veth|br-|virbr)", re.IGNORECASE)
SnmpGetFn = Callable[[str], Awaitable[Optional[str]]]
SnmpWalkFn = Callable[[str], Awaitable[Dict[str, str]]]
SnmpGetFn = Callable[[str], Awaitable[str | None]]
SnmpWalkFn = Callable[[str], Awaitable[dict[str, str]]]
def ticks_to_seconds(raw: str | None) -> int | None:
@@ -48,35 +50,35 @@ def ticks_to_seconds(raw: str | None) -> int | None:
def build_if_metrics(
metrics: Dict[str, Any],
descr: Dict[str, str],
speed: Dict[str, str],
in_oct: Dict[str, str],
out_oct: Dict[str, str],
in_err: Dict[str, str],
out_err: Dict[str, str],
metrics: dict[str, Any],
descr: dict[str, str],
speed: dict[str, str],
in_oct: dict[str, str],
out_oct: dict[str, str],
in_err: dict[str, str],
out_err: dict[str, str],
*,
if_skip: re.Pattern[str] | None = None,
tx_err_is_drop: bool = False,
hc_in_oct: Optional[Dict[str, str]] = None,
hc_out_oct: Optional[Dict[str, str]] = None,
high_speed: Optional[Dict[str, str]] = None,
hc_in_oct: dict[str, str] | None = None,
hc_out_oct: dict[str, str] | None = None,
high_speed: dict[str, str] | None = None,
) -> None:
if if_skip is None:
if_skip = IF_SKIP_DEFAULT
interfaces: Dict[str, Any] = {}
interfaces: dict[str, Any] = {}
for idx, name in descr.items():
if if_skip.match(name):
continue
iface: Dict[str, Any] = {"name": name}
iface: dict[str, Any] = {"name": name}
try:
iface["rx_bytes"] = int((hc_in_oct or {}).get(idx) or in_oct.get(idx) or 0)
iface["tx_bytes"] = int((hc_out_oct or {}).get(idx) or out_oct.get(idx) or 0)
iface["rx_errors"] = int(in_err.get(idx, 0) or 0)
iface["rx_bytes"] = int((hc_in_oct or {}).get(idx) or in_oct.get(idx) or 0)
iface["tx_bytes"] = int((hc_out_oct or {}).get(idx) or out_oct.get(idx) or 0)
iface["rx_errors"] = int(in_err.get(idx, 0) or 0)
tx_val = int(out_err.get(idx, 0) or 0)
if tx_err_is_drop:
iface["tx_errors"] = 0
iface["tx_drops"] = tx_val
iface["tx_drops"] = tx_val
else:
iface["tx_errors"] = tx_val
high = (high_speed or {}).get(idx)
@@ -98,15 +100,33 @@ async def collect_ucd_metrics(
*,
tx_err_is_drop: bool = False,
if_skip: re.Pattern[str] | None = None,
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""Collect UCD-MIB system metrics + IF-MIB interface counters in parallel."""
(
uptime_raw, cpu_idle_raw,
mem_total_raw, mem_free_raw, mem_buf_raw, mem_cache_raw,
swap_total_raw, swap_avail_raw,
load1_raw, load5_raw, load15_raw,
), (
descr, speed, in_oct, out_oct, in_err, out_err, hc_in_oct, hc_out_oct, high_speed,
(
uptime_raw,
cpu_idle_raw,
mem_total_raw,
mem_free_raw,
mem_buf_raw,
mem_cache_raw,
swap_total_raw,
swap_avail_raw,
load1_raw,
load5_raw,
load15_raw,
),
(
descr,
speed,
in_oct,
out_oct,
in_err,
out_err,
hc_in_oct,
hc_out_oct,
high_speed,
),
) = await asyncio.gather(
asyncio.gather(
snmp_get(OID_SYS_UPTIME),
@@ -134,7 +154,7 @@ async def collect_ucd_metrics(
),
)
metrics: Dict[str, Any] = {}
metrics: dict[str, Any] = {}
secs = ticks_to_seconds(uptime_raw)
if secs is not None:
@@ -149,14 +169,14 @@ async def collect_ucd_metrics(
if mem_total_raw and mem_free_raw:
try:
total_kb = int(mem_total_raw)
free_kb = int(mem_free_raw)
buf_kb = int(mem_buf_raw) if mem_buf_raw else 0
free_kb = int(mem_free_raw)
buf_kb = int(mem_buf_raw) if mem_buf_raw else 0
cache_kb = int(mem_cache_raw) if mem_cache_raw else 0
used_kb = max(0, total_kb - free_kb - buf_kb - cache_kb)
used_kb = max(0, total_kb - free_kb - buf_kb - cache_kb)
metrics["memory_total_bytes"] = total_kb * 1024
metrics["memory_used_bytes"] = used_kb * 1024
metrics["memory_percent"] = round(used_kb / total_kb * 100, 1) if total_kb else 0.0
metrics["memory_free_bytes"] = free_kb * 1024
metrics["memory_used_bytes"] = used_kb * 1024
metrics["memory_percent"] = round(used_kb / total_kb * 100, 1) if total_kb else 0.0
metrics["memory_free_bytes"] = free_kb * 1024
except ValueError:
pass
@@ -164,10 +184,10 @@ async def collect_ucd_metrics(
try:
stotal = int(swap_total_raw)
savail = int(swap_avail_raw)
sused = stotal - savail
sused = stotal - savail
metrics["swap_total_bytes"] = stotal * 1024
metrics["swap_used_bytes"] = sused * 1024
metrics["swap_percent"] = round(sused / stotal * 100, 1) if stotal else 0.0
metrics["swap_used_bytes"] = sused * 1024
metrics["swap_percent"] = round(sused / stotal * 100, 1) if stotal else 0.0
except ValueError:
pass
@@ -178,7 +198,18 @@ async def collect_ucd_metrics(
except ValueError:
pass
build_if_metrics(metrics, descr, speed, in_oct, out_oct, in_err, out_err,
if_skip=if_skip, tx_err_is_drop=tx_err_is_drop,
hc_in_oct=hc_in_oct, hc_out_oct=hc_out_oct, high_speed=high_speed)
build_if_metrics(
metrics,
descr,
speed,
in_oct,
out_oct,
in_err,
out_err,
if_skip=if_skip,
tx_err_is_drop=tx_err_is_drop,
hc_in_oct=hc_in_oct,
hc_out_oct=hc_out_oct,
high_speed=high_speed,
)
return metrics
+20 -27
View File
@@ -10,14 +10,12 @@ Usage::
...
"""
from typing import Any, Dict, List, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.base import DeviceTypeDriver
from napalm_device_types.mac_acl import MacAclMixin
from napalm_device_types.updates import UpdateMixin
from napalm_device_types.services import ServiceControlMixin
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.interface_filter import InterfaceFilterMixin
from napalm_device_types.mac_acl import MacAclMixin
from napalm_device_types.models import (
Dot1XConfigDict,
FastTransitionConfigDict,
@@ -26,9 +24,20 @@ from napalm_device_types.models import (
SSIDBridgeDict,
WirelessConfigDict,
)
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.services import ServiceControlMixin
from napalm_device_types.updates import UpdateMixin
class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthMetricsMixin, InterfaceFilterMixin, DeviceTypeDriver):
class AccessPointDriver(
MacAclMixin,
UpdateMixin,
ServiceControlMixin,
PackageManagementMixin,
HealthMetricsMixin,
InterfaceFilterMixin,
DeviceTypeDriver,
):
"""
Abstract intermediate driver for wireless access points.
@@ -42,11 +51,6 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa
ROLE: str = "access_point"
TYPE_LABEL: str = "Access Point"
# AP-specific methods (get_wireless_clients, get_ssids, get_radio_status,
# get_interfaces, get_vlans, …) are intentionally NOT defined here.
# AccessPointDriver sits AFTER the mixin classes in the MRO of concrete
@@ -86,7 +90,7 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa
"""
...
def get_fast_transition_config(self) -> Dict[str, FastTransitionConfigDict]:
def get_fast_transition_config(self) -> dict[str, FastTransitionConfigDict]:
"""
Returns the 802.11r Fast BSS Transition (FT) configuration per SSID.
@@ -118,7 +122,7 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa
"""
...
def get_mesh_config(self) -> Dict[str, MeshConfigDict]:
def get_mesh_config(self) -> dict[str, MeshConfigDict]:
"""
Returns the 802.11s mesh configuration per mesh interface.
@@ -148,7 +152,7 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa
"""
...
def get_mesh_peers(self) -> List[MeshPeerDict]:
def get_mesh_peers(self) -> list[MeshPeerDict]:
"""
Returns a list of currently active 802.11s mesh peers.
@@ -178,7 +182,7 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa
"""
...
def get_ssid_bridge_config(self) -> Dict[str, SSIDBridgeDict]:
def get_ssid_bridge_config(self) -> dict[str, SSIDBridgeDict]:
"""
Returns the Layer-2 bridging configuration for each SSID, i.e. which
bridge interface and VLAN each SSID is mapped to.
@@ -219,9 +223,7 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa
"""
...
def get_dot1x_config(self) -> Dict[str, Dot1XConfigDict]:
def get_dot1x_config(self) -> dict[str, Dot1XConfigDict]:
"""
Returns the 802.1X / WPA-Enterprise (RADIUS) configuration per SSID.
@@ -268,12 +270,3 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa
}
"""
...
+14 -14
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""The command channel: a public way to run a command on a host, or open a stream.
A driver already reaches its host, over SSH, a REST API or WinRM. What it did
@@ -26,9 +25,8 @@ stays with the driver.
from __future__ import annotations
import socket
import time
from typing import Any, NamedTuple, Optional, Protocol, Tuple, TYPE_CHECKING, cast
from typing import TYPE_CHECKING, Any, NamedTuple, Protocol, cast
#: How much of a stream's stderr is kept. Enough to tell "permission denied"
#: from "no such command"; a stream that writes megabytes there keeps its tail.
@@ -56,7 +54,7 @@ class ByteStream(Protocol):
``close_write`` closes the writing side.
"""
def read(self, max_bytes: int, timeout: Optional[float] = None) -> bytes: ...
def read(self, max_bytes: int, timeout: float | None = None) -> bytes: ...
def write(self, data: bytes) -> None: ...
@@ -65,7 +63,7 @@ class ByteStream(Protocol):
def close(self) -> None: ...
@property
def exit_status(self) -> Optional[int]: ...
def exit_status(self) -> int | None: ...
@property
def stderr(self) -> str: ...
@@ -82,7 +80,7 @@ class CommandChannelMixin:
*,
privileged: bool = False,
timeout: float = 60,
stdin: Optional[bytes] = None,
stdin: bytes | None = None,
) -> CommandResult:
"""Run *command* with the host's shell; *stdin*, if given, is written first."""
...
@@ -108,11 +106,11 @@ class ParamikoExecStream:
while self._channel.recv_stderr_ready():
self._stderr = (self._stderr + self._channel.recv_stderr(_READ))[-self._limit :]
def read(self, max_bytes: int, timeout: Optional[float] = None) -> bytes:
def read(self, max_bytes: int, timeout: float | None = None) -> bytes:
self._channel.settimeout(timeout)
try:
return cast(bytes, self._channel.recv(max_bytes))
except socket.timeout:
except TimeoutError:
raise TimeoutError(f"no data within {timeout}s") from None
finally:
self._drain_stderr()
@@ -127,7 +125,7 @@ class ParamikoExecStream:
self._channel.close()
@property
def exit_status(self) -> Optional[int]:
def exit_status(self) -> int | None:
if not self._channel.exit_status_ready():
return None
return cast(int, self._channel.recv_exit_status())
@@ -138,7 +136,7 @@ class ParamikoExecStream:
return self._stderr.decode("utf-8", "replace")
def _collect(channel: Any, command: str, timeout: float) -> Tuple[bytes, bytes]:
def _collect(channel: Any, command: str, timeout: float) -> tuple[bytes, bytes]:
out, err = b"", b""
deadline = time.monotonic() + timeout
while True:
@@ -153,7 +151,7 @@ def _collect(channel: Any, command: str, timeout: float) -> Tuple[bytes, bytes]:
out += chunk
while chunk := channel.recv_stderr(_READ):
err += chunk
except socket.timeout:
except TimeoutError:
raise TimeoutError(f"{command!r} did not finish within {timeout}s") from None
return out, err
elif time.monotonic() > deadline:
@@ -163,7 +161,7 @@ def _collect(channel: Any, command: str, timeout: float) -> Tuple[bytes, bytes]:
def run_on_transport(
transport: Any, command: str, *, stdin: Optional[bytes] = None, timeout: float = 60
transport: Any, command: str, *, stdin: bytes | None = None, timeout: float = 60
) -> CommandResult:
"""Run *command* on an exec channel of the paramiko *transport*."""
channel = transport.open_session()
@@ -175,14 +173,16 @@ def run_on_transport(
channel.shutdown_write()
out, err = _collect(channel, command, timeout)
return CommandResult(
out.decode("utf-8", "replace"), err.decode("utf-8", "replace"), channel.recv_exit_status()
out.decode("utf-8", "replace"),
err.decode("utf-8", "replace"),
channel.recv_exit_status(),
)
finally:
channel.close()
def open_stream_on_transport(
transport: Any, command: str, *, stdin_prefix: Optional[bytes] = None
transport: Any, command: str, *, stdin_prefix: bytes | None = None
) -> ParamikoExecStream:
"""Start *command* on an exec channel and return its stream.
+4 -9
View File
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import difflib
from typing import ClassVar, TYPE_CHECKING
from typing import TYPE_CHECKING, ClassVar
from napalm.base.exceptions import (
CommandErrorException,
@@ -41,15 +40,11 @@ class ConfigLifecycleMixin:
if TYPE_CHECKING:
def _get_running_config(self) -> str:
...
def _get_running_config(self) -> str: ...
def commit_config(self, message: str = "", revert_in: int | None = None) -> None:
...
def commit_config(self, message: str = "", revert_in: int | None = None) -> None: ...
def load_merge_candidate(
self, filename: str | None = None, config: str | None = None
) -> None:
def load_merge_candidate(self, filename: str | None = None, config: str | None = None) -> None:
if filename is not None:
try:
with open(filename) as fh:
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Configuration sync between the two hosts of an HA pair.
A host that pushes its configuration to a peer (OPNsense's XMLRPC sync, say)
+19 -13
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Access to a host's container engine, and nothing more.
This package abstracts the connection, the driver builds it, and netOrk does
@@ -29,13 +28,14 @@ stays truthful.
from __future__ import annotations
import shlex
from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING, cast
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, cast
from napalm_device_types.channel import ByteStream, CommandResult
from napalm_device_types.models import ContainerEngineDict, FixResultDict
#: Engines this package knows how to reach, and the API each speaks.
ENGINES: Dict[str, str] = {"docker": "docker-engine"}
ENGINES: dict[str, str] = {"docker": "docker-engine"}
_PROBE_TIMEOUT = 15
_CLI_TIMEOUT = 120
@@ -68,7 +68,7 @@ class ContainerEngineConnection:
self,
args: Sequence[str],
*,
stdin: Optional[bytes] = None,
stdin: bytes | None = None,
timeout: float = _CLI_TIMEOUT,
privileged: bool = False,
) -> CommandResult:
@@ -77,9 +77,12 @@ class ContainerEngineConnection:
*privileged* runs it as root, the way the driver gains root for any
command: for a call the engine refused to the login user.
"""
return cast(CommandResult, self._driver.run_command(
self._command(args), privileged=privileged, timeout=timeout, stdin=stdin
))
return cast(
CommandResult,
self._driver.run_command(
self._command(args), privileged=privileged, timeout=timeout, stdin=stdin
),
)
def stream_cli(
self, args: Sequence[str], *, merge_stderr: bool = False, privileged: bool = False
@@ -90,9 +93,12 @@ class ContainerEngineConnection:
progress there (``compose up``); *privileged* as for :meth:`run_cli`.
"""
command = self._command(args)
return cast(ByteStream, self._driver.open_stream(
f"{command} 2>&1" if merge_stderr else command, privileged=privileged
))
return cast(
ByteStream,
self._driver.open_stream(
f"{command} 2>&1" if merge_stderr else command, privileged=privileged
),
)
class ContainerEngineMixin:
@@ -106,7 +112,7 @@ class ContainerEngineMixin:
*,
privileged: bool = False,
timeout: float = 60,
stdin: Optional[bytes] = None,
stdin: bytes | None = None,
) -> CommandResult: ...
def open_stream(self, command: str, *, privileged: bool = False) -> ByteStream: ...
@@ -121,7 +127,7 @@ class ContainerEngineMixin:
"""The host command that is *engine*'s CLI. Override where it is not on PATH."""
return engine
def container_engines(self) -> List[ContainerEngineDict]:
def container_engines(self) -> list[ContainerEngineDict]:
"""
Returns the container engines the host offers:
@@ -132,7 +138,7 @@ class ContainerEngineMixin:
user may use it is decided by the first API call, which says
"permission denied" in the stream's stderr.
"""
found: List[ContainerEngineDict] = []
found: list[ContainerEngineDict] = []
for engine, api in ENGINES.items():
probe = shlex.join(["command", "-v", self._container_engine_binary(engine)])
result = self.run_command(probe, timeout=_PROBE_TIMEOUT)
+29 -42
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""DHCP desired-state management, shared by firewalls and gateways.
Covers two independent desired-state sets:
@@ -24,16 +23,17 @@ takes DHCP down for an entire VLAN.
from __future__ import annotations
import re
from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING
from collections.abc import Iterator
from typing import TYPE_CHECKING, Any
from napalm_device_types._matching import check_desired, index_live
from napalm_device_types.models import (
DhcpHaDict,
DhcpReservationDiffDict,
DhcpReservationDict,
DhcpReservationDiffDict,
DhcpReservationUpdateDict,
DhcpSubnetDiffDict,
DhcpSubnetDict,
DhcpSubnetDiffDict,
DhcpSubnetUpdateDict,
)
@@ -58,7 +58,7 @@ _DHCP_SUBNET_COMPARE_FIELDS = (
_HEX_ONLY = re.compile(r"[^0-9a-f]")
def normalize_mac(mac: Optional[str]) -> str:
def normalize_mac(mac: str | None) -> str:
"""Reduces a MAC address to lowercase colon-separated form.
Devices report MACs in whatever form their config store happens to use --
@@ -80,7 +80,7 @@ def normalize_mac(mac: Optional[str]) -> str:
return ":".join(hex_digits[i : i + 2] for i in range(0, 12, 2))
def normalize_cidr(cidr: Optional[str]) -> str:
def normalize_cidr(cidr: str | None) -> str:
"""Reduces a subnet CIDR to a canonical string for matching.
Only whitespace and case are normalised -- deliberately not the network
@@ -91,9 +91,7 @@ def normalize_cidr(cidr: Optional[str]) -> str:
return (cidr or "").strip().lower()
def _subnet_field_differs(
field: str, live: DhcpSubnetDict, desired: DhcpSubnetDict
) -> bool:
def _subnet_field_differs(field: str, live: DhcpSubnetDict, desired: DhcpSubnetDict) -> bool:
"""Compares one subnet field, with `option_data` handled specially.
For `option_data` only the options `desired` actually names are compared;
@@ -105,9 +103,7 @@ def _subnet_field_differs(
desired_options = desired.get("option_data") or {}
live_options = live.get("option_data") or {}
return any(
live_options.get(option) != value for option, value in desired_options.items()
)
return any(live_options.get(option) != value for option, value in desired_options.items())
class DhcpServerMixin:
@@ -125,7 +121,7 @@ class DhcpServerMixin:
if TYPE_CHECKING:
def get_dhcp_reservations(self) -> List[DhcpReservationDict]:
def get_dhcp_reservations(self) -> list[DhcpReservationDict]:
"""
Returns all static DHCP reservations currently configured on the
device, across all subnets.
@@ -139,8 +135,8 @@ class DhcpServerMixin:
...
def apply_dhcp_reservation(
self, reservation: DhcpReservationDict, *, uuid: Optional[str] = None
) -> Dict[str, Any]:
self, reservation: DhcpReservationDict, *, uuid: str | None = None
) -> dict[str, Any]:
"""
Creates or updates a single static DHCP reservation on the device.
@@ -157,7 +153,7 @@ class DhcpServerMixin:
"""
...
def get_dhcp_subnets(self) -> List[DhcpSubnetDict]:
def get_dhcp_subnets(self) -> list[DhcpSubnetDict]:
"""
Returns every DHCPv4 subnet the device serves, with its pools and
per-subnet options.
@@ -168,8 +164,8 @@ class DhcpServerMixin:
...
def apply_dhcp_subnet(
self, subnet: DhcpSubnetDict, *, uuid: Optional[str] = None
) -> Dict[str, Any]:
self, subnet: DhcpSubnetDict, *, uuid: str | None = None
) -> dict[str, Any]:
"""
Creates or updates a single DHCPv4 subnet on the device.
@@ -189,7 +185,7 @@ class DhcpServerMixin:
"""
...
def commit_dhcp_subnets(self) -> Dict[str, Any]:
def commit_dhcp_subnets(self) -> dict[str, Any]:
"""
Applies pending subnet changes (e.g. Kea's ``service/reconfigure``).
@@ -204,7 +200,7 @@ class DhcpServerMixin:
"""
...
def commit_dhcp_reservations(self) -> Dict[str, Any]:
def commit_dhcp_reservations(self) -> dict[str, Any]:
"""
Applies pending reservation changes (e.g. Kea's ``service/reconfigure``,
or a dnsmasq reload).
@@ -238,9 +234,7 @@ class DhcpServerMixin:
# Generic, vendor-neutral algorithms.
# ------------------------------------------------------------------
def diff_dhcp_reservations(
self, desired: List[DhcpReservationDict]
) -> DhcpReservationDiffDict:
def diff_dhcp_reservations(self, desired: list[DhcpReservationDict]) -> DhcpReservationDiffDict:
"""
Compares `desired` against the device's current reservations and
returns what would need to change to reach that state.
@@ -263,14 +257,15 @@ class DhcpServerMixin:
"changed_fields"}, ...]}``.
:raises ValueError: When a desired reservation cannot name a single live one.
"""
def key(reservation: DhcpReservationDict) -> str:
return normalize_mac(reservation.get("mac"))
live_by_mac, shared = index_live(self.get_dhcp_reservations(), key)
check_desired(desired, key, shared, "MAC")
add: List[DhcpReservationDict] = []
update: List[DhcpReservationUpdateDict] = []
add: list[DhcpReservationDict] = []
update: list[DhcpReservationUpdateDict] = []
for desired_reservation in desired:
live = live_by_mac.get(key(desired_reservation))
@@ -294,9 +289,7 @@ class DhcpServerMixin:
return {"add": add, "update": update}
def apply_dhcp_reservationset(
self, desired: List[DhcpReservationDict]
) -> Iterator[str]:
def apply_dhcp_reservationset(self, desired: list[DhcpReservationDict]) -> Iterator[str]:
"""
Computes the diff against `desired` and applies it, yielding one
human-readable progress line per change, then commits.
@@ -319,8 +312,7 @@ class DhcpServerMixin:
self.apply_dhcp_reservation(entry["reservation"], uuid=entry["uuid"])
fields = ", ".join(entry["changed_fields"])
yield (
f"[update] {entry['reservation']['mac']} -> "
f"{entry['reservation']['ip']} ({fields})"
f"[update] {entry['reservation']['mac']} -> {entry['reservation']['ip']} ({fields})"
)
if not diff["add"] and not diff["update"]:
@@ -328,12 +320,9 @@ class DhcpServerMixin:
return
self.commit_dhcp_reservations()
yield (
f"[commit] applied {len(diff['add'])} add(s), "
f"{len(diff['update'])} update(s)"
)
yield (f"[commit] applied {len(diff['add'])} add(s), {len(diff['update'])} update(s)")
def diff_dhcp_subnets(self, desired: List[DhcpSubnetDict]) -> DhcpSubnetDiffDict:
def diff_dhcp_subnets(self, desired: list[DhcpSubnetDict]) -> DhcpSubnetDiffDict:
"""
Compares `desired` against the device's current subnets and returns
what would need to change to reach that state.
@@ -362,14 +351,15 @@ class DhcpServerMixin:
"changed_fields"}, ...]}``.
:raises ValueError: When a desired subnet cannot name a single live one.
"""
def key(subnet: DhcpSubnetDict) -> str:
return normalize_cidr(subnet.get("subnet"))
live_by_cidr, shared = index_live(self.get_dhcp_subnets(), key)
check_desired(desired, key, shared, "subnet")
add: List[DhcpSubnetDict] = []
update: List[DhcpSubnetUpdateDict] = []
add: list[DhcpSubnetDict] = []
update: list[DhcpSubnetUpdateDict] = []
for desired_subnet in desired:
live = live_by_cidr.get(key(desired_subnet))
@@ -393,7 +383,7 @@ class DhcpServerMixin:
return {"add": add, "update": update}
def apply_dhcp_subnetset(self, desired: List[DhcpSubnetDict]) -> Iterator[str]:
def apply_dhcp_subnetset(self, desired: list[DhcpSubnetDict]) -> Iterator[str]:
"""
Computes the diff against `desired` and applies it, yielding one
human-readable progress line per change, then commits.
@@ -421,7 +411,4 @@ class DhcpServerMixin:
return
self.commit_dhcp_subnets()
yield (
f"[commit] applied {len(diff['add'])} add(s), "
f"{len(diff['update'])} update(s)"
)
yield (f"[commit] applied {len(diff['add'])} add(s), {len(diff['update'])} update(s)")
+2 -3
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Domains a device's DNS resolver forwards to another server.
An application that makes the device's resolver ask another server for a zone
@@ -9,7 +8,7 @@ Declared only; a driver whose device resolves DNS implements it.
from __future__ import annotations
from typing import List, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.models import DnsForwardDict
@@ -17,7 +16,7 @@ from napalm_device_types.models import DnsForwardDict
class DnsForwardMixin:
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
def get_dns_forwards(self) -> List[DnsForwardDict]:
def get_dns_forwards(self) -> list[DnsForwardDict]:
"""Every domain the resolver forwards, enabled or not."""
...
+16 -22
View File
@@ -10,21 +10,28 @@ Usage::
...
"""
from typing import Any, ClassVar, Dict, Iterator, List, Optional, TYPE_CHECKING
from typing import TYPE_CHECKING, Any, ClassVar
from napalm_device_types.base import DeviceTypeDriver
from napalm_device_types.nat_vpn import NatVpnMixin
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.dhcp import DhcpServerMixin
from napalm_device_types.firewall_rules import FirewallRuleMixin
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.models import (
SecurityZoneDict,
SessionDict,
)
from napalm_device_types.nat_vpn import NatVpnMixin
from napalm_device_types.packages import PackageManagementMixin
class FirewallDriver(NatVpnMixin, PackageManagementMixin, HealthMetricsMixin, FirewallRuleMixin, DhcpServerMixin, DeviceTypeDriver):
class FirewallDriver(
NatVpnMixin,
PackageManagementMixin,
HealthMetricsMixin,
FirewallRuleMixin,
DhcpServerMixin,
DeviceTypeDriver,
):
"""
Abstract intermediate driver for firewall/security devices.
@@ -42,11 +49,9 @@ class FirewallDriver(NatVpnMixin, PackageManagementMixin, HealthMetricsMixin, Fi
# OPNsense reports drops in the out-error counter.
_SNMP_TX_ERR_IS_DROP: ClassVar[bool] = True
if TYPE_CHECKING:
def get_security_zones(self) -> Dict[str, SecurityZoneDict]:
def get_security_zones(self) -> dict[str, SecurityZoneDict]:
"""
Returns the security zone configuration.
@@ -73,7 +78,7 @@ class FirewallDriver(NatVpnMixin, PackageManagementMixin, HealthMetricsMixin, Fi
"""
...
def get_sessions(self) -> List[SessionDict]:
def get_sessions(self) -> list[SessionDict]:
"""
Returns a list of active connection sessions (stateful flows).
@@ -103,8 +108,7 @@ class FirewallDriver(NatVpnMixin, PackageManagementMixin, HealthMetricsMixin, Fi
"""
...
def send_wake_on_lan(self, mac_address: str, interface: str = "") -> Dict[str, Any]:
def send_wake_on_lan(self, mac_address: str, interface: str = "") -> dict[str, Any]:
"""
Sends a Wake-on-LAN "magic packet" to wake a host on the network.
@@ -142,19 +146,9 @@ class FirewallDriver(NatVpnMixin, PackageManagementMixin, HealthMetricsMixin, Fi
"""
...
# ------------------------------------------------------------------
# Firewall rule diff/apply. get_firewall_rules/apply_firewall_rule/
# commit_firewall_rules are abstract (device communication); everything
# else here is a concrete, vendor-neutral algorithm -- see README.md
# "Design principle: generic vs. device-specific logic".
# ------------------------------------------------------------------
+14 -10
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Firewall rule reconciliation: generic here, device communication in the driver.
The split follows the rule in this package's README: matching, diffing and the
@@ -14,7 +13,8 @@ working implementation inherited from elsewhere.
from __future__ import annotations
from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING
from collections.abc import Iterator
from typing import TYPE_CHECKING, Any
from napalm_device_types._matching import check_desired, index_live
from napalm_device_types.models import (
@@ -43,7 +43,7 @@ class FirewallRuleMixin:
if TYPE_CHECKING:
def get_firewall_rules(self) -> List[FirewallRuleDict]:
def get_firewall_rules(self) -> list[FirewallRuleDict]:
"""
Returns all firewall filter rules currently configured on the device.
@@ -55,9 +55,10 @@ class FirewallRuleMixin:
firewall rules.
"""
...
def apply_firewall_rule(
self, rule: FirewallRuleDict, *, uuid: Optional[str] = None
) -> Dict[str, Any]:
self, rule: FirewallRuleDict, *, uuid: str | None = None
) -> dict[str, Any]:
"""
Creates or updates a single firewall filter rule on the device.
@@ -73,7 +74,8 @@ class FirewallRuleMixin:
:returns: A dict with at least ``{"success": bool}``.
"""
...
def commit_firewall_rules(self) -> Dict[str, Any]:
def commit_firewall_rules(self) -> dict[str, Any]:
"""
Applies pending firewall filter rule changes (e.g. reloads pf/pfctl,
or whatever the device's equivalent of "Apply Changes" is).
@@ -88,7 +90,7 @@ class FirewallRuleMixin:
"""
...
def diff_firewall_rules(self, desired: List[FirewallRuleDict]) -> FirewallRuleDiffDict:
def diff_firewall_rules(self, desired: list[FirewallRuleDict]) -> FirewallRuleDiffDict:
"""
Compares `desired` against the device's current rules and returns
what would need to change to reach that state.
@@ -113,14 +115,15 @@ class FirewallRuleMixin:
"changed_fields"}, ...]}``.
:raises ValueError: When a desired rule cannot name a single live one.
"""
def key(rule: FirewallRuleDict) -> str:
return (rule.get("description") or "").strip()
live_by_description, shared = index_live(self.get_firewall_rules(), key)
check_desired(desired, key, shared, "description")
add: List[FirewallRuleDict] = []
update: List[FirewallRuleUpdateDict] = []
add: list[FirewallRuleDict] = []
update: list[FirewallRuleUpdateDict] = []
for desired_rule in desired:
live_rule = live_by_description.get(key(desired_rule))
@@ -143,7 +146,8 @@ class FirewallRuleMixin:
)
return {"add": add, "update": update}
def apply_firewall_ruleset(self, desired: List[FirewallRuleDict]) -> Iterator[str]:
def apply_firewall_ruleset(self, desired: list[FirewallRuleDict]) -> Iterator[str]:
"""
Computes the diff against `desired` and applies it, yielding one
human-readable progress line per change, then commits.
+2 -3
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""The firmware a vendor offers for a device.
A fact for the caller (docs/STRUCTURE.md): a driver may know the newer firmware
@@ -10,7 +9,7 @@ the offer is newer. Declared only; a driver that can tell implements it.
from __future__ import annotations
from typing import Optional, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.models import FirmwareOfferDict
@@ -18,7 +17,7 @@ from napalm_device_types.models import FirmwareOfferDict
class FirmwareOfferMixin:
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
def get_firmware_offer(self) -> Optional[FirmwareOfferDict]:
def get_firmware_offer(self) -> FirmwareOfferDict | None:
"""The firmware running and the newest the vendor offers.
None when the driver cannot tell for this device (an unknown model,
+10 -9
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Fix routines: changes an application wants made on a device, as templates.
A template fixes the order of the steps; the driver knows how each step is done on
@@ -22,7 +21,8 @@ They replace ``run_device_action`` (docs/STRUCTURE.md):
from __future__ import annotations
from typing import Any, Callable, List, Optional, Sequence, Tuple, TYPE_CHECKING
from collections.abc import Callable, Sequence
from typing import TYPE_CHECKING, Any
from napalm_device_types.models import (
FixResultDict,
@@ -33,7 +33,7 @@ from napalm_device_types.models import (
)
#: One step of a fix: its name and the call that does it.
FixStep = Tuple[str, Callable[[], FixStepOutcomeDict]]
FixStep = tuple[str, Callable[[], FixStepOutcomeDict]]
def run_fix_steps(steps: Sequence[FixStep]) -> FixResultDict:
@@ -41,7 +41,7 @@ def run_fix_steps(steps: Sequence[FixStep]) -> FixResultDict:
A step that raises has failed, with the exception's message as its output.
"""
done: List[FixStepDict] = []
done: list[FixStepDict] = []
for name, step in steps:
try:
outcome = step()
@@ -79,7 +79,7 @@ class SnmpAgentMixin:
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
def get_management_peer(self) -> Optional[str]:
def get_management_peer(self) -> str | None:
"""The address the device sees this management connection come from
(for SSH, the client side of ``SSH_CONNECTION``), or None when it cannot
tell. A fact for the caller choosing ``allowed_sources``; optional --
@@ -90,7 +90,9 @@ class SnmpAgentMixin:
def _install_snmp_agent(self) -> FixStepOutcomeDict: ...
def _write_snmp_agent_config(self, settings: SnmpAgentSettingsDict) -> FixStepOutcomeDict: ...
def _write_snmp_agent_config(
self, settings: SnmpAgentSettingsDict
) -> FixStepOutcomeDict: ...
def _allow_snmp_sources(self, settings: SnmpAgentSettingsDict) -> FixStepOutcomeDict:
"""Let ``settings["allowed_sources"]`` reach the agent; ``ok`` with nothing
@@ -126,7 +128,7 @@ class EnsurePackageMixin:
if TYPE_CHECKING: # pragma: no cover
def get_packages(self) -> List[PackageDict]: ...
def get_packages(self) -> list[PackageDict]: ...
def install_package(self, name: str, version: str = "") -> None: ...
@@ -140,7 +142,7 @@ class PackageProxyMixin:
"""Have the package manager fetch through *url*; an empty *url* removes it."""
...
def get_package_proxy(self) -> Optional[str]:
def get_package_proxy(self) -> str | None:
"""The proxy the package manager fetches through now; None for none.
A fact: whether it is the right one is the caller's to judge."""
@@ -154,4 +156,3 @@ __all__ = [
"SnmpAgentMixin",
"run_fix_steps",
]
+3 -3
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""SNMP health collection, shared by every device type that speaks UCD-MIB.
**Deprecated** (docs/STRUCTURE.md): drivers do not collect over SNMP. The
@@ -21,7 +20,8 @@ from __future__ import annotations
import re
import warnings
from typing import Any, Awaitable, Callable, ClassVar, Dict, cast
from collections.abc import Awaitable, Callable
from typing import Any, ClassVar, cast
from napalm_device_types._ucd_metrics import IF_SKIP_DEFAULT, collect_ucd_metrics
from napalm_device_types.models import HealthMetricsDict
@@ -43,7 +43,7 @@ class HealthMetricsMixin:
async def get_health_metrics(
cls,
snmp_get: Callable[..., Awaitable[Any]],
snmp_walk: Callable[..., Awaitable[Dict[str, Any]]],
snmp_walk: Callable[..., Awaitable[dict[str, Any]]],
) -> HealthMetricsDict:
"""Collect SNMP health metrics (CPU, memory, uptime, load, interfaces).
+42 -42
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""A Linux host's own firewall: its netfilter rules, read once, in one shape.
Whether a service is reachable from outside its host is decided twice: by the
@@ -37,8 +36,9 @@ import ipaddress
import json
import re
import shlex
from collections.abc import Iterator
from shlex import quote
from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from napalm_device_types.models import (
HostFirewallDict,
@@ -71,7 +71,7 @@ _IPTABLES_FAMILY = {"iptables-save": "ip", "ip6tables-save": "ip6"}
#: The built-in chains of iptables' tables: ``(type, hook, priority)``, as
#: iptables-nft registers them (and as the legacy tables run in the kernel).
_BUILT_IN: Dict[str, Dict[str, Tuple[str, str, int]]] = {
_BUILT_IN: dict[str, dict[str, tuple[str, str, int]]] = {
"raw": {"PREROUTING": ("filter", "prerouting", -300), "OUTPUT": ("filter", "output", -300)},
"mangle": {
"PREROUTING": ("filter", "prerouting", -150),
@@ -132,7 +132,7 @@ class HostFirewallUnavailable(NotImplementedError):
"""Nothing on the host could be read: no ``nft``, no ``iptables-save``, or no root."""
def _frame(output: str) -> List[str]:
def _frame(output: str) -> list[str]:
lines = [line.rstrip() for line in strip_terminal_codes(output).splitlines()]
stripped = [line.strip() for line in lines]
try:
@@ -143,11 +143,11 @@ def _frame(output: str) -> List[str]:
return lines[start + 1 : end]
def _sections(lines: List[str]) -> Dict[str, Tuple[List[str], Optional[int]]]:
def _sections(lines: list[str]) -> dict[str, tuple[list[str], int | None]]:
"""Each tool's output and exit status."""
sections: Dict[str, Tuple[List[str], Optional[int]]] = {}
name: Optional[str] = None
body: List[str] = []
sections: dict[str, tuple[list[str], int | None]] = {}
name: str | None = None
body: list[str] = []
for line in lines:
text = line.strip()
if text in ("[nft]", "[iptables-save]", "[ip6tables-save]"):
@@ -197,7 +197,7 @@ _KNOWN_MODULES = frozenset(
)
def _match(field: str, negated: bool, values: List[str]) -> NetfilterMatchDict:
def _match(field: str, negated: bool, values: list[str]) -> NetfilterMatchDict:
return {"field": field, "op": "!=" if negated else "==", "values": values}
@@ -210,25 +210,25 @@ def _iface(name: str) -> str:
return name[:-1] + "*" if name.endswith("+") else name
def _ports(spec: str) -> List[str]:
def _ports(spec: str) -> list[str]:
return [p.replace(":", "-") for p in spec.split(",") if p]
def _states(spec: str) -> List[str]:
def _states(spec: str) -> list[str]:
return [s.lower() for s in spec.split(",") if s]
class _RuleTokens:
"""The tokens of one ``-A`` line, read front to back."""
def __init__(self, tokens: List[str]):
def __init__(self, tokens: list[str]):
self.tokens = tokens
self.i = 0
def more(self) -> bool:
return self.i < len(self.tokens)
def peek(self, ahead: int = 0) -> Optional[str]:
def peek(self, ahead: int = 0) -> str | None:
j = self.i + ahead
return self.tokens[j] if j < len(self.tokens) else None
@@ -238,7 +238,7 @@ class _RuleTokens:
return token
def _module_options(module: str, rt: _RuleTokens, matches: List[NetfilterMatchDict]) -> None:
def _module_options(module: str, rt: _RuleTokens, matches: list[NetfilterMatchDict]) -> None:
"""Read *module*'s options from *rt* into *matches*; stop at the next top-level option."""
text = ["-m", module]
unknown = module not in _KNOWN_MODULES
@@ -291,7 +291,7 @@ def _module_match(module: str, option: str, value: str, negated: bool) -> Any:
return None
def _target(name: str, chains: Set[str]) -> Tuple[str, Optional[str]]:
def _target(name: str, chains: set[str]) -> tuple[str, str | None]:
if name in _TERMINAL:
return _TERMINAL[name], None
if name in chains:
@@ -301,12 +301,12 @@ def _target(name: str, chains: Set[str]) -> Tuple[str, Optional[str]]:
return "other", name
def _iptables_rule(line: str, chains: Set[str]) -> Tuple[str, NetfilterRuleDict]:
def _iptables_rule(line: str, chains: set[str]) -> tuple[str, NetfilterRuleDict]:
"""``-A CHAIN ...`` as ``(chain, rule)``."""
tokens = shlex.split(line)
chain = tokens[1]
rt = _RuleTokens(tokens[2:])
matches: List[NetfilterMatchDict] = []
matches: list[NetfilterMatchDict] = []
verdict, target = "continue", None
negated = False
simple = {
@@ -351,7 +351,7 @@ def _iptables_rule(line: str, chains: Set[str]) -> Tuple[str, NetfilterRuleDict]
return chain, {"matches": matches, "verdict": verdict, "target": target, "text": line}
def _iptables_variant(lines: List[str]) -> Optional[str]:
def _iptables_variant(lines: list[str]) -> str | None:
"""``iptables-nft`` or ``iptables-legacy`` from the header; None if nothing was saved."""
for line in lines:
if line.startswith("# Generated by"):
@@ -359,10 +359,10 @@ def _iptables_variant(lines: List[str]) -> Optional[str]:
return None
def _parse_iptables_save(lines: List[str], family: str) -> List[NetfilterChainDict]:
chains: List[NetfilterChainDict] = []
table: Optional[str] = None
by_name: Dict[str, NetfilterChainDict] = {}
def _parse_iptables_save(lines: list[str], family: str) -> list[NetfilterChainDict]:
chains: list[NetfilterChainDict] = []
table: str | None = None
by_name: dict[str, NetfilterChainDict] = {}
for raw in lines:
line = raw.strip()
if line.startswith("*"):
@@ -421,7 +421,7 @@ _OTHER = frozenset({"queue", "dnat", "snat", "masquerade", "redirect", "tproxy",
_TRANSPORTS = frozenset({"tcp", "udp", "sctp", "udplite", "dccp"})
def _nft_scalar(value: Any) -> Optional[List[str]]:
def _nft_scalar(value: Any) -> list[str] | None:
"""A right-hand side as values; None for one this does not model."""
if isinstance(value, bool):
return None
@@ -442,9 +442,9 @@ def _nft_scalar(value: Any) -> Optional[List[str]]:
return None
def _nft_values(items: Any) -> Optional[List[str]]:
def _nft_values(items: Any) -> list[str] | None:
items = items if isinstance(items, list) else [items]
out: List[str] = []
out: list[str] = []
for item in items:
values = _nft_scalar(item)
if values is None:
@@ -476,7 +476,7 @@ def _right_text(right: Any) -> str:
return values[0] if len(values) == 1 else "{" + ", ".join(values) + "}"
def _nft_field(left: Any) -> Tuple[Optional[str], Optional[str]]:
def _nft_field(left: Any) -> tuple[str | None, str | None]:
"""``(field, implied l4proto)`` of a left-hand side; ``(None, None)`` if not modelled."""
if not isinstance(left, dict):
return None, None
@@ -509,8 +509,8 @@ def _nft_field(left: Any) -> Tuple[Optional[str], Optional[str]]:
def _nft_match(
match: Dict[str, Any], sets: Dict[Tuple[str, str, str], List[str]], where: Tuple[str, str]
) -> List[NetfilterMatchDict]:
match: dict[str, Any], sets: dict[tuple[str, str, str], list[str]], where: tuple[str, str]
) -> list[NetfilterMatchDict]:
left, op, right = match.get("left"), match.get("op"), match.get("right")
field, implied = _nft_field(left)
raw_text = (
@@ -524,7 +524,7 @@ def _nft_match(
values = _nft_scalar(right)
if values is None:
return [_unknown(raw_text)]
out: List[NetfilterMatchDict] = []
out: list[NetfilterMatchDict] = []
if implied:
out.append(_match("l4proto", False, [implied]))
if field in ("iifname", "oifname", "l4proto", "ct_state", "nfproto"):
@@ -536,10 +536,10 @@ def _nft_match(
def _nft_rule(
rule: Dict[str, Any], sets: Dict[Tuple[str, str, str], List[str]]
rule: dict[str, Any], sets: dict[tuple[str, str, str], list[str]]
) -> NetfilterRuleDict:
where = (rule.get("family", ""), rule.get("table", ""))
matches: List[NetfilterMatchDict] = []
matches: list[NetfilterMatchDict] = []
verdict, target = "continue", None
for statement in rule.get("expr") or []:
if not isinstance(statement, dict) or not statement:
@@ -576,19 +576,19 @@ def _nft_rule(
return entry
def _parse_nft(text: str, skip: Set[Tuple[str, str]]) -> List[NetfilterChainDict]:
def _parse_nft(text: str, skip: set[tuple[str, str]]) -> list[NetfilterChainDict]:
start = text.find("{")
if start < 0:
raise ValueError("nft printed no JSON")
objects = json.loads(text[start:]).get("nftables") or []
sets: Dict[Tuple[str, str, str], List[str]] = {}
sets: dict[tuple[str, str, str], list[str]] = {}
for obj in objects:
if "set" in obj and "elem" in obj["set"]:
values = _nft_values(obj["set"]["elem"])
if values is not None:
s = obj["set"]
sets[(s.get("family", ""), s.get("table", ""), s.get("name", ""))] = values
chains: Dict[Tuple[str, str, str], NetfilterChainDict] = {}
chains: dict[tuple[str, str, str], NetfilterChainDict] = {}
for obj in objects:
if "chain" not in obj:
continue
@@ -618,7 +618,7 @@ def _parse_nft(text: str, skip: Set[Tuple[str, str]]) -> List[NetfilterChainDict
# ── text ─────────────────────────────────────────────────────────────────────
def _family_of(values: List[str]) -> str:
def _family_of(values: list[str]) -> str:
try:
return (
"ip6"
@@ -629,7 +629,7 @@ def _family_of(values: List[str]) -> str:
return "ip"
def _values_text(values: List[str]) -> str:
def _values_text(values: list[str]) -> str:
return values[0] if len(values) == 1 else "{" + ", ".join(values) + "}"
@@ -641,7 +641,7 @@ def rule_text(rule: NetfilterRuleDict) -> str:
if m["field"] == "l4proto" and m["op"] == "=="
for v in m["values"]
]
parts: List[str] = []
parts: list[str] = []
for m in rule["matches"]:
neg = "!= " if m["op"] == "!=" else ""
field, values = m["field"], m["values"]
@@ -673,8 +673,8 @@ def rule_text(rule: NetfilterRuleDict) -> str:
def _iptables_sections(
sections: Dict[str, Tuple[List[str], Optional[int]]],
) -> Iterator[Tuple[str, str, List[str]]]:
sections: dict[str, tuple[list[str], int | None]],
) -> Iterator[tuple[str, str, list[str]]]:
"""``(variant, family, lines)`` for each iptables tool that saved something."""
for tool, family in _IPTABLES_FAMILY.items():
lines, rc = sections.get(tool, ([], None))
@@ -692,9 +692,9 @@ def parse_host_firewall(output: str) -> HostFirewallDict:
:raises HostFirewallUnavailable: if neither tool could be read.
"""
sections = _sections(_frame(output))
sources: List[str] = []
chains: List[NetfilterChainDict] = []
covered: Set[Tuple[str, str]] = set()
sources: list[str] = []
chains: list[NetfilterChainDict] = []
covered: set[tuple[str, str]] = set()
for variant, family, lines in _iptables_sections(sections):
saved = _parse_iptables_save(lines, family)
chains += saved
+21 -22
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Host status: does the host need a reboot, and does it patch itself?
A patch run that installed a new kernel or libc has not closed anything until
@@ -59,7 +58,7 @@ too full is not decided here.
from __future__ import annotations
import re
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.models import (
FilesystemUsageDict,
@@ -110,7 +109,7 @@ HOST_STATUS_COMMAND = (
"apt-config dump 2>/dev/null | grep '^APT::Periodic::Unattended-Upgrade '; "
# Named through a variable: spelt out, the program name would match BUSY_CHECK
# in this very command line.
"uu=unattended-upgrade; { command -v \"$uu\" || [ -x \"/usr/bin/$uu\" ]; } >/dev/null 2>&1 "
'uu=unattended-upgrade; { command -v "$uu" || [ -x "/usr/bin/$uu" ]; } >/dev/null 2>&1 '
"&& echo '[unattended-upgrade]'; "
f"echo '[upgrade-stamp]'; stat -c %Y {_UPGRADE_STAMP} 2>/dev/null; fi; "
"if command -v dpkg-query >/dev/null 2>&1; then "
@@ -147,15 +146,15 @@ _HALFWAY = {
}
def _sections(output: str) -> Dict[str, List[str]]:
def _sections(output: str) -> dict[str, list[str]]:
lines = [line.strip() for line in strip_terminal_codes(output).splitlines()]
try:
start = lines.index(_BEGIN)
end = lines.index(_END, start)
except ValueError:
raise ValueError("no intact host status report in the output") from None
sections: Dict[str, List[str]] = {}
current: List[str] = []
sections: dict[str, list[str]] = {}
current: list[str] = []
for line in lines[start + 1 : end]:
if line.startswith("[") and line.endswith("]"):
current = sections.setdefault(line[1:-1], [])
@@ -164,12 +163,12 @@ def _sections(output: str) -> Dict[str, List[str]]:
return sections
def _version_key(version: str) -> Tuple[object, ...]:
def _version_key(version: str) -> tuple[object, ...]:
"""Natural order: 6.8.0-142 after 6.8.0-87, 7.0.14 after 7.0.2."""
return tuple(int(part) if part.isdigit() else part for part in re.split(r"(\d+)", version))
def kernel_reboot_pending(running: str, installed: List[str]) -> Optional[str]:
def kernel_reboot_pending(running: str, installed: list[str]) -> str | None:
"""The newest installed kernel of the running flavour, if it is newer than the
running one; otherwise None.
@@ -187,7 +186,7 @@ def kernel_reboot_pending(running: str, installed: List[str]) -> Optional[str]:
return None
def _kernel_flavour(release: str) -> Tuple[str, str]:
def _kernel_flavour(release: str) -> tuple[str, str]:
"""``6.8.0-87-generic`` -> ``("6.8.0-87", "generic")``,
``5.14.0-427.13.1.el9_4.x86_64`` -> ``("5.14.0-427.13.1.el9_4", "x86_64")``."""
base, sep, tail = release.rpartition("-")
@@ -199,7 +198,7 @@ def _kernel_flavour(release: str) -> Tuple[str, str]:
return base, tail
def _freebsd_reboot(sections: Dict[str, List[str]]) -> Optional[Tuple[bool, Optional[str]]]:
def _freebsd_reboot(sections: dict[str, list[str]]) -> tuple[bool, str | None] | None:
"""FreeBSD's answer from freebsd-version, or None on any other system."""
installed = (sections.get("freebsd-kernel") or [""])[0]
running = (sections.get("freebsd-running") or [""])[0]
@@ -210,7 +209,7 @@ def _freebsd_reboot(sections: Dict[str, List[str]]) -> Optional[Tuple[bool, Opti
return False, None
def _reboot(sections: Dict[str, List[str]]) -> Tuple[Optional[bool], Optional[str]]:
def _reboot(sections: dict[str, list[str]]) -> tuple[bool | None, str | None]:
if "reboot-required" in sections:
return True, f"{_REBOOT_FILE} is present"
needs = sections.get("needs-restarting")
@@ -229,29 +228,29 @@ def _reboot(sections: Dict[str, List[str]]) -> Tuple[Optional[bool], Optional[st
return None, None
def _timer_states(sections: Dict[str, List[str]]) -> Dict[str, str]:
states: Dict[str, str] = {}
def _timer_states(sections: dict[str, list[str]]) -> dict[str, str]:
states: dict[str, str] = {}
for line in sections.get("timers") or []:
unit, _, state = line.partition(" ")
states[unit] = state.strip()
return states
def _periodic(sections: Dict[str, List[str]]) -> Optional[str]:
def _periodic(sections: dict[str, list[str]]) -> str | None:
"""The value of ``APT::Periodic::Unattended-Upgrade``, or None when it is not set."""
lines = sections.get("apt-config") or []
match = next(filter(None, (_PERIODIC.match(line) for line in lines)), None)
return match.group(1) if match else None
def _dnf_applies(sections: Dict[str, List[str]]) -> bool:
def _dnf_applies(sections: dict[str, list[str]]) -> bool:
"""Whether dnf-automatic.timer installs what it downloads; dnf's default is no."""
lines = sections.get("dnf-automatic") or []
values = [m.group(1) for m in map(_APPLY_UPDATES.match, lines) if m]
return bool(values) and values[-1].lower() in _TRUE
def _auto_updates(sections: Dict[str, List[str]]) -> Optional[bool]:
def _auto_updates(sections: dict[str, list[str]]) -> bool | None:
timers = _timer_states(sections)
if timers.get(_DNF_INSTALL_TIMER) == "enabled":
return True
@@ -265,7 +264,7 @@ def _auto_updates(sections: Dict[str, List[str]]) -> Optional[bool]:
return switched_on and installed and timers.get(_APT_TIMER) not in _OFF_STATES
def _interval(sections: Dict[str, List[str]]) -> Optional[int]:
def _interval(sections: dict[str, list[str]]) -> int | None:
"""apt's interval in seconds, the way ``apt.systemd.daily`` reads it; 0 for "always"."""
value = _periodic(sections)
if value == "always":
@@ -276,19 +275,19 @@ def _interval(sections: Dict[str, List[str]]) -> Optional[int]:
return int(match.group(1)) * _UNIT_SECONDS[match.group(2)]
def _last_success(sections: Dict[str, List[str]]) -> Optional[int]:
def _last_success(sections: dict[str, list[str]]) -> int | None:
stamp = (sections.get("upgrade-stamp") or [""])[0]
return int(stamp) if stamp.isdigit() else None
def _halfway(line: str) -> Optional[InterruptedPackageDict]:
def _halfway(line: str) -> InterruptedPackageDict | None:
status, _, name = line.partition(" ")
name = name.strip()
state = "reinstall-required" if status[2:3] == "R" else _HALFWAY.get(status[1:2])
return {"name": name, "state": state} if name and state else None
def _dpkg(sections: Dict[str, List[str]]) -> Tuple[Optional[bool], List[InterruptedPackageDict]]:
def _dpkg(sections: dict[str, list[str]]) -> tuple[bool | None, list[InterruptedPackageDict]]:
if "dpkg-audit" not in sections or "dpkg-busy" in sections:
return None, []
packages = [p for p in map(_halfway, sections["dpkg-audit"]) if p is not None]
@@ -296,9 +295,9 @@ def _dpkg(sections: Dict[str, List[str]]) -> Tuple[Optional[bool], List[Interrup
return bool(packages) or journal, packages
def _filesystems(sections: Dict[str, List[str]]) -> Optional[List[FilesystemUsageDict]]:
def _filesystems(sections: dict[str, list[str]]) -> list[FilesystemUsageDict] | None:
"""Each filesystem df named, once and in its order; None when it named none."""
found: Dict[str, FilesystemUsageDict] = {}
found: dict[str, FilesystemUsageDict] = {}
for line in sections.get("filesystems") or []:
match = _DF_LINE.match(line)
if not match or int(match.group(1)) == 0:
+20 -24
View File
@@ -10,25 +10,26 @@ Usage::
...
"""
from typing import Any, Dict, List, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from napalm_device_types.base import DeviceTypeDriver
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.provisioning import QEMU_GUEST_AGENTS, GuestAgents
from napalm_device_types.models import (
HypervisorHostDict,
NICConfigDict,
NetworkTargetDict,
NICConfigDict,
SnapshotDict,
StorageTargetDict,
StorageVolumeDict,
VirtualNetworkDict,
VMConfigDict,
VMCpuTypeDict,
VMDict,
VMProvisionResultDict,
VMStatusDict,
VirtualNetworkDict,
)
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.provisioning import QEMU_GUEST_AGENTS, GuestAgents
class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDriver):
@@ -52,14 +53,13 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
#: the driver can provision; netOrk reads this off the class.
GUEST_AGENTS: GuestAgents = {"linux": QEMU_GUEST_AGENTS["linux"]}
# ------------------------------------------------------------------
# Virtual machines – read
# ------------------------------------------------------------------
if TYPE_CHECKING:
def get_vms(self) -> List[VMDict]:
def get_vms(self) -> list[VMDict]:
"""
Returns a list of all virtual machines known to this hypervisor,
including their runtime status.
@@ -262,7 +262,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
# Snapshots
# ------------------------------------------------------------------
def get_vm_snapshots(self, name: str) -> List[SnapshotDict]:
def get_vm_snapshots(self, name: str) -> list[SnapshotDict]:
"""
Returns all snapshots of a virtual machine.
@@ -301,8 +301,9 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
"""
...
def create_vm_snapshot(self, name: str, snapshot: str,
description: str = "", include_memory: bool = False) -> None:
def create_vm_snapshot(
self, name: str, snapshot: str, description: str = "", include_memory: bool = False
) -> None:
"""
Creates a snapshot of a virtual machine.
@@ -359,7 +360,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
# Storage
# ------------------------------------------------------------------
def get_vm_storage_pools(self) -> Dict[str, StorageVolumeDict]:
def get_vm_storage_pools(self) -> dict[str, StorageVolumeDict]:
"""
Returns the storage pools / datastores configured on this hypervisor.
@@ -403,7 +404,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
# Virtual networking
# ------------------------------------------------------------------
def get_virtual_networks(self) -> Dict[str, VirtualNetworkDict]:
def get_virtual_networks(self) -> dict[str, VirtualNetworkDict]:
"""
Returns the virtual networks / bridges defined on this hypervisor.
@@ -443,11 +444,6 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
# Package management (hypervisor extensions / plugins)
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# Virtual machine provisioning
# ------------------------------------------------------------------
@@ -459,10 +455,10 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
image_url: str,
cpu: int,
memory: int,
nics: List[NICConfigDict],
cloud_init_config: Dict[str, Any],
nics: list[NICConfigDict],
cloud_init_config: dict[str, Any],
image_checksum: str | None = None,
ssh_public_keys: List[str] | None = None,
ssh_public_keys: list[str] | None = None,
disk_resize_gb: int | None = None,
storage: str | None = None,
cpu_type: str | None = None,
@@ -602,7 +598,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
"""
...
def get_network_targets(self) -> List[NetworkTargetDict]:
def get_network_targets(self) -> list[NetworkTargetDict]:
"""
List the network targets a new VM's NIC may attach to.
@@ -620,7 +616,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
"""
...
def get_image_storages(self) -> List[StorageTargetDict]:
def get_image_storages(self) -> list[StorageTargetDict]:
"""
List the storage pools a new VM's root disk may be placed on, scoped to
the specific node the VM will be created on.
@@ -638,13 +634,13 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
"""
...
def get_hypervisor_hosts(self) -> List[HypervisorHostDict]:
def get_hypervisor_hosts(self) -> list[HypervisorHostDict]:
"""The hosts this hypervisor or its manager runs, and how each stands:
connected, in maintenance, with configuration issues, under a central
manager. Facts; what is a problem is the caller's to decide."""
...
def get_vm_cpu_types(self) -> List[VMCpuTypeDict]:
def get_vm_cpu_types(self) -> list[VMCpuTypeDict]:
"""
List the virtual CPU models a new VM may be given on the node it will
be created on.
+4 -5
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Dropping interfaces that are noise rather than signal.
An access point exposes radio PHYs and a loopback alongside its real
@@ -9,19 +8,19 @@ attributes are the customisation point.
from __future__ import annotations
from typing import Any, ClassVar, Dict, FrozenSet, Tuple
from typing import Any, ClassVar
class InterfaceFilterMixin:
"""Adds :meth:`_filter_interfaces` for drivers that report raw interface dicts."""
#: Interface names dropped outright.
_EXCLUDED_INTERFACES: ClassVar[FrozenSet[str]] = frozenset({"lo"})
_EXCLUDED_INTERFACES: ClassVar[frozenset[str]] = frozenset({"lo"})
#: Name prefixes dropped -- ``phy*`` are radio devices, not links.
_EXCLUDED_INTERFACE_PREFIXES: ClassVar[Tuple[str, ...]] = ("phy",)
_EXCLUDED_INTERFACE_PREFIXES: ClassVar[tuple[str, ...]] = ("phy",)
def _filter_interfaces(self, interfaces: Dict[str, Any]) -> Dict[str, Any]:
def _filter_interfaces(self, interfaces: dict[str, Any]) -> dict[str, Any]:
"""Remove loopback and radio-device (phy*) interfaces from an interface dict."""
return {
name: data
+7 -8
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""What the running kernel has built and loaded.
A kernel CVE's exploitability often hangs on code that is simply not there --
@@ -25,7 +24,7 @@ import base64
import binascii
import gzip
import zlib
from typing import Dict, List, Optional, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.models import KernelFactsDict
@@ -78,9 +77,9 @@ def _report(output: str) -> str:
return report
def _sections(report: str) -> Dict[str, List[str]]:
sections: Dict[str, List[str]] = {}
current: Optional[List[str]] = None
def _sections(report: str) -> dict[str, list[str]]:
sections: dict[str, list[str]] = {}
current: list[str] | None = None
for line in report.splitlines():
line = line.strip()
if line.startswith("[") and line.endswith("]"):
@@ -90,8 +89,8 @@ def _sections(report: str) -> Dict[str, List[str]]:
return sections
def _config(lines: List[str]) -> Dict[str, str]:
config: Dict[str, str] = {}
def _config(lines: list[str]) -> dict[str, str]:
config: dict[str, str] = {}
for line in lines:
option, sep, value = line.partition("=")
if not sep:
@@ -112,7 +111,7 @@ def parse_kernel_facts(output: str) -> KernelFactsDict:
"""
sections = _sections(_report(output))
def names(key: str) -> Optional[List[str]]:
def names(key: str) -> list[str] | None:
if key not in sections:
return None
return sorted({module_name(line) for line in sections[key]})
+7 -8
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Logical LAG entries for ``get_interfaces()``, built from their member ports.
Some switches list only physical ports, each tagged with the trunk it belongs
@@ -10,17 +9,17 @@ the same for every vendor, so it lives here once; a driver only has to set
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional
from typing import Any
def _port_order(name: str) -> List[Any]:
def _port_order(name: str) -> list[Any]:
return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", name)]
def add_lag_interfaces(
interfaces: Dict[str, Dict[str, Any]],
lag_modes: Optional[Dict[str, str]] = None,
) -> Dict[str, Dict[str, Any]]:
interfaces: dict[str, dict[str, Any]],
lag_modes: dict[str, str] | None = None,
) -> dict[str, dict[str, Any]]:
"""Return *interfaces* plus one logical entry per ``trunk_group``.
The LAG entry is up/enabled if any member is, its speed is the members'
@@ -31,7 +30,7 @@ def add_lag_interfaces(
mode gets no ``lag_mode`` key rather than a guessed one.
"""
result = dict(interfaces)
groups: Dict[str, List[str]] = {}
groups: dict[str, list[str]] = {}
for name, iface in interfaces.items():
group = iface.get("trunk_group")
if group:
@@ -41,7 +40,7 @@ def add_lag_interfaces(
if group in result:
continue
members = sorted(members, key=_port_order)
lag: Dict[str, Any] = {
lag: dict[str, Any] = {
"is_up": any(interfaces[m].get("is_up") for m in members),
"is_enabled": any(interfaces[m].get("is_enabled") for m in members),
"description": f"LAG ({', '.join(members)})",
+2 -3
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Licenses and subscriptions a device reports.
A fact for the caller (docs/STRUCTURE.md): whether a free edition, a missing
@@ -8,7 +7,7 @@ driver. Declared only; a driver that can read licenses implements it.
from __future__ import annotations
from typing import List, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.models import LicenseDict
@@ -16,7 +15,7 @@ from napalm_device_types.models import LicenseDict
class LicenseMixin:
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
def get_licenses(self) -> List[LicenseDict]:
def get_licenses(self) -> list[LicenseDict]:
"""Every license or subscription the device knows of, with its status.
Example::
+23 -22
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Listening sockets: what listens on which address, and which service it is.
Whether a service is reachable from outside its host is decided by what it
@@ -34,7 +33,7 @@ from __future__ import annotations
import re
from shlex import quote
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.models import ListeningSocketDict, ListeningSocketsDict
from napalm_device_types.terminal import strip_terminal_codes
@@ -58,11 +57,11 @@ LISTENING_SOCKETS_COMMAND = (
"t=netstat; s=$(netstat -lntup 2>&1); r=$?; "
"pids=$(printf '%s\\n' \"$s\" | grep -o ' [0-9][0-9]*/' | tr -d ' /'); "
"else t=; fi; "
"if [ -n \"$t\" ]; then "
"echo \"[$t]\"; printf '%s\\n' \"$s\"; echo \"__SS_RC=$r\"; "
'if [ -n "$t" ]; then '
'echo "[$t]"; printf \'%s\\n\' "$s"; echo "__SS_RC=$r"; '
"echo '[cgroups]'; "
"for p in $(printf '%s\\n' \"$pids\" | sort -u); do "
"sed \"s|^|$p |\" /proc/$p/cgroup 2>/dev/null; done; "
'sed "s|^|$p |" /proc/$p/cgroup 2>/dev/null; done; '
"else echo '[no-ss]'; fi; "
"printf '%s%s\\n' SOCK_ END"
)
@@ -88,7 +87,7 @@ class ListeningSocketsUnavailable(NotImplementedError):
"""The host has neither ``ss`` nor ``netstat``; nothing to read, nothing to retry."""
def _frame(output: str) -> List[str]:
def _frame(output: str) -> list[str]:
lines = [line.strip() for line in strip_terminal_codes(output).splitlines()]
try:
start = lines.index(_BEGIN)
@@ -98,9 +97,9 @@ def _frame(output: str) -> List[str]:
return lines[start + 1 : end]
def _sections(lines: List[str]) -> Dict[str, List[str]]:
sections: Dict[str, List[str]] = {}
current: List[str] = []
def _sections(lines: list[str]) -> dict[str, list[str]]:
sections: dict[str, list[str]] = {}
current: list[str] = []
for line in lines:
if line.startswith("[") and line.endswith("]") and " " not in line:
current = sections.setdefault(line[1:-1], [])
@@ -109,7 +108,7 @@ def _sections(lines: List[str]) -> Dict[str, List[str]]:
return sections
def _split_local(local: str) -> Optional[Tuple[str, Optional[str], int]]:
def _split_local(local: str) -> tuple[str, str | None, int] | None:
"""``[fe80::1]%eth0:546`` (or ``[fe80::1%eth0]:546``) -> ``("fe80::1", "eth0", 546)``;
None if no port."""
host, sep, port = local.rpartition(":")
@@ -122,9 +121,9 @@ def _split_local(local: str) -> Optional[Tuple[str, Optional[str], int]]:
return address or "*", zone or None, int(port)
def _cgroup_paths(lines: List[str]) -> Dict[int, str]:
def _cgroup_paths(lines: list[str]) -> dict[int, str]:
"""Each process's cgroup path: the unified hierarchy, or systemd's under v1."""
paths: Dict[int, str] = {}
paths: dict[int, str] = {}
for line in lines:
pid, _, entry = line.partition(" ")
parts = entry.split(":", 2)
@@ -136,7 +135,7 @@ def _cgroup_paths(lines: List[str]) -> Dict[int, str]:
return paths
def _unit(path: Optional[str]) -> Optional[str]:
def _unit(path: str | None) -> str | None:
services = _SERVICE_RE.findall(path or "")
if services:
return str(services[-1])
@@ -144,17 +143,17 @@ def _unit(path: Optional[str]) -> Optional[str]:
return str(procd.group(1)) if procd else None
def _container(path: Optional[str]) -> Optional[str]:
def _container(path: str | None) -> str | None:
match = _CONTAINER_RE.search(path or "")
return (match.group(1) or match.group(2)) if match else None
def _entry(
proto: str,
local: Tuple[str, Optional[str], int],
process: Optional[str],
pid: Optional[int],
paths: Dict[int, str],
local: tuple[str, str | None, int],
process: str | None,
pid: int | None,
paths: dict[int, str],
) -> ListeningSocketDict:
address, interface, port = local
path = paths.get(pid) if pid is not None else None
@@ -170,7 +169,7 @@ def _entry(
}
def _ss_socket(line: str, paths: Dict[int, str]) -> Optional[ListeningSocketDict]:
def _ss_socket(line: str, paths: dict[int, str]) -> ListeningSocketDict | None:
parts = line.split()
if len(parts) < 5 or parts[0] not in _PROTOCOLS:
return None
@@ -182,7 +181,7 @@ def _ss_socket(line: str, paths: Dict[int, str]) -> Optional[ListeningSocketDict
return _entry(parts[0], local, process, pid, paths)
def _netstat_socket(line: str, paths: Dict[int, str]) -> Optional[ListeningSocketDict]:
def _netstat_socket(line: str, paths: dict[int, str]) -> ListeningSocketDict | None:
"""``Proto Recv-Q Send-Q Local Foreign [State] PID/Program`` -- a UDP line
has no state, and ``-`` is a socket without a process."""
parts = line.split()
@@ -204,7 +203,7 @@ def _netstat_socket(line: str, paths: Dict[int, str]) -> Optional[ListeningSocke
_PARSERS = {"ss": _ss_socket, "netstat": _netstat_socket}
def parse_listening_sockets(output: str) -> List[ListeningSocketDict]:
def parse_listening_sockets(output: str) -> list[ListeningSocketDict]:
"""Parse what :data:`LISTENING_SOCKETS_COMMAND` printed, sorted by protocol,
port and address.
@@ -223,7 +222,9 @@ def parse_listening_sockets(output: str) -> List[ListeningSocketDict]:
paths = _cgroup_paths(sections.get("cgroups", []))
parse = _PARSERS[tool]
sockets = [s for s in (parse(line, paths) for line in lines) if s is not None]
return sorted(sockets, key=lambda s: (s["proto"], s["port"], s["address"], s["interface"] or ""))
return sorted(
sockets, key=lambda s: (s["proto"], s["port"], s["address"], s["interface"] or "")
)
class ListeningSocketsMixin:
+3 -4
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""MAC-based access control, per SSID on an AP and per port on a switch.
The reader is identical either way; only the writer is AP-specific so far.
@@ -10,7 +9,7 @@ this class in can never shadow a working implementation from a sibling base.
from __future__ import annotations
from typing import Dict, List, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.models import MACACLDict
@@ -18,7 +17,7 @@ from napalm_device_types.models import MACACLDict
class MacAclMixin:
if TYPE_CHECKING:
def get_mac_acl(self) -> Dict[str, MACACLDict]:
def get_mac_acl(self) -> dict[str, MACACLDict]:
"""
Returns the MAC-address-based access control lists configured per SSID.
@@ -59,7 +58,7 @@ class MacAclMixin:
"""
...
def push_mac_acl(self, ssid_name: str, mode: str, macs: List[str]) -> None:
def push_mac_acl(self, ssid_name: str, mode: str, macs: list[str]) -> None:
"""
Rewrites the MAC-address access control list for a single SSID.
+3 -4
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""
Abstract base class for server baseboard management controllers (BMCs).
@@ -10,7 +9,7 @@ Usage::
...
"""
from typing import TYPE_CHECKING, List
from typing import TYPE_CHECKING
from napalm_device_types.base import DeviceTypeDriver
@@ -107,7 +106,7 @@ class ManagementControllerDriver(DeviceTypeDriver):
"""
...
def get_event_log(self, limit: int = 500) -> List[EventLogEntryDict]:
def get_event_log(self, limit: int = 500) -> list[EventLogEntryDict]:
"""
Returns the BMC's system event log (SEL, Lifecycle Log, IML),
newest first.
@@ -116,7 +115,7 @@ class ManagementControllerDriver(DeviceTypeDriver):
"""
...
def get_firmware_inventory(self) -> List[FirmwareDict]:
def get_firmware_inventory(self) -> list[FirmwareDict]:
"""
Returns every firmware component the BMC reports: its own, the
BIOS, storage controllers, NICs, power supplies.
+3 -4
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""
Abstract base class for networked media players.
@@ -10,7 +9,7 @@ Usage::
...
"""
from typing import TYPE_CHECKING, Any, Dict, List
from typing import TYPE_CHECKING, Any
from napalm_device_types.base import DeviceTypeDriver
@@ -34,7 +33,7 @@ class MediaDriver(DeviceTypeDriver):
if TYPE_CHECKING:
def get_playback_state(self) -> Dict[str, Any]:
def get_playback_state(self) -> dict[str, Any]:
"""
Returns the transport state of the renderer.
@@ -51,7 +50,7 @@ class MediaDriver(DeviceTypeDriver):
"""Sets the output volume, 0-100."""
...
def get_zone_info(self) -> List[Dict[str, Any]]:
def get_zone_info(self) -> list[dict[str, Any]]:
"""
Returns the zones or groups this renderer participates in.
+221 -219
View File
@@ -5,9 +5,9 @@ These supplement the types defined in napalm.base.models and are used by the
abstract device-type driver classes in this package.
"""
from typing import Dict, List, Literal, Optional, Tuple, get_args
from typing_extensions import NotRequired, TypedDict
from typing import Literal, get_args
from typing_extensions import NotRequired, TypedDict
# ---------------------------------------------------------------------------
# Common (shared across device types)
@@ -36,7 +36,7 @@ class MACACLDict(TypedDict):
name: str
policy: str
entries: List[MACACLEntryDict]
entries: list[MACACLEntryDict]
class PackageDict(TypedDict):
@@ -70,9 +70,9 @@ class UpdateDict(TypedDict):
current_version: str
new_version: str
#: Where the new version comes from, e.g. apt's suites "noble-updates,noble-security".
origin: NotRequired[Optional[str]]
origin: NotRequired[str | None]
#: True for a security update, False for a known other one, None when unknown.
security: NotRequired[Optional[bool]]
security: NotRequired[bool | None]
class InterruptedPackageDict(TypedDict):
@@ -100,24 +100,24 @@ class HostStatusDict(TypedDict):
"""What a host says about its own patch state (``HostStatusMixin.get_host_status``)."""
#: True when the host needs a reboot to finish an update, None when it cannot tell.
reboot_required: Optional[bool]
reboot_required: bool | None
#: Why, e.g. "kernel 6.8.0-142-generic installed, 6.8.0-139-generic running".
reboot_reason: Optional[str]
reboot_reason: str | None
#: True when the host installs updates on its own (unattended-upgrades, dnf-automatic).
auto_updates: Optional[bool]
auto_updates: bool | None
#: True when a package manager run was cut off and has to be finished
#: (``dpkg --configure -a``) before anything else installs; None when it
#: cannot tell, or a package manager is running right now.
package_manager_interrupted: NotRequired[Optional[bool]]
package_manager_interrupted: NotRequired[bool | None]
#: The packages that run left halfway; empty when only its journal says so.
interrupted_packages: NotRequired[List[InterruptedPackageDict]]
interrupted_packages: NotRequired[list[InterruptedPackageDict]]
#: When the host's own automatic updates last ran successfully (Unix time).
auto_updates_last_success: NotRequired[Optional[int]]
auto_updates_last_success: NotRequired[int | None]
#: How often they are meant to run, in seconds; 0 for every time the timer fires.
auto_updates_interval: NotRequired[Optional[int]]
auto_updates_interval: NotRequired[int | None]
#: How full ``/``, ``/boot`` and ``/var`` are, each filesystem once; None when
#: it could not be read.
filesystems: NotRequired[Optional[List[FilesystemUsageDict]]]
filesystems: NotRequired[list[FilesystemUsageDict] | None]
# ---------------------------------------------------------------------------
@@ -163,11 +163,11 @@ class ChannelScanEntryDict(TypedDict):
bssid: str
ssid: str
frequency: int # centre frequency in MHz, e.g. 2437
channel: int # channel number, e.g. 6
signal_dbm: int # RSSI in dBm, e.g. -72
frequency: int # centre frequency in MHz, e.g. 2437
channel: int # channel number, e.g. 6
signal_dbm: int # RSSI in dBm, e.g. -72
channel_width: int # MHz: 20 / 40 / 80 / 160; 0 if unknown
band: str # "2.4GHz" | "5GHz" | "6GHz"
band: str # "2.4GHz" | "5GHz" | "6GHz"
class WirelessConfigDict(TypedDict):
@@ -226,7 +226,7 @@ class Dot1XConfigDict(TypedDict):
enabled: bool
ssid: str
auth_server: RadiusServerDict
acct_server: Optional[RadiusServerDict]
acct_server: RadiusServerDict | None
reauth_interval: int
pmksa_caching: bool
@@ -263,7 +263,7 @@ class InterfaceConfigDict(TypedDict, total=False):
mode: str
access_vlan: int
voice_vlan: int
trunk_vlans: List[int]
trunk_vlans: list[int]
native_vlan: int
@@ -280,7 +280,7 @@ class VlanConfigDict(TypedDict, total=False):
name: str
active: bool
interfaces: List[str]
interfaces: list[str]
class STPInterfaceDict(TypedDict):
@@ -297,11 +297,11 @@ class SpanningTreeDict(TypedDict):
root_priority: int
bridge_id: str
bridge_priority: int
interfaces: Dict[str, STPInterfaceDict]
interfaces: dict[str, STPInterfaceDict]
class PortChannelDict(TypedDict):
members: List[str]
members: list[str]
protocol: str
min_links: int
is_up: bool
@@ -314,7 +314,7 @@ class Dot1XPortDict(TypedDict):
port_control: str
host_mode: str
auth_server: RadiusServerDict
acct_server: Optional[RadiusServerDict]
acct_server: RadiusServerDict | None
reauthentication: bool
reauth_interval: int
guest_vlan: int
@@ -334,7 +334,7 @@ class PoEPortDict(TypedDict):
class PoESummaryDict(TypedDict):
total_power_budget: float
total_power_draw: float
ports: Dict[str, PoEPortDict]
ports: dict[str, PoEPortDict]
# ---------------------------------------------------------------------------
@@ -363,10 +363,10 @@ class KernelFactsDict(TypedDict):
"""
release: str # uname -r
loaded: Optional[List[str]] # /proc/modules
builtin: Optional[List[str]] # modules.builtin -- compiled into the kernel image
available: Optional[List[str]] # modules.dep -- shipped as loadable modules
config: Optional[Dict[str, str]] # build configuration, set options only; quotes stripped
loaded: list[str] | None # /proc/modules
builtin: list[str] | None # modules.builtin -- compiled into the kernel image
available: list[str] | None # modules.dep -- shipped as loadable modules
config: dict[str, str] | None # build configuration, set options only; quotes stripped
class ListeningSocketDict(TypedDict):
@@ -381,11 +381,11 @@ class ListeningSocketDict(TypedDict):
proto: str # "tcp" or "udp"
address: str # "0.0.0.0", "::", "*", "127.0.0.1", "::ffff:127.0.0.1", ...
port: int
interface: Optional[str] # the %zone a socket is bound to ("lo", "eth0"), if any
process: Optional[str] # the first process holding it; None without one or without root
pid: Optional[int]
unit: Optional[str] # the process's systemd service, without ".service"
container_id: Optional[str] # the full container ID, for a container on the host network
interface: str | None # the %zone a socket is bound to ("lo", "eth0"), if any
process: str | None # the first process holding it; None without one or without root
pid: int | None
unit: str | None # the process's systemd service, without ".service"
container_id: str | None # the full container ID, for a container on the host network
class ListeningSocketsDict(TypedDict):
@@ -397,7 +397,7 @@ class ListeningSocketsDict(TypedDict):
"""
attributed: bool
sockets: List[ListeningSocketDict]
sockets: list[ListeningSocketDict]
class NetfilterMatchDict(TypedDict):
@@ -413,7 +413,7 @@ class NetfilterMatchDict(TypedDict):
field: str
op: str # "==" or "!="
values: List[str]
values: list[str]
class NetfilterRuleDict(TypedDict):
@@ -424,9 +424,9 @@ class NetfilterRuleDict(TypedDict):
marks), or ``other`` -- NAT, a queue, a verdict map -- with ``target`` naming it.
"""
matches: List[NetfilterMatchDict]
matches: list[NetfilterMatchDict]
verdict: str
target: Optional[str]
target: str | None
text: str # the rule as iptables-save printed it, or as one nft-style line
@@ -437,11 +437,11 @@ class NetfilterChainDict(TypedDict):
family: str # "ip", "ip6", "inet", "bridge", "arp", "netdev"
table: str
name: str
type: Optional[str] # "filter", "nat", "route"
hook: Optional[str] # "prerouting", "input", "forward", "output", "postrouting", ...
priority: Optional[int]
policy: Optional[str] # "accept" or "drop"
rules: List[NetfilterRuleDict]
type: str | None # "filter", "nat", "route"
hook: str | None # "prerouting", "input", "forward", "output", "postrouting", ...
priority: int | None
policy: str | None # "accept" or "drop"
rules: list[NetfilterRuleDict]
class HostFirewallDict(TypedDict):
@@ -452,8 +452,8 @@ class HostFirewallDict(TypedDict):
extensions opaquely) and ``iptables-legacy`` (a ruleset of its own).
"""
sources: List[str]
chains: List[NetfilterChainDict]
sources: list[str]
chains: list[NetfilterChainDict]
class VirtualIpDict(TypedDict):
@@ -471,14 +471,14 @@ class VirtualIpDict(TypedDict):
#: CARP address's interface description
instance: str
address: str # normalised: "10.7.224.10", "fd00::10"
prefix_length: Optional[int] # None where the configuration gives none
interface: Optional[str]
prefix_length: int | None # None where the configuration gives none
interface: str | None
#: vip-manager's trigger-key, "vrid:<n>" for keepalived, "vhid:<n>" for CARP
group_key: Optional[str]
running: Optional[bool] # the unit is active; for CARP: in MASTER or BACKUP
group_key: str | None
running: bool | None # the unit is active; for CARP: in MASTER or BACKUP
#: The host holds the address now, by the mechanism's own state (CARP's
#: MASTER). Absent or None: the interface addresses tell.
holds: NotRequired[Optional[bool]]
holds: NotRequired[bool | None]
class ServiceClusterMemberDict(TypedDict):
@@ -492,19 +492,19 @@ class ServiceClusterMemberDict(TypedDict):
endpoint.
"""
name: Optional[str]
address: Optional[str]
port: Optional[int]
name: str | None
address: str | None
port: int | None
#: as the mechanism words it: Patroni's "leader"/"sync_standby"/"replica",
#: adguardhome-sync's "origin"/"replica", BunkerWeb's "manager"/"worker"
role: Optional[str]
role: str | None
#: the member's state where the mechanism reports one (Patroni's "running",
#: "streaming"); configuration alone has none
state: Optional[str]
state: str | None
this_host: bool
#: the index in ``members`` of the member whose configuration this one takes:
#: a replica's origin, a worker's manager. None where it takes none.
config_from: Optional[int]
config_from: int | None
class ServiceClusterDict(TypedDict):
@@ -525,9 +525,9 @@ class ServiceClusterDict(TypedDict):
#: what the members share where the reading host can name it: Patroni's scope,
#: the sync's origin ("10.9.100.5:3000"), a worker's manager address. None for a
#: manager, which cannot name its own address.
cluster: Optional[str]
running: Optional[bool] # the unit is active; a listed container runs
members: List[ServiceClusterMemberDict]
cluster: str | None
running: bool | None # the unit is active; a listed container runs
members: list[ServiceClusterMemberDict]
class ServiceClustersDict(TypedDict):
@@ -538,9 +538,9 @@ class ServiceClustersDict(TypedDict):
container engine it may not ask, a sync configured by file.
"""
patroni: Optional[List[ServiceClusterDict]]
adguardhome_sync: Optional[List[ServiceClusterDict]]
bunkerweb: Optional[List[ServiceClusterDict]]
patroni: list[ServiceClusterDict] | None
adguardhome_sync: list[ServiceClusterDict] | None
bunkerweb: list[ServiceClusterDict] | None
class VirtualIpsDict(TypedDict):
@@ -553,11 +553,11 @@ class VirtualIpsDict(TypedDict):
keeps what it knew for a None, and for a missing ``carp``.
"""
vip_manager: Optional[List[VirtualIpDict]]
keepalived: Optional[List[VirtualIpDict]]
vip_manager: list[VirtualIpDict] | None
keepalived: list[VirtualIpDict] | None
#: A firewall's CARP addresses (not read by ``VirtualIpsMixin``, which reads Linux
#: hosts): every address bound to a VHID, the CARP address and IP aliases on it.
carp: NotRequired[Optional[List[VirtualIpDict]]]
carp: NotRequired[list[VirtualIpDict] | None]
class PortForwardDict(TypedDict):
@@ -576,7 +576,7 @@ class PortForwardDict(TypedDict):
class SecurityZoneDict(TypedDict):
interfaces: List[str]
interfaces: list[str]
policy: str
description: str
@@ -622,12 +622,12 @@ class FirewallRuleDict(TypedDict):
class FirewallRuleUpdateDict(TypedDict):
uuid: str
rule: FirewallRuleDict
changed_fields: List[str]
changed_fields: list[str]
class FirewallRuleDiffDict(TypedDict):
add: List[FirewallRuleDict]
update: List[FirewallRuleUpdateDict]
add: list[FirewallRuleDict]
update: list[FirewallRuleUpdateDict]
class DhcpReservationDict(TypedDict):
@@ -656,12 +656,12 @@ class DhcpReservationDict(TypedDict):
class DhcpReservationUpdateDict(TypedDict):
uuid: str
reservation: DhcpReservationDict
changed_fields: List[str]
changed_fields: list[str]
class DhcpReservationDiffDict(TypedDict):
add: List[DhcpReservationDict]
update: List[DhcpReservationUpdateDict]
add: list[DhcpReservationDict]
update: list[DhcpReservationUpdateDict]
class DhcpOptionDataDict(TypedDict, total=False):
@@ -676,11 +676,11 @@ class DhcpOptionDataDict(TypedDict, total=False):
desired value is empty". A driver must preserve options it was not given.
"""
routers: List[str]
domain_name_servers: List[str]
routers: list[str]
domain_name_servers: list[str]
domain_name: str
domain_search: List[str]
ntp_servers: List[str]
domain_search: list[str]
ntp_servers: list[str]
class DhcpSubnetDict(TypedDict):
@@ -702,7 +702,7 @@ class DhcpSubnetDict(TypedDict):
uuid: str
subnet: str
description: str
pools: List[str]
pools: list[str]
option_data: DhcpOptionDataDict
match_client_id: bool
@@ -710,12 +710,12 @@ class DhcpSubnetDict(TypedDict):
class DhcpSubnetUpdateDict(TypedDict):
uuid: str
subnet: DhcpSubnetDict
changed_fields: List[str]
changed_fields: list[str]
class DhcpSubnetDiffDict(TypedDict):
add: List[DhcpSubnetDict]
update: List[DhcpSubnetUpdateDict]
add: list[DhcpSubnetDict]
update: list[DhcpSubnetUpdateDict]
class DhcpHaPeerDict(TypedDict):
@@ -742,7 +742,7 @@ class DhcpHaDict(TypedDict):
enabled: bool
#: Which of ``peers`` is this server.
this_server: NotRequired[str]
peers: List[DhcpHaPeerDict]
peers: list[DhcpHaPeerDict]
class ConfigSyncDict(TypedDict):
@@ -752,12 +752,12 @@ class ConfigSyncDict(TypedDict):
host without the feature switched on).
"""
peer: Optional[str]
peer: str | None
#: The services whose configuration it pushes, by the name ``get_services``
#: reports.
services: List[str]
services: list[str]
#: Sync items the driver has no service name for, as the host names them.
unmapped: List[str]
unmapped: list[str]
class VPNTunnelDict(TypedDict):
@@ -834,8 +834,8 @@ class VMDict(TypedDict):
class VMPassthroughDict(TypedDict):
"""A host device handed through to a VM (PCI, USB)."""
slot: str # hypervisor's device key, e.g. "hostpci0"
kind: str # "pci" or "usb"
slot: str # hypervisor's device key, e.g. "hostpci0"
kind: str # "pci" or "usb"
config: str # hypervisor's own description of the device
@@ -845,19 +845,19 @@ class VMConfigDict(TypedDict):
vcpus: int
memory: int
os_type: str
boot_order: List[str]
disks: List[VMDiskDict]
nics: List[VMNICDict]
boot_order: list[str]
disks: list[VMDiskDict]
nics: list[VMNICDict]
description: str
tags: List[str]
tags: list[str]
# Hardware details not every hypervisor exposes; absent when unknown.
os_name: NotRequired[str] # human-readable guest OS, e.g. "Ubuntu Linux (64-bit)"
cpu_type: NotRequired[str] # e.g. "host", "kvm64"
os_name: NotRequired[str] # human-readable guest OS, e.g. "Ubuntu Linux (64-bit)"
cpu_type: NotRequired[str] # e.g. "host", "kvm64"
sockets: NotRequired[int]
cores_per_socket: NotRequired[int]
firmware: NotRequired[str] # "bios" or "efi"
machine: NotRequired[str] # machine type / virtual hardware version
passthrough: NotRequired[List[VMPassthroughDict]]
firmware: NotRequired[str] # "bios" or "efi"
machine: NotRequired[str] # machine type / virtual hardware version
passthrough: NotRequired[list[VMPassthroughDict]]
class StorageVolumeDict(TypedDict):
@@ -900,29 +900,29 @@ class PhysicalDiskDict(TypedDict):
model: str
serial: str
vendor: str
type: str # "hdd", "ssd", "nvme"
size: int # bytes
rpm: int # 0 for SSD/NVMe
temperature: int # Celsius; -1 if unavailable
health: str # "healthy", "warning", "failed", "unknown"
pool: str # name of the containing pool; empty string if spare/unassigned
type: str # "hdd", "ssd", "nvme"
size: int # bytes
rpm: int # 0 for SSD/NVMe
temperature: int # Celsius; -1 if unavailable
health: str # "healthy", "warning", "failed", "unknown"
pool: str # name of the containing pool; empty string if spare/unassigned
class DiskPoolDict(TypedDict):
"""A RAID array, ZFS pool, or volume group."""
name: str
type: str # "zfs", "lvm", "md", "hardware-raid", "btrfs"
level: str # "stripe", "mirror", "raidz1", "raidz2", "raidz3",
# "raid0" … "raid60", "single", etc.
status: str # "online", "degraded", "faulted", "offline", "unknown"
total: int # bytes
used: int # bytes
available: int # bytes
disks: List[str] # slot identifiers of member disks
type: str # "zfs", "lvm", "md", "hardware-raid", "btrfs"
level: str # "stripe", "mirror", "raidz1", "raidz2", "raidz3",
# "raid0" … "raid60", "single", etc.
status: str # "online", "degraded", "faulted", "offline", "unknown"
total: int # bytes
used: int # bytes
available: int # bytes
disks: list[str] # slot identifiers of member disks
auto_expand: bool
dedup: bool
compression: str # "off", "lz4", "gzip", "zstd", etc.
compression: str # "off", "lz4", "gzip", "zstd", etc.
class LogicalVolumeDict(TypedDict):
@@ -930,74 +930,74 @@ class LogicalVolumeDict(TypedDict):
name: str
pool: str
type: str # "filesystem", "volume" (block device / LUN), "zvol"
total: int # bytes
used: int # bytes
available: int # bytes
mountpoint: str # empty string for block volumes
compression: str # "off", "lz4", etc.
type: str # "filesystem", "volume" (block device / LUN), "zvol"
total: int # bytes
used: int # bytes
available: int # bytes
mountpoint: str # empty string for block volumes
compression: str # "off", "lz4", etc.
dedup: bool
readonly: bool
snapshots: int # number of snapshots currently held
snapshots: int # number of snapshots currently held
class NASShareDict(TypedDict):
"""A network share or iSCSI target exported by the storage device."""
name: str
protocol: str # "nfs", "smb", "afp", "ftp", "sftp", "iscsi", "webdav"
path: str # local filesystem path or iSCSI target IQN
volume: str # logical volume or dataset this share is backed by
protocol: str # "nfs", "smb", "afp", "ftp", "sftp", "iscsi", "webdav"
path: str # local filesystem path or iSCSI target IQN
volume: str # logical volume or dataset this share is backed by
enabled: bool
readonly: bool
description: str
clients: List[str] # IP/subnet allow-list; empty list = all hosts allowed
clients: list[str] # IP/subnet allow-list; empty list = all hosts allowed
class VolumeSnapshotDict(TypedDict):
"""A point-in-time snapshot of a logical volume or dataset."""
name: str
volume: str # parent volume / dataset
created: float # Unix epoch
size: int # bytes of unique data referenced by this snapshot
volume: str # parent volume / dataset
created: float # Unix epoch
size: int # bytes of unique data referenced by this snapshot
description: str
clones: List[str] # volumes cloned from this snapshot
clones: list[str] # volumes cloned from this snapshot
class StorageQuotaDict(TypedDict):
"""A filesystem quota applied to a user, group, or dataset."""
target: str # username, group name, or dataset path
target_type: str # "user", "group", "dataset"
volume: str # volume / dataset the quota applies to
used: int # bytes currently used
quota: int # hard limit in bytes; 0 = no limit
ref_quota: int # referenced-data limit in bytes; 0 = no limit
target: str # username, group name, or dataset path
target_type: str # "user", "group", "dataset"
volume: str # volume / dataset the quota applies to
used: int # bytes currently used
quota: int # hard limit in bytes; 0 = no limit
ref_quota: int # referenced-data limit in bytes; 0 = no limit
class StorageServiceDict(TypedDict):
"""Status of a file-sharing or access service running on the device."""
name: str # "nfs", "smb", "ftp", "ssh", "iscsi", "webdav", etc.
enabled: bool # administratively enabled
running: bool # currently active / listening
port: int # primary listening port; 0 if N/A
version: str # protocol version string (e.g. "4.1" for NFSv4.1)
name: str # "nfs", "smb", "ftp", "ssh", "iscsi", "webdav", etc.
enabled: bool # administratively enabled
running: bool # currently active / listening
port: int # primary listening port; 0 if N/A
version: str # protocol version string (e.g. "4.1" for NFSv4.1)
class ReplicationJobDict(TypedDict):
"""A scheduled replication or sync task."""
name: str
source: str # source path / dataset
target: str # destination path / dataset (may be remote: host:path)
direction: str # "push" or "pull"
schedule: str # cron expression or human label ("daily", "hourly")
source: str # source path / dataset
target: str # destination path / dataset (may be remote: host:path)
direction: str # "push" or "pull"
schedule: str # cron expression or human label ("daily", "hourly")
enabled: bool
last_run: float # Unix epoch; 0.0 if never run
last_status: str # "success", "failed", "running", "pending"
bytes_sent: int # bytes transferred in the last run
last_run: float # Unix epoch; 0.0 if never run
last_status: str # "success", "failed", "running", "pending"
bytes_sent: int # bytes transferred in the last run
# ---------------------------------------------------------------------------
@@ -1013,7 +1013,7 @@ class UserDict(TypedDict):
gid: int
home: str
shell: str
groups: List[str] # all supplementary group names
groups: list[str] # all supplementary group names
class ProcessDict(TypedDict):
@@ -1022,30 +1022,30 @@ class ProcessDict(TypedDict):
pid: int
ppid: int
user: str
cpu: float # CPU utilisation 0.0–100.0
memory: float # RSS percentage of total RAM 0.0–100.0
vsz: int # virtual memory size in KiB
rss: int # resident set size in KiB
tty: str # controlling terminal; empty string if none
state: str # "R" running, "S" sleeping, "D" uninterruptible, "Z" zombie, etc.
started: str # start time string as reported by ``ps`` (e.g. "12:34" or "May28")
command: str # full command line
cpu: float # CPU utilisation 0.0–100.0
memory: float # RSS percentage of total RAM 0.0–100.0
vsz: int # virtual memory size in KiB
rss: int # resident set size in KiB
tty: str # controlling terminal; empty string if none
state: str # "R" running, "S" sleeping, "D" uninterruptible, "Z" zombie, etc.
started: str # start time string as reported by ``ps`` (e.g. "12:34" or "May28")
command: str # full command line
class CronJobDict(TypedDict):
"""A scheduled cron task."""
user: str # owner of the crontab entry
schedule: str # five-field cron expression, e.g. "0 * * * *"
command: str # shell command to execute
user: str # owner of the crontab entry
schedule: str # five-field cron expression, e.g. "0 * * * *"
command: str # shell command to execute
description: NotRequired[str] # optional inline comment
class ApplyUpdatesResultDict(TypedDict):
"""Result of an ``apply_updates()`` call."""
success: bool # True if the package manager exited without error
output: str # raw stdout/stderr captured from the package manager
success: bool # True if the package manager exited without error
output: str # raw stdout/stderr captured from the package manager
error: NotRequired[str] # error message if success is False
@@ -1056,24 +1056,24 @@ class ContainerEngineDict(TypedDict):
it is read and modelled above the driver, in netOrk (netork#765).
"""
engine: str # "docker"
api: str # "docker-engine": the Docker Engine API over ``system dial-stdio``
engine: str # "docker"
api: str # "docker-engine": the Docker Engine API over ``system dial-stdio``
class DeviceActionResultDict(TypedDict):
"""Return value of ``run_device_action()``."""
success: bool # True if the action completed without error
output: str # human-readable output or status message
success: bool # True if the action completed without error
output: str # human-readable output or status message
class SNMPConfigDict(TypedDict):
"""SNMP agent configuration detected on the device."""
running: bool # True if the SNMP daemon is active
community: str # read community string (e.g. "public")
port: int # listening port (default 161)
version: str # highest supported version: "1", "2c", or "3"
running: bool # True if the SNMP daemon is active
community: str # read community string (e.g. "public")
port: int # listening port (default 161)
version: str # highest supported version: "1", "2c", or "3"
# ---------------------------------------------------------------------------
@@ -1089,7 +1089,7 @@ class HealthMetricsIfaceDict(TypedDict):
tx_bytes: NotRequired[int]
rx_errors: NotRequired[int]
tx_errors: NotRequired[int]
tx_drops: NotRequired[int] # populated instead of tx_errors when the driver reports drops
tx_drops: NotRequired[int] # populated instead of tx_errors when the driver reports drops
speed_mbps: NotRequired[int]
@@ -1108,7 +1108,7 @@ class HealthMetricsDict(TypedDict):
load_1: NotRequired[float]
load_5: NotRequired[float]
load_15: NotRequired[float]
interfaces: NotRequired[Dict[str, HealthMetricsIfaceDict]]
interfaces: NotRequired[dict[str, HealthMetricsIfaceDict]]
LicenseStatus = Literal["active", "missing", "expired", "invalid", "none", "unknown"]
@@ -1124,13 +1124,13 @@ class LicenseDict(TypedDict):
name: str # what it licenses, as the device names it
status: LicenseStatus # "none": the device runs without one, as installed
edition: NotRequired[str] # the edition or level, e.g. "Free", "Enterprise Plus"
expires: NotRequired[Optional[str]] # ISO date, None for no expiry
expires: NotRequired[str | None] # ISO date, None for no expiry
detail: NotRequired[str] # the device's own words, e.g. why a feature is refused
# What the device withholds without it while the rest works, e.g. ["drives",
# "firmware inventory"] on a BMC that answers but wants a further license.
# Absent: the device says nothing of the kind, or withholds everything it
# would offer with it.
withholds: NotRequired[List[str]]
withholds: NotRequired[list[str]]
UpdateState = Literal["idle", "running", "done", "failed"]
@@ -1170,9 +1170,9 @@ class OspfInterfaceDict(TypedDict):
class OspfDict(TypedDict):
"""The device's OSPF state (``OspfMixin.get_ospf``)."""
router_id: Optional[str]
areas: List[str]
interfaces: Dict[str, OspfInterfaceDict] # by interface name
router_id: str | None
areas: list[str]
interfaces: dict[str, OspfInterfaceDict] # by interface name
class FirmwareOfferDict(TypedDict):
@@ -1189,10 +1189,10 @@ class HypervisorHostDict(TypedDict):
"""One host of a hypervisor or cluster (``HypervisorDriver.get_hypervisor_hosts``)."""
name: str
connected: Optional[bool] # reachable by its manager; None where it cannot tell
maintenance: Optional[bool] # in maintenance mode
config_issues: List[str] # the manager's configuration warnings, verbatim
managed_by: NotRequired[Optional[str]] # the address of a central manager, if any
connected: bool | None # reachable by its manager; None where it cannot tell
maintenance: bool | None # in maintenance mode
config_issues: list[str] # the manager's configuration warnings, verbatim
managed_by: NotRequired[str | None] # the address of a central manager, if any
class FixStepOutcomeDict(TypedDict):
@@ -1214,7 +1214,7 @@ class FixResultDict(TypedDict):
"""The result of a fix routine: every step that ran, up to the first failure."""
success: bool
steps: List[FixStepDict]
steps: list[FixStepDict]
output: str # the steps' output, one "[step] output" line each
@@ -1222,7 +1222,9 @@ class SnmpAgentSettingsDict(TypedDict):
"""What the caller wants an SNMP agent to be (``SnmpAgentMixin``)."""
community: str # the read-only v2c community
allowed_sources: List[str] # addresses or CIDRs let through to the agent; [] changes no firewall
allowed_sources: list[
str
] # addresses or CIDRs let through to the agent; [] changes no firewall
contact: NotRequired[str] # sysContact
location: NotRequired[str] # sysLocation
listen: NotRequired[str] # the address the agent binds to; absent: every address
@@ -1237,7 +1239,7 @@ class SnmpValueSpec(TypedDict, total=False):
with text.
"""
oids: List[str]
oids: list[str]
table: str
fold: Literal["first", "average", "max"]
pattern: str
@@ -1346,7 +1348,7 @@ class VMCpuTypeDict(TypedDict):
name: str # Model name, usable directly as create_vm_from_cloud_init(cpu_type=...)
description: str # One line on what the model is for, for a picker
features: List[str] # cpuinfo flags the guest is guaranteed to see
features: list[str] # cpuinfo flags the guest is guaranteed to see
available: bool # False when this node's CPU cannot run the model
default: bool # The model create_vm_from_cloud_init uses when cpu_type is None
@@ -1359,19 +1361,19 @@ class VMCpuTypeDict(TypedDict):
class PingSweepEntryDict(TypedDict):
"""Outcome of a single ``ping`` inside a sweep (see ``PingSweepMixin``)."""
ip: str # destination that was probed
alive: bool # True if at least one probe was answered
rtt_ms: Optional[float] # average round-trip time in ms; None if unreachable
ip: str # destination that was probed
alive: bool # True if at least one probe was answered
rtt_ms: float | None # average round-trip time in ms; None if unreachable
error: NotRequired[str] # driver/transport error for this destination
class PingSweepResultDict(TypedDict):
"""Result of a ``ping_sweep()`` call."""
entries: List[PingSweepEntryDict] # one entry per probed destination, in input order
scanned: int # destinations actually probed
alive_count: int # entries with alive=True
truncated: bool # True if targets were dropped at the sweep cap
entries: list[PingSweepEntryDict] # one entry per probed destination, in input order
scanned: int # destinations actually probed
alive_count: int # entries with alive=True
truncated: bool # True if targets were dropped at the sweep cap
# ---------------------------------------------------------------------------
@@ -1387,29 +1389,29 @@ PowerAction = Literal[
"ForceRestart",
"PowerCycle",
]
POWER_ACTIONS: Tuple[str, ...] = get_args(PowerAction)
POWER_ACTIONS: tuple[str, ...] = get_args(PowerAction)
class PowerStateDict(TypedDict):
"""The managed server's power, as its BMC reports it."""
state: str # "On", "Off", "PoweringOn" or "PoweringOff"
actions: List[str] # the PowerAction values this BMC accepts now
state: str # "On", "Off", "PoweringOn" or "PoweringOff"
actions: list[str] # the PowerAction values this BMC accepts now
class BmcCpuDict(TypedDict):
socket: str # "CPU1", "Proc 1"
model: str # "Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz"
cores: Optional[int]
threads: Optional[int]
status: str # "OK", "Warning", "Critical", "Absent"
socket: str # "CPU1", "Proc 1"
model: str # "Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz"
cores: int | None
threads: int | None
status: str # "OK", "Warning", "Critical", "Absent"
class BmcDimmDict(TypedDict):
slot: str # "DIMMA1", "PROC 1 DIMM 3"
size_bytes: Optional[int]
type: str # "DDR4", "DDR5"; empty when unknown
speed_mts: Optional[int] # MT/s
slot: str # "DIMMA1", "PROC 1 DIMM 3"
size_bytes: int | None
type: str # "DDR4", "DDR5"; empty when unknown
speed_mts: int | None # MT/s
part_number: str
serial_number: str
status: str
@@ -1419,9 +1421,9 @@ class BmcDriveDict(TypedDict):
slot: str
model: str
serial_number: str
size_bytes: Optional[int]
media: str # "HDD", "SSD"; empty when unknown
protocol: str # "SATA", "SAS", "NVMe"; empty when unknown
size_bytes: int | None
media: str # "HDD", "SSD"; empty when unknown
protocol: str # "SATA", "SAS", "NVMe"; empty when unknown
status: str
@@ -1429,17 +1431,17 @@ class BmcPsuDict(TypedDict):
slot: str
model: str
serial_number: str
capacity_watts: Optional[int]
capacity_watts: int | None
status: str
class BmcNicDict(TypedDict):
"""A network port of the managed server, not of the BMC."""
name: str # "NIC.Integrated.1-1", "Port 1"
mac_address: str # lowercase, colon-separated; empty when unknown
speed_mbps: Optional[int]
link_up: Optional[bool]
name: str # "NIC.Integrated.1-1", "Port 1"
mac_address: str # lowercase, colon-separated; empty when unknown
speed_mbps: int | None
link_up: bool | None
class HardwareInventoryDict(TypedDict):
@@ -1447,35 +1449,35 @@ class HardwareInventoryDict(TypedDict):
manufacturer: str
model: str
serial_number: str # the server's, not the BMC's
system_uuid: str # SMBIOS system UUID, lowercase
cpus: List[BmcCpuDict]
memory: List[BmcDimmDict]
drives: List[BmcDriveDict]
power_supplies: List[BmcPsuDict]
nics: List[BmcNicDict]
serial_number: str # the server's, not the BMC's
system_uuid: str # SMBIOS system UUID, lowercase
cpus: list[BmcCpuDict]
memory: list[BmcDimmDict]
drives: list[BmcDriveDict]
power_supplies: list[BmcPsuDict]
nics: list[BmcNicDict]
class EventLogEntryDict(TypedDict):
"""One entry of the BMC's system event log (SEL / Lifecycle / IML)."""
id: str
timestamp: Optional[float] # Unix time; None when the BMC gave none
severity: str # "OK", "Warning" or "Critical"
timestamp: float | None # Unix time; None when the BMC gave none
severity: str # "OK", "Warning" or "Critical"
message: str
class FirmwareDict(TypedDict):
"""One firmware component: the BMC's own, BIOS, a controller, a NIC."""
name: str # "BMC", "BIOS", "PERC H755 Front"
name: str # "BMC", "BIOS", "PERC H755 Front"
version: str
updateable: Optional[bool]
updateable: bool | None
class ProtocolStateDict(TypedDict):
enabled: bool
port: Optional[int] # None for a protocol without a port of its own
port: int | None # None for a protocol without a port of its own
class ManagementProtocolsDict(TypedDict):
@@ -1495,6 +1497,6 @@ class SystemIdentityDict(TypedDict):
Each value is empty when unknown or a vendor placeholder."""
system_uuid: str # lowercase
system_uuid: str # lowercase
product_serial: str
board_serial: str
+4 -5
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Address translation and VPN tunnels.
A home gateway does a subset of what a firewall does, and these readers are
@@ -11,7 +10,7 @@ this class in can never shadow a working implementation from a sibling base.
from __future__ import annotations
from typing import Dict, List, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.models import NATTranslationDict, PortForwardDict, VPNTunnelDict
@@ -19,7 +18,7 @@ from napalm_device_types.models import NATTranslationDict, PortForwardDict, VPNT
class NatVpnMixin:
if TYPE_CHECKING:
def get_nat_translations(self) -> List[NATTranslationDict]:
def get_nat_translations(self) -> list[NATTranslationDict]:
"""
Returns a list of active NAT translation entries.
@@ -47,7 +46,7 @@ class NatVpnMixin:
"""
...
def get_port_forwards(self) -> List[PortForwardDict]:
def get_port_forwards(self) -> list[PortForwardDict]:
"""
Returns the port forwards that let traffic in from the WAN.
@@ -87,7 +86,7 @@ class NatVpnMixin:
"""
...
def get_vpn_tunnels(self) -> Dict[str, VPNTunnelDict]:
def get_vpn_tunnels(self) -> dict[str, VPNTunnelDict]:
"""
Returns the status of VPN tunnels.
+12 -15
View File
@@ -10,11 +10,9 @@ Usage::
...
"""
from typing import List, Optional, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.base import DeviceTypeDriver
from napalm_device_types.updates import UpdateMixin
from napalm_device_types.services import ServiceControlMixin
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.models import (
CronJobDict,
@@ -23,9 +21,14 @@ from napalm_device_types.models import (
SNMPConfigDict,
UserDict,
)
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.services import ServiceControlMixin
from napalm_device_types.updates import UpdateMixin
class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthMetricsMixin, DeviceTypeDriver):
class OSDriver(
UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthMetricsMixin, DeviceTypeDriver
):
"""
Abstract intermediate driver for general-purpose operating systems
(e.g. Linux, BSD, macOS).
@@ -44,26 +47,20 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM
# Interfaces matching this pattern are excluded from health-metric collection.
# Set True for drivers where the out-error counter actually reports drops (e.g. OPNsense).
# ------------------------------------------------------------------
# Package management
# ------------------------------------------------------------------
if TYPE_CHECKING:
# ------------------------------------------------------------------
# Service management
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# Users
# ------------------------------------------------------------------
def get_os_users(self) -> List[UserDict]:
def get_os_users(self) -> list[UserDict]:
"""
Returns a list of local OS user accounts.
@@ -95,7 +92,7 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM
# Processes
# ------------------------------------------------------------------
def get_processes(self) -> List[ProcessDict]:
def get_processes(self) -> list[ProcessDict]:
"""
Returns a snapshot of currently running processes.
@@ -138,7 +135,7 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM
# Cron jobs
# ------------------------------------------------------------------
def get_cron_jobs(self) -> List[CronJobDict]:
def get_cron_jobs(self) -> list[CronJobDict]:
"""
Returns scheduled cron tasks from all user crontabs and ``/etc/cron.d``.
@@ -166,7 +163,7 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM
# SNMP
# ------------------------------------------------------------------
def get_snmp_config(self) -> Optional[SNMPConfigDict]:
def get_snmp_config(self) -> SNMPConfigDict | None:
"""
Returns the SNMP agent configuration currently active on the device,
or ``None`` if no SNMP daemon is running or detectable.
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""A device's OSPF state, in neutral terms.
The routing daemon's own output (FRR, Quagga, a vendor's CLI) is the driver's
+6 -8
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Pending package updates: which package, from where, and whether it is a security fix.
A patch deadline -- "security updates within 14 days" -- needs to know which
@@ -16,7 +15,6 @@ security -- netOrk's CVE matching is what catches those.
from __future__ import annotations
import re
from typing import Dict, List, Set
from napalm_device_types.models import UpdateDict
from napalm_device_types.terminal import strip_terminal_codes
@@ -42,10 +40,10 @@ _APT_STATUS = re.compile(r"^__APT_RC=(\d+)\s*$", re.MULTILINE)
_DNF_STATUS = re.compile(r"^__DNF_RC=(\d+)\s*$", re.MULTILINE)
def _joined_lines(output: str) -> List[str]:
def _joined_lines(output: str) -> list[str]:
"""Lines as apt printed them: a terminal wraps long ones, and the
continuation starts with a space."""
lines: List[str] = []
lines: list[str] = []
for line in output.splitlines():
if line.startswith(" ") and lines:
lines[-1] += line[1:].rstrip()
@@ -67,7 +65,7 @@ def _listing(output: str, status: re.Pattern[str], what: str) -> str:
return status.sub("", text)
def parse_apt_upgradable(output: str) -> List[UpdateDict]:
def parse_apt_upgradable(output: str) -> list[UpdateDict]:
"""Parse :data:`APT_UPGRADABLE_COMMAND`'s output, one entry per package.
A package listed for several architectures (``libc6`` for amd64 and i386)
@@ -75,7 +73,7 @@ def parse_apt_upgradable(output: str) -> List[UpdateDict]:
:raises ValueError: when apt failed or its exit status never arrived.
"""
by_name: Dict[str, UpdateDict] = {}
by_name: dict[str, UpdateDict] = {}
for line in _joined_lines(_listing(output, _APT_STATUS, "apt list --upgradable")):
match = _APT_LINE.match(line)
if not match:
@@ -103,7 +101,7 @@ def nevra_name(nevra: str) -> str:
return without_arch.rsplit("-", 2)[0]
def parse_dnf_security(output: str) -> Set[str]:
def parse_dnf_security(output: str) -> set[str]:
"""The names of the packages a pending security advisory covers.
Parses :data:`DNF_SECURITY_COMMAND`'s ``ADVISORY SEVERITY/Sec. NEVRA`` lines.
@@ -111,7 +109,7 @@ def parse_dnf_security(output: str) -> Set[str]:
:raises ValueError: when dnf failed or its exit status never arrived -- every
update would otherwise read as not security.
"""
names: Set[str] = set()
names: set[str] = set()
for line in _listing(output, _DNF_STATUS, "dnf updateinfo list --security").splitlines():
parts = line.split()
if len(parts) >= 3 and parts[1].endswith("/Sec."):
+4 -5
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Listing and managing installed software.
Identical on every device type that has a package manager -- this was five
@@ -12,7 +11,7 @@ this class in can never shadow a working implementation from a sibling base.
from __future__ import annotations
from typing import Any, Dict, List, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from napalm_device_types.models import PackageDict
@@ -20,7 +19,7 @@ from napalm_device_types.models import PackageDict
class PackageManagementMixin:
if TYPE_CHECKING:
def get_packages(self) -> List[PackageDict]:
def get_packages(self) -> list[PackageDict]:
"""
Returns all packages currently known to the device's package manager
(e.g. ``opkg`` on OpenWrt, ``apk`` on Alpine-based APs).
@@ -97,7 +96,7 @@ class PackageManagementMixin:
"""
...
def get_package_config(self, name: str) -> Dict[str, Any]:
def get_package_config(self, name: str) -> dict[str, Any]:
"""
Returns the current configuration of an installed package as a
dictionary. The structure is package-specific.
@@ -123,7 +122,7 @@ class PackageManagementMixin:
"""
...
def set_package_config(self, name: str, config: Dict[str, Any]) -> None:
def set_package_config(self, name: str, config: dict[str, Any]) -> None:
"""
Writes a new configuration for an installed package.
+3 -4
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""
Abstract base class for IP telephony endpoints.
@@ -10,7 +9,7 @@ Usage::
...
"""
from typing import TYPE_CHECKING, Any, Dict, List
from typing import TYPE_CHECKING, Any
from napalm_device_types.base import DeviceTypeDriver
@@ -36,7 +35,7 @@ class PhoneDriver(DeviceTypeDriver):
if TYPE_CHECKING:
def get_sip_accounts(self) -> List[Dict[str, Any]]:
def get_sip_accounts(self) -> list[dict[str, Any]]:
"""
Returns the SIP registrations configured on the phone.
@@ -60,7 +59,7 @@ class PhoneDriver(DeviceTypeDriver):
"""
...
def get_call_status(self) -> Dict[str, Any]:
def get_call_status(self) -> dict[str, Any]:
"""
Returns what the phone is doing right now.
+15 -10
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Generic ICMP sweep on top of the NAPALM-standard ``ping()``.
Sweeping a range of addresses is orchestration, not device mechanics: the
@@ -21,7 +20,8 @@ target list (see ``PING_SWEEP_MAX_TARGETS``).
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Dict, Iterable, List, Optional
from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING, Any, ClassVar
from napalm.base import NetworkDriver
@@ -60,7 +60,7 @@ class PingSweepMixin:
#: ``False`` opts a driver out of ping sweeps even though it implements
#: ``ping()``. ``None`` (the default) means "decide by introspection".
SUPPORTS_PING: ClassVar[Optional[bool]] = None
SUPPORTS_PING: ClassVar[bool | None] = None
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
@@ -73,7 +73,7 @@ class PingSweepMixin:
size: int = 100,
count: int = 5,
vrf: str = "",
) -> Dict[str, Any]: ...
) -> dict[str, Any]: ...
@classmethod
def supports_ping(cls) -> bool:
@@ -86,9 +86,9 @@ class PingSweepMixin:
*,
count: int = 1,
timeout: int = 1,
max_targets: Optional[int] = None,
on_progress: Optional[Callable[[int, int], None]] = None,
should_stop: Optional[Callable[[], bool]] = None,
max_targets: int | None = None,
on_progress: Callable[[int, int], None] | None = None,
should_stop: Callable[[], bool] | None = None,
) -> PingSweepResultDict:
"""Ping every address in *destinations* and report who answered.
@@ -115,7 +115,7 @@ class PingSweepMixin:
targets = targets[:limit]
total = len(targets)
entries: List[PingSweepEntryDict] = []
entries: list[PingSweepEntryDict] = []
for done, destination in enumerate(targets, start=1):
if should_stop is not None and should_stop():
break
@@ -151,7 +151,12 @@ class PingSweepMixin:
success = reply.get("success") or {}
if not isinstance(success, dict):
return {"ip": destination, "alive": False, "rtt_ms": None, "error": "malformed ping reply"}
return {
"ip": destination,
"alive": False,
"rtt_ms": None,
"error": "malformed ping reply",
}
probes_sent = _as_int(success.get("probes_sent"))
packet_loss = _as_int(success.get("packet_loss"), default=probes_sent)
alive = bool(success.get("results")) or probes_sent > packet_loss
@@ -167,7 +172,7 @@ def _as_int(value: Any, default: int = 0) -> int:
return default
def _as_float(value: Any) -> Optional[float]:
def _as_float(value: Any) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
+8 -11
View File
@@ -19,11 +19,11 @@ Usage::
...
"""
from typing import Dict, List, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.base import DeviceTypeDriver
from napalm_device_types.nat_vpn import NatVpnMixin
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.dhcp import DhcpServerMixin
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.models import (
HostDict,
RadioStatusDict,
@@ -31,6 +31,7 @@ from napalm_device_types.models import (
WANStatusDict,
WirelessClientDict,
)
from napalm_device_types.nat_vpn import NatVpnMixin
class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin, DeviceTypeDriver):
@@ -48,8 +49,6 @@ class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin,
ROLE: str = "residential_gateway"
TYPE_LABEL: str = "Gateway"
if TYPE_CHECKING:
def get_wan_status(self) -> WANStatusDict:
@@ -85,7 +84,7 @@ class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin,
"""
...
def get_hosts(self) -> List[HostDict]:
def get_hosts(self) -> list[HostDict]:
"""
Returns the list of hosts known to the gateway (LAN clients).
@@ -113,9 +112,7 @@ class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin,
"""
...
def get_wireless_clients(self) -> List[WirelessClientDict]:
def get_wireless_clients(self) -> list[WirelessClientDict]:
"""
Returns the list of wireless clients currently associated with the
device's built-in access point(s).
@@ -125,7 +122,7 @@ class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin,
"""
...
def get_ssids(self) -> Dict[str, SSIDDict]:
def get_ssids(self) -> dict[str, SSIDDict]:
"""
Returns the configured wireless networks (SSIDs).
@@ -134,7 +131,7 @@ class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin,
"""
...
def get_radio_status(self) -> Dict[str, RadioStatusDict]:
def get_radio_status(self) -> dict[str, RadioStatusDict]:
"""
Returns the status of the device's wireless radios.
+4 -5
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Which roles a driver class fills, and which of them is primary.
A *role* is what a device is: a firewall, a switch, a NAS. Real devices are
@@ -22,7 +21,7 @@ when a concrete driver provided it.
from __future__ import annotations
from typing import Any, List, Optional, Type
from typing import Any
def _is_role_base(cls: type) -> bool:
@@ -36,7 +35,7 @@ def _is_role_base(cls: type) -> bool:
return isinstance(role, str) and bool(role)
def roles_of(driver_cls: type) -> List[Type[Any]]:
def roles_of(driver_cls: type) -> list[type[Any]]:
"""Every role base in ``driver_cls``'s MRO, most significant first.
MRO order is inheritance order, so the list reflects exactly what the driver
@@ -49,12 +48,12 @@ def roles_of(driver_cls: type) -> List[Type[Any]]:
return [cls for cls in roles if not any(o is not cls and issubclass(o, cls) for o in roles)]
def role_keys_of(driver_cls: type) -> List[str]:
def role_keys_of(driver_cls: type) -> list[str]:
"""The stable string keys of :func:`roles_of`, e.g. ``["storage", "linux"]``."""
return [vars(cls)["ROLE"] for cls in roles_of(driver_cls)]
def primary_role_of(driver_cls: type) -> Optional[str]:
def primary_role_of(driver_cls: type) -> str | None:
"""The driver's headline role, or ``None`` when it declares no role at all.
This is the value netOrk exposes as ``device_class``.
+55 -45
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Service clusters: the configuration that says which services run together.
Two hosts that answer at one virtual IP may run the same service by accident.
@@ -47,8 +46,9 @@ from __future__ import annotations
import ipaddress
import json
import re
from collections.abc import Callable
from shlex import quote
from typing import Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
from typing import TYPE_CHECKING
from urllib.parse import urlsplit
from napalm_device_types.models import (
@@ -119,7 +119,7 @@ _PATRONI_SH = (
'if [ -n "$r" ]; then '
'b=$(curl -s -k -m 3 "$s://$r/cluster" 2>/dev/null || '
'wget -q -T 3 --no-check-certificate -O - "$s://$r/cluster" 2>/dev/null); '
"if [ -n \"$b\" ]; then echo \"rest $(echo \"$b\" | tr -d '\\n')\"; "
'if [ -n "$b" ]; then echo "rest $(echo "$b" | tr -d \'\\n\')"; '
"else echo rest-unreachable; fi; fi; "
'elif [ -e "$f" ]; then echo "unreadable $f"; '
'else echo "missing $f"; fi; '
@@ -128,7 +128,7 @@ _PATRONI_SH = (
)
def _docker_sh(binary: Optional[str]) -> str:
def _docker_sh(binary: str | None) -> str:
"""The containers part: running containers of the clustered images, their
allowlisted environment. ``[no-docker]`` for a host without the engine."""
if not binary:
@@ -138,17 +138,17 @@ def _docker_sh(binary: Optional[str]) -> str:
'if command -v "$D" >/dev/null 2>&1; then '
"if c=$(\"$D\" ps --format '{{.Names}}' 2>/dev/null); then echo '[docker]'; "
"for n in $c; do "
"i=$(\"$D\" inspect --format '{{.Config.Image}}' \"$n\" 2>/dev/null); "
'i=$("$D" inspect --format \'{{.Config.Image}}\' "$n" 2>/dev/null); '
'case "$i" in *adguardhome-sync*|*bunkerweb*) '
'echo "[container $n]"; echo "image $i"; '
"\"$D\" inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"$n\" "
'"$D" inspect --format \'{{range .Config.Env}}{{println .}}{{end}}\' "$n" '
f"2>/dev/null | awk {quote(_ENV_AWK)};; esac; done; "
"else echo '[docker-unreadable]'; fi; "
"else echo '[no-docker]'; fi; "
)
def service_clusters_command(docker: Optional[str] = "docker") -> str:
def service_clusters_command(docker: str | None = "docker") -> str:
"""One line of POSIX ``sh``, read-only. *docker* is the engine's binary as the
driver reaches it (QNAP's lives elsewhere), None for a host without one. The
frame markers are printed in two halves so that a transport which echoes the
@@ -165,11 +165,11 @@ def service_clusters_command(docker: Optional[str] = "docker") -> str:
SERVICE_CLUSTERS_COMMAND = service_clusters_command()
_COMMENT_RE = re.compile(r"\s+#.*$")
_Record = Tuple[str, str]
_Section = Tuple[str, str, List[_Record]]
_Record = tuple[str, str]
_Section = tuple[str, str, list[_Record]]
def _frame(output: str) -> List[str]:
def _frame(output: str) -> list[str]:
lines = [line.strip() for line in strip_terminal_codes(output).splitlines()]
try:
start = lines.index(_BEGIN)
@@ -179,9 +179,9 @@ def _frame(output: str) -> List[str]:
return lines[start + 1 : end]
def _sections(lines: List[str]) -> List[_Section]:
def _sections(lines: list[str]) -> list[_Section]:
"""``[<kind> <name>]`` headers, each with its records as ``(kind, rest)``."""
sections: List[_Section] = []
sections: list[_Section] = []
for line in lines:
if line.startswith("[") and line.endswith("]"):
kind, _, name = line[1:-1].partition(" ")
@@ -202,13 +202,13 @@ def _clean(value: str) -> str:
def _member(
*,
address: Optional[str] = None,
port: Optional[int] = None,
name: Optional[str] = None,
role: Optional[str] = None,
state: Optional[str] = None,
address: str | None = None,
port: int | None = None,
name: str | None = None,
role: str | None = None,
state: str | None = None,
this_host: bool = False,
config_from: Optional[int] = None,
config_from: int | None = None,
) -> ServiceClusterMemberDict:
return {
"name": name,
@@ -225,9 +225,9 @@ def _cluster(
mechanism: str,
service: str,
source: str,
cluster: Optional[str],
members: List[ServiceClusterMemberDict],
running: Optional[bool] = True,
cluster: str | None,
members: list[ServiceClusterMemberDict],
running: bool | None = True,
) -> ServiceClusterDict:
return {
"mechanism": mechanism,
@@ -239,7 +239,7 @@ def _cluster(
}
def _host_port(text: str) -> Tuple[Optional[str], Optional[int]]:
def _host_port(text: str) -> tuple[str | None, int | None]:
"""``"10.0.0.5:8008"`` or ``"[fd00::5]:8008"`` -> host and port."""
try:
split = urlsplit(f"//{text.strip()}")
@@ -248,7 +248,7 @@ def _host_port(text: str) -> Tuple[Optional[str], Optional[int]]:
return None, None
def _url(text: str) -> Tuple[Optional[str], Optional[int]]:
def _url(text: str) -> tuple[str | None, int | None]:
"""A URL's host and port, the scheme's default where it names none."""
value = text.strip()
if "://" not in value:
@@ -264,7 +264,7 @@ def _url(text: str) -> Tuple[Optional[str], Optional[int]]:
# --- Patroni ------------------------------------------------------------------
def _patroni_members(rest: str, own: Optional[str]) -> Optional[List[ServiceClusterMemberDict]]:
def _patroni_members(rest: str, own: str | None) -> list[ServiceClusterMemberDict] | None:
try:
members = json.loads(rest).get("members")
except (ValueError, AttributeError):
@@ -289,9 +289,9 @@ def _patroni_members(rest: str, own: Optional[str]) -> Optional[List[ServiceClus
return sorted(found, key=lambda m: m["name"] or "")
def _patroni(unit: str, records: List[_Record]) -> List[ServiceClusterDict]:
values: Dict[str, str] = {}
rest: Optional[str] = None
def _patroni(unit: str, records: list[_Record]) -> list[ServiceClusterDict]:
values: dict[str, str] = {}
rest: str | None = None
for kind, text in records:
if kind in ("active", "scope", "name", "restapi") and kind not in values:
values[kind] = _clean(text)
@@ -313,8 +313,8 @@ def _patroni(unit: str, records: List[_Record]) -> List[ServiceClusterDict]:
# --- Containers ----------------------------------------------------------------
def _env(records: List[_Record]) -> Dict[str, str]:
env: Dict[str, str] = {}
def _env(records: list[_Record]) -> dict[str, str]:
env: dict[str, str] = {}
for kind, text in records:
if kind == "env":
key, _, value = text.partition(" ")
@@ -322,13 +322,13 @@ def _env(records: List[_Record]) -> Dict[str, str]:
return env
def _image_name(records: List[_Record]) -> str:
def _image_name(records: list[_Record]) -> str:
"""``bunkerity/bunkerweb@sha256:...`` -> ``bunkerweb``."""
image = next((text for kind, text in records if kind == "image"), "")
return image.split("@")[0].rsplit("/", 1)[-1].split(":")[0]
def _adguardhome_sync(name: str, records: List[_Record]) -> Optional[List[ServiceClusterDict]]:
def _adguardhome_sync(name: str, records: list[_Record]) -> list[ServiceClusterDict] | None:
env = _env(records)
origin = _url(env["ORIGIN_URL"]) if env.get("ORIGIN_URL") else (None, None)
if origin[0] is None:
@@ -346,7 +346,7 @@ def _adguardhome_sync(name: str, records: List[_Record]) -> Optional[List[Servic
return [_cluster("adguardhome-sync", "adguardhome", name, cluster, members)]
def _peer_address(entry: str) -> Optional[str]:
def _peer_address(entry: str) -> str | None:
"""An entry of a whitelist that names one peer: a single address that is no
loopback, link-local or unspecified one -- not a network."""
text = entry.strip()
@@ -367,12 +367,12 @@ def _peer_address(entry: str) -> Optional[str]:
return str(address)
def _api_port(env: Dict[str, str]) -> int:
def _api_port(env: dict[str, str]) -> int:
port = env.get("API_HTTP_PORT", "")
return int(port) if port.isdigit() else _BUNKERWEB_API_PORT
def _bunkerweb_worker(name: str, env: Dict[str, str]) -> List[ServiceClusterDict]:
def _bunkerweb_worker(name: str, env: dict[str, str]) -> list[ServiceClusterDict]:
managers = [
address
for entry in re.split(r"[\s,]+", env.get("API_WHITELIST_IP", ""))
@@ -385,7 +385,7 @@ def _bunkerweb_worker(name: str, env: Dict[str, str]) -> List[ServiceClusterDict
return [_cluster("bunkerweb", "bunkerweb", name, managers[0], members)]
def _bunkerweb_manager(name: str, env: Dict[str, str]) -> List[ServiceClusterDict]:
def _bunkerweb_manager(name: str, env: dict[str, str]) -> list[ServiceClusterDict]:
instances = [entry for entry in env.get("BUNKERWEB_INSTANCES", "").split() if entry]
if not instances:
return []
@@ -394,12 +394,14 @@ def _bunkerweb_manager(name: str, env: Dict[str, str]) -> List[ServiceClusterDic
host, port = _host_port(entry)
if host:
members.append(
_member(address=host, port=port or _BUNKERWEB_API_PORT, role="worker", config_from=0)
_member(
address=host, port=port or _BUNKERWEB_API_PORT, role="worker", config_from=0
)
)
return [_cluster("bunkerweb", "bunkerweb", name, None, members)] if len(members) > 1 else []
def _bunkerweb(name: str, records: List[_Record]) -> List[ServiceClusterDict]:
def _bunkerweb(name: str, records: list[_Record]) -> list[ServiceClusterDict]:
image = _image_name(records)
if image == "bunkerweb":
return _bunkerweb_worker(name, _env(records))
@@ -408,13 +410,13 @@ def _bunkerweb(name: str, records: List[_Record]) -> List[ServiceClusterDict]:
return [] # the UI, the API, autoconf: no instance and no manager
_CONTAINER_READERS: Dict[str, Callable[[str, List[_Record]], Optional[List[ServiceClusterDict]]]] = {
_CONTAINER_READERS: dict[str, Callable[[str, list[_Record]], list[ServiceClusterDict] | None]] = {
"adguardhome_sync": _adguardhome_sync,
"bunkerweb": _bunkerweb,
}
def _reader_for(records: List[_Record]) -> Optional[str]:
def _reader_for(records: list[_Record]) -> str | None:
image = next((text for kind, text in records if kind == "image"), "")
if "adguardhome-sync" in image:
return "adguardhome_sync"
@@ -423,13 +425,13 @@ def _reader_for(records: List[_Record]) -> Optional[str]:
return None
def _containers(sections: List[_Section]) -> Dict[str, Optional[List[ServiceClusterDict]]]:
def _containers(sections: list[_Section]) -> dict[str, list[ServiceClusterDict] | None]:
kinds = {kind for kind, _, _ in sections}
if "no-docker" in kinds:
return {"adguardhome_sync": [], "bunkerweb": []}
if "docker" not in kinds:
return {"adguardhome_sync": None, "bunkerweb": None}
found: Dict[str, Optional[List[ServiceClusterDict]]] = {"adguardhome_sync": [], "bunkerweb": []}
found: dict[str, list[ServiceClusterDict] | None] = {"adguardhome_sync": [], "bunkerweb": []}
for kind, name, records in sections:
key = _reader_for(records) if kind == "container" else None
if key is None or found[key] is None:
@@ -448,14 +450,20 @@ def parse_service_clusters(output: str) -> ServiceClustersDict:
:raises ValueError: when the output carries no intact report.
"""
sections = _sections(_frame(output))
patroni: Optional[List[ServiceClusterDict]]
patroni: list[ServiceClusterDict] | None
if any(kind == "no-systemd" for kind, _, _ in sections):
patroni = None
elif any(kind == "patroni" and any(r[0] == "unreadable" for r in records) for kind, _, records in sections):
elif any(
kind == "patroni" and any(r[0] == "unreadable" for r in records)
for kind, _, records in sections
):
patroni = None
else:
patroni = [
c for kind, unit, records in sections if kind == "patroni" for c in _patroni(unit, records)
c
for kind, unit, records in sections
if kind == "patroni"
for c in _patroni(unit, records)
]
containers = _containers(sections)
return {
@@ -522,7 +530,9 @@ class ServiceClustersMixin:
"""
command = f"sh -c {quote(service_clusters_command(self._service_clusters_docker()))}"
try:
return parse_service_clusters(self._run_service_clusters_command(command, privileged=True))
return parse_service_clusters(
self._run_service_clusters_command(command, privileged=True)
)
except ValueError:
pass
return parse_service_clusters(self._run_service_clusters_command(command, privileged=False))
+13 -14
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Which installed packages a systemd service is made of.
A vulnerability is found on a *package*; a statement about a *service* -- the
@@ -26,7 +25,7 @@ nothing.
from __future__ import annotations
import re
from typing import Dict, List, Optional, Set, Tuple, TYPE_CHECKING
from typing import TYPE_CHECKING
_BEGIN = "SVCPKG_BEGIN"
_END = "SVCPKG_END"
@@ -61,7 +60,7 @@ SERVICE_PACKAGES_COMMAND = (
)
def _report(output: str) -> Dict[str, List[str]]:
def _report(output: str) -> dict[str, list[str]]:
lines = [line.rstrip() for line in output.splitlines()]
stripped = [line.strip() for line in lines]
try:
@@ -69,8 +68,8 @@ def _report(output: str) -> Dict[str, List[str]]:
end = stripped.index(_END, start)
except ValueError:
raise ValueError("no intact service package report in the output") from None
sections: Dict[str, List[str]] = {}
current: Optional[List[str]] = None
sections: dict[str, list[str]] = {}
current: list[str] | None = None
for line in lines[start + 1 : end]:
if line in ("[units]", "[dpkg]", "[rpm]"):
current = sections.setdefault(line[1:-1], [])
@@ -79,11 +78,11 @@ def _report(output: str) -> Dict[str, List[str]]:
return sections
def _units(lines: List[str]) -> Dict[str, Set[str]]:
def _units(lines: list[str]) -> dict[str, set[str]]:
"""``systemctl show`` blocks -> ``{unit: its unit file and binary}``."""
units: Dict[str, Set[str]] = {}
units: dict[str, set[str]] = {}
unit = ""
paths: Set[str] = set()
paths: set[str] = set()
for line in [*lines, ""]:
key, _, value = line.partition("=")
if not line.strip():
@@ -101,7 +100,7 @@ def _units(lines: List[str]) -> Dict[str, Set[str]]:
return units
def _names(raw: str) -> Set[str]:
def _names(raw: str) -> set[str]:
"""``libfoo1:amd64, foo-bin`` -> ``{"libfoo1", "foo-bin"}``; nothing else counts."""
found = set()
for name in raw.replace(",", " ").split():
@@ -111,9 +110,9 @@ def _names(raw: str) -> Set[str]:
return found
def _owners(sections: Dict[str, List[str]]) -> Dict[str, Set[str]]:
def _owners(sections: dict[str, list[str]]) -> dict[str, set[str]]:
"""``{path: the packages owning it}`` from dpkg's or rpm's answer."""
owners: Dict[str, Set[str]] = {}
owners: dict[str, set[str]] = {}
for line in sections.get("dpkg", []):
# "pkg[, pkg2][:arch]: /path"; "diversion by x from: /path" is no owner.
if line.startswith("diversion by") or ": /" not in line:
@@ -127,11 +126,11 @@ def _owners(sections: Dict[str, List[str]]) -> Dict[str, Set[str]]:
return owners
def _across_usr_merge(path: str) -> Tuple[str, str]:
def _across_usr_merge(path: str) -> tuple[str, str]:
return (path, path[4:] if path.startswith("/usr/") else "/usr" + path)
def parse_service_packages(output: str) -> Dict[str, List[str]]:
def parse_service_packages(output: str) -> dict[str, list[str]]:
"""Parse what :data:`SERVICE_PACKAGES_COMMAND` printed.
``{unit name without ".service": its packages, sorted}``. A unit no package
@@ -163,7 +162,7 @@ class ServicePackagesMixin:
"""Run *command* on the host with ``sh`` and return what it printed."""
...
def get_service_packages(self) -> Dict[str, List[str]]:
def get_service_packages(self) -> dict[str, list[str]]:
"""
Returns, per systemd service, the installed packages it is made of.
+3 -4
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Init-system services: listing them and starting/stopping them.
Deliberately *not* the same thing as a NAS's exported shares, which live on
@@ -13,7 +12,7 @@ this class in can never shadow a working implementation from a sibling base.
from __future__ import annotations
from typing import Any, Dict, List, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from napalm_device_types.models import ServiceDict
@@ -21,7 +20,7 @@ from napalm_device_types.models import ServiceDict
class ServiceControlMixin:
if TYPE_CHECKING:
def get_services(self) -> List[ServiceDict]:
def get_services(self) -> list[ServiceDict]:
"""
Returns the list of system services known to the device's init system.
@@ -42,7 +41,7 @@ class ServiceControlMixin:
"""
...
def manage_service(self, name: str, action: str) -> Dict[str, Any]:
def manage_service(self, name: str, action: str) -> dict[str, Any]:
"""
Execute a lifecycle action on a named service.
+13 -13
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""SSH host keys: trust on first use, and a changed key refused before any login.
A driver that accepts whatever host key answers at a device's address logs in
@@ -41,9 +40,10 @@ import hashlib
import logging
import os
import tempfile
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Any, Dict, Iterator, List, Mapping, Optional, Set
from typing import Any
from napalm.base.exceptions import ConnectionException
@@ -74,11 +74,11 @@ class SshHostKey:
digest = hashlib.sha256(base64.b64decode(self.key)).digest()
return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=")
def as_dict(self) -> Dict[str, str]:
def as_dict(self) -> dict[str, str]:
return {"key_type": self.key_type, "key": self.key, "fingerprint": self.fingerprint}
@classmethod
def from_value(cls, value: Any) -> Optional["SshHostKey"]:
def from_value(cls, value: Any) -> SshHostKey | None:
"""The key in *value* (a mapping with ``key_type`` and ``key``), or None for none."""
if not value:
return None
@@ -87,7 +87,7 @@ class SshHostKey:
return cls(str(value["key_type"]), str(value["key"]))
@classmethod
def of(cls, pkey: Any) -> "SshHostKey":
def of(cls, pkey: Any) -> SshHostKey:
"""The key of a paramiko ``PKey`` (what a transport says the server presented)."""
return cls(pkey.get_name(), pkey.get_base64())
@@ -104,14 +104,14 @@ class HostKeyObservation:
host: str
port: int
seen: SshHostKey
expected: Optional[SshHostKey]
expected: SshHostKey | None
@property
def accepted(self) -> bool:
"""True on first use and for the expected key; False for a refused one."""
return self.expected is None or self.expected == self.seen
def as_dict(self) -> Dict[str, Any]:
def as_dict(self) -> dict[str, Any]:
return {
"host": self.host,
"port": self.port,
@@ -161,7 +161,7 @@ class _RefuseUnknown:
raise ConnectionException(f"SSH host key of {hostname} is not the one expected")
def _expected_key(known: Any, port: int) -> Optional[SshHostKey]:
def _expected_key(known: Any, port: int) -> SshHostKey | None:
"""The key in *known* (``{port: key}``, the port as text or number) for *port*."""
if not known:
return None
@@ -170,10 +170,10 @@ def _expected_key(known: Any, port: int) -> Optional[SshHostKey]:
return SshHostKey.from_value(known.get(str(port)) or known.get(port))
def _refused_key(exc: BaseException) -> Optional[Any]:
def _refused_key(exc: BaseException) -> Any | None:
"""paramiko's ``BadHostKeyException`` in *exc*'s chain (netmiko and drivers wrap it)."""
seen: Set[int] = set()
stack: List[Optional[BaseException]] = [exc]
seen: set[int] = set()
stack: list[BaseException | None] = [exc]
while stack:
current = stack.pop()
if current is None or id(current) in seen:
@@ -192,7 +192,7 @@ class HostKeyCheck:
address the connection goes to, as given to paramiko or netmiko.
"""
def __init__(self, optional_args: Optional[Mapping[str, Any]], host: str, port: int) -> None:
def __init__(self, optional_args: Mapping[str, Any] | None, host: str, port: int) -> None:
args = optional_args or {}
self.host = host
self.port = int(port)
@@ -228,7 +228,7 @@ class HostKeyCheck:
# ------------------------------------------------------------------
@contextmanager
def netmiko(self, base: Mapping[str, Any]) -> Iterator[Dict[str, Any]]:
def netmiko(self, base: Mapping[str, Any]) -> Iterator[dict[str, Any]]:
"""``ConnectHandler`` keyword arguments: *base* with the host key decided.
Open the connection inside the block and call :meth:`observe` on its
+58 -46
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""OpenSSH server hardening: taking algorithms out of what sshd offers.
A scanner such as ssh-audit names algorithms a server should stop offering --
@@ -36,15 +35,18 @@ Only without such an ``Include`` is sshd_config itself rewritten, after a copy.
from __future__ import annotations
import time
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from shlex import quote
from typing import Callable, Dict, Iterable, List, Mapping, Optional, Sequence, Set, Tuple, TYPE_CHECKING
from typing import (
TYPE_CHECKING,
)
if TYPE_CHECKING: # pragma: no cover - for type checkers only
from napalm_device_types.channel import CommandResult
#: The algorithm classes: key -> (``sshd -T`` keyword, sshd_config directive).
SSH_ALGORITHM_CLASSES: Dict[str, Tuple[str, str]] = {
SSH_ALGORITHM_CLASSES: dict[str, tuple[str, str]] = {
"kex": ("kexalgorithms", "KexAlgorithms"),
"hostkey": ("hostkeyalgorithms", "HostKeyAlgorithms"),
"cipher": ("ciphers", "Ciphers"),
@@ -65,7 +67,7 @@ _HEADER = (
#: Host key type, as ``ssh-keygen -l`` prints it -> the algorithms that key serves.
#: Certificate algorithms are left out: they also need a HostCertificate.
_KEY_TYPE_ALGORITHMS: Dict[str, Tuple[str, ...]] = {
_KEY_TYPE_ALGORITHMS: dict[str, tuple[str, ...]] = {
"ED25519": ("ssh-ed25519",),
"RSA": ("rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"),
"DSA": ("ssh-dss",),
@@ -82,7 +84,7 @@ _RELOAD = (
)
def _directive_key(line: str) -> Optional[str]:
def _directive_key(line: str) -> str | None:
"""The algorithm class a global sshd_config line sets, if it sets one."""
parts = line.split(None, 1)
if not parts:
@@ -94,12 +96,12 @@ def _directive_key(line: str) -> Optional[str]:
return None
def parse_sshd_effective(text: str) -> Dict[str, List[str]]:
def parse_sshd_effective(text: str) -> dict[str, list[str]]:
"""The four algorithm lists from ``sshd -T`` output, in the server's order.
:raises ValueError: if a list is missing -- not sshd -T output, or cut short.
"""
found: Dict[str, List[str]] = {}
found: dict[str, list[str]] = {}
for raw in text.splitlines():
parts = raw.strip().split(None, 1)
if len(parts) != 2:
@@ -113,9 +115,9 @@ def parse_sshd_effective(text: str) -> Dict[str, List[str]]:
return found
def parse_sshd_hostkeys(text: str) -> List[str]:
def parse_sshd_hostkeys(text: str) -> list[str]:
"""The ``hostkey`` paths from ``sshd -T`` output, in order."""
paths: List[str] = []
paths: list[str] = []
for raw in text.splitlines():
parts = raw.strip().split(None, 1)
if len(parts) == 2 and parts[0].lower() == "hostkey":
@@ -123,12 +125,12 @@ def parse_sshd_hostkeys(text: str) -> List[str]:
return paths
def host_key_algorithms(fingerprints: str) -> Optional[Set[str]]:
def host_key_algorithms(fingerprints: str) -> set[str] | None:
"""The host key algorithms the keys in ``ssh-keygen -l`` output can serve.
None when a line is not understood or there is no key: then nothing is known.
"""
usable: Set[str] = set()
usable: set[str] = set()
for raw in fingerprints.splitlines():
parts = raw.split()
if not parts:
@@ -163,13 +165,13 @@ class Refusal:
@dataclass(frozen=True)
class SshHardeningPlan:
keep: Dict[str, List[str]]
remove: Dict[str, List[str]]
refused: List[Refusal] = field(default_factory=list)
not_offered: List[Refusal] = field(default_factory=list)
keep: dict[str, list[str]]
remove: dict[str, list[str]]
refused: list[Refusal] = field(default_factory=list)
not_offered: list[Refusal] = field(default_factory=list)
@property
def changed_classes(self) -> List[str]:
def changed_classes(self) -> list[str]:
return [k for k in SSH_ALGORITHM_CLASSES if self.remove.get(k)]
@@ -177,8 +179,8 @@ def plan_ssh_hardening(
effective: Mapping[str, Sequence[str]],
remove: Mapping[str, Iterable[str]],
*,
client: Optional[Mapping[str, Iterable[str]]] = None,
host_keys: Optional[Iterable[str]] = None,
client: Mapping[str, Iterable[str]] | None = None,
host_keys: Iterable[str] | None = None,
) -> SshHardeningPlan:
"""What the lists become when *remove* is taken out of *effective*.
@@ -199,10 +201,10 @@ def plan_ssh_hardening(
unknown = [k for k in remove if k not in SSH_ALGORITHM_CLASSES]
if unknown:
raise ValueError(f"unknown SSH algorithm class: {', '.join(unknown)}")
keep: Dict[str, List[str]] = {}
removed: Dict[str, List[str]] = {}
refused: List[Refusal] = []
not_offered: List[Refusal] = []
keep: dict[str, list[str]] = {}
removed: dict[str, list[str]] = {}
refused: list[Refusal] = []
not_offered: list[Refusal] = []
usable_keys = set(host_keys) if host_keys is not None else None
for kind in SSH_ALGORITHM_CLASSES:
offered = list(effective.get(kind, []))
@@ -245,7 +247,7 @@ def render_dropin(keep: Mapping[str, Sequence[str]], kinds: Iterable[str]) -> st
return _HEADER + "".join(lines)
def dropin_classes(text: str) -> List[str]:
def dropin_classes(text: str) -> list[str]:
"""The classes an existing drop-in sets."""
return [k for k in (_directive_key(line.strip()) for line in text.splitlines()) if k]
@@ -268,11 +270,11 @@ def has_dropin_include(text: str) -> bool:
return False
def _split_our_block(text: str) -> Tuple[List[str], str]:
def _split_our_block(text: str) -> tuple[list[str], str]:
"""(the classes an earlier rewrite put above the file, the file without them)."""
if not text.startswith(_HEADER):
return [], text
lines = text[len(_HEADER):].splitlines(keepends=True)
lines = text[len(_HEADER) :].splitlines(keepends=True)
ours = 0
while ours < len(lines) and _directive_key(lines[ours].strip()):
ours += 1
@@ -288,7 +290,7 @@ def render_main_config(text: str, keep: Mapping[str, Sequence[str]], kinds: Iter
"""
earlier, body = _split_our_block(text)
wanted = set(kinds) | set(earlier)
out: List[str] = []
out: list[str] = []
in_match = False
for raw in body.splitlines():
if _is_match(raw.strip()):
@@ -312,8 +314,8 @@ class SshHardeningResult:
plan: SshHardeningPlan
applied: bool = False
rolled_back: bool = False
path: Optional[str] = None
backup: Optional[str] = None
path: str | None = None
backup: str | None = None
message: str = ""
@@ -336,19 +338,19 @@ class OpenSshServerMixin:
*,
privileged: bool = False,
timeout: float = 60,
stdin: Optional[bytes] = None,
stdin: bytes | None = None,
) -> CommandResult: ...
# ── the host ────────────────────────────────────────────────────────
def _ssh_run(self, command: str, stdin: Optional[bytes] = None) -> "CommandResult":
def _ssh_run(self, command: str, stdin: bytes | None = None) -> CommandResult:
return self.run_command(command, privileged=True, timeout=30, stdin=stdin)
def _ssh_read(self, path: str) -> Optional[str]:
def _ssh_read(self, path: str) -> str | None:
result = self._ssh_run(f"cat -- {quote(path)}")
return result.stdout if result.exit_code == 0 else None
def _ssh_write(self, path: str, content: str, mode_of: Optional[str] = None) -> None:
def _ssh_write(self, path: str, content: str, mode_of: str | None = None) -> None:
"""Replace *path* with *content*, as root, without root reading stdin.
A driver gains root with ``sudo -S`` and puts the password in front of
@@ -368,17 +370,21 @@ class OpenSshServerMixin:
)
tmp = staged.stdout.strip()
if staged.exit_code != 0 or not tmp.startswith("/tmp/netork-ssh."):
raise RuntimeError(f"could not stage {path}: {(staged.stderr or staged.stdout).strip()}")
raise RuntimeError(
f"could not stage {path}: {(staged.stderr or staged.stdout).strip()}"
)
part = path + ".netork-tmp"
result = self._ssh_run(
# GNU stat, then BSD's; 644 for a file that does not exist yet.
f"r={quote(mode_of or path)}; m=$(stat -c %a -- \"$r\" 2>/dev/null"
f'r={quote(mode_of or path)}; m=$(stat -c %a -- "$r" 2>/dev/null'
f' || stat -f %Lp -- "$r" 2>/dev/null) || m=644;'
f' install -m "$m" -- {quote(tmp)} {quote(part)} && mv -f -- {quote(part)} {quote(path)};'
f" s=$?; rm -f -- {quote(tmp)} {quote(part)}; exit $s"
)
if result.exit_code != 0:
raise RuntimeError(f"could not write {path}: {(result.stderr or result.stdout).strip()}")
raise RuntimeError(
f"could not write {path}: {(result.stderr or result.stdout).strip()}"
)
def _ssh_remove(self, path: str) -> None:
self._ssh_run(f"rm -f -- {quote(path)}")
@@ -389,7 +395,7 @@ class OpenSshServerMixin:
if result.exit_code != 0:
raise RuntimeError(f"could not reload sshd: {(result.stderr or result.stdout).strip()}")
def get_ssh_server_algorithms(self) -> Dict[str, List[str]]:
def get_ssh_server_algorithms(self) -> dict[str, list[str]]:
"""The algorithm lists sshd uses now (``sshd -T``), per class.
:raises RuntimeError: if sshd -T fails -- no OpenSSH, or no root.
@@ -402,7 +408,7 @@ class OpenSshServerMixin:
raise RuntimeError(f"sshd -T failed: {(result.stderr or result.stdout).strip()}")
return result.stdout
def _ssh_host_key_algorithms(self, sshd_t: str) -> Optional[Set[str]]:
def _ssh_host_key_algorithms(self, sshd_t: str) -> set[str] | None:
"""The host key algorithms the host has a key for; None when that cannot be read.
A ``HostKey`` whose file does not exist is skipped, as sshd skips it.
@@ -418,7 +424,7 @@ class OpenSshServerMixin:
return None
return host_key_algorithms(result.stdout)
def _sshd_accepts(self) -> Optional[str]:
def _sshd_accepts(self) -> str | None:
"""None when ``sshd -t`` accepts the configuration, else what it said."""
result = self._ssh_run("PATH=$PATH:/usr/sbin:/sbin sshd -t")
return None if result.exit_code == 0 else (result.stderr or result.stdout).strip()
@@ -429,9 +435,9 @@ class OpenSshServerMixin:
self,
remove: Mapping[str, Iterable[str]],
*,
client: Optional[Mapping[str, Iterable[str]]] = None,
verify: Optional[Callable[[], bool]] = None,
log: Optional[Callable[[str], None]] = None,
client: Mapping[str, Iterable[str]] | None = None,
verify: Callable[[], bool] | None = None,
log: Callable[[str], None] | None = None,
) -> SshHardeningResult:
"""Stop the server offering the algorithms in *remove* (class -> names).
@@ -447,11 +453,13 @@ class OpenSshServerMixin:
say = log or (lambda _line: None)
say("Reading the algorithms sshd offers (sshd -T)")
sshd_t = self._sshd_effective_text()
host_keys: Optional[Set[str]] = None
host_keys: set[str] | None = None
if remove.get("hostkey"):
say("Reading which host keys the server has (ssh-keygen -l)")
host_keys = self._ssh_host_key_algorithms(sshd_t)
plan = plan_ssh_hardening(parse_sshd_effective(sshd_t), remove, client=client, host_keys=host_keys)
plan = plan_ssh_hardening(
parse_sshd_effective(sshd_t), remove, client=client, host_keys=host_keys
)
whys = {
"last": "it is the last one",
"client": "netOrk could no longer connect",
@@ -477,7 +485,11 @@ class OpenSshServerMixin:
return self._apply_or_restore(plan, kinds, result, restore, verify, say)
def _write_change(
self, plan: SshHardeningPlan, main: str, result: SshHardeningResult, say: Callable[[str], None]
self,
plan: SshHardeningPlan,
main: str,
result: SshHardeningResult,
say: Callable[[str], None],
) -> Callable[[], None]:
"""Write the new lists; return what puts the previous state back."""
if has_dropin_include(main):
@@ -509,10 +521,10 @@ class OpenSshServerMixin:
def _apply_or_restore(
self,
plan: SshHardeningPlan,
kinds: List[str],
kinds: list[str],
result: SshHardeningResult,
restore: Callable[[], None],
verify: Optional[Callable[[], bool]],
verify: Callable[[], bool] | None,
say: Callable[[str], None],
) -> SshHardeningResult:
def back(message: str, reload: bool) -> SshHardeningResult:
+11 -15
View File
@@ -10,9 +10,9 @@ Usage::
...
"""
from typing import Any, Dict, List, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.base import DeviceTypeDriver
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.models import (
DiskPoolDict,
LogicalVolumeDict,
@@ -23,6 +23,7 @@ from napalm_device_types.models import (
StorageServiceDict,
VolumeSnapshotDict,
)
from napalm_device_types.packages import PackageManagementMixin
class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
@@ -47,7 +48,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
if TYPE_CHECKING:
def get_disks(self) -> List[PhysicalDiskDict]:
def get_disks(self) -> list[PhysicalDiskDict]:
"""
Returns all physical drives detected by the storage device.
@@ -95,7 +96,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
"""
...
def get_disk_pools(self) -> Dict[str, DiskPoolDict]:
def get_disk_pools(self) -> dict[str, DiskPoolDict]:
"""
Returns the disk pools (RAID arrays, ZFS pools, LVM volume groups, etc.)
configured on the device.
@@ -140,7 +141,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
# Logical volumes / datasets
# ------------------------------------------------------------------
def get_volumes(self) -> Dict[str, LogicalVolumeDict]:
def get_volumes(self) -> dict[str, LogicalVolumeDict]:
"""
Returns all logical volumes, ZFS datasets, or LUNs on the device.
@@ -196,7 +197,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
# Shares
# ------------------------------------------------------------------
def get_shares(self) -> Dict[str, NASShareDict]:
def get_shares(self) -> dict[str, NASShareDict]:
"""
Returns all network shares and iSCSI targets exported by the device.
@@ -244,7 +245,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
# Snapshots
# ------------------------------------------------------------------
def get_volume_snapshots(self, volume: str = "") -> List[VolumeSnapshotDict]:
def get_volume_snapshots(self, volume: str = "") -> list[VolumeSnapshotDict]:
"""
Returns volume / dataset snapshots.
@@ -339,7 +340,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
# Quotas
# ------------------------------------------------------------------
def get_quotas(self) -> List[StorageQuotaDict]:
def get_quotas(self) -> list[StorageQuotaDict]:
"""
Returns all filesystem quotas configured on the device.
@@ -380,7 +381,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
# Services
# ------------------------------------------------------------------
def get_storage_services(self) -> Dict[str, StorageServiceDict]:
def get_storage_services(self) -> dict[str, StorageServiceDict]:
"""
Returns the file-sharing and access services available on the device.
@@ -443,7 +444,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
# Replication
# ------------------------------------------------------------------
def get_replication_jobs(self) -> List[ReplicationJobDict]:
def get_replication_jobs(self) -> list[ReplicationJobDict]:
"""
Returns all replication / sync jobs configured on the device.
@@ -493,8 +494,3 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
# ------------------------------------------------------------------
# Package management (plugins / extensions)
# ------------------------------------------------------------------
+8 -7
View File
@@ -10,7 +10,9 @@ Usage::
...
"""
from typing import Any, Awaitable, Callable, Dict, List, TYPE_CHECKING
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
from napalm_device_types.base import DeviceTypeDriver
from napalm_device_types.mac_acl import MacAclMixin
from napalm_device_types.models import (
@@ -45,7 +47,7 @@ class SwitchDriver(MacAclMixin, DeviceTypeDriver):
async def get_health_metrics(
cls,
snmp_get: Callable[..., Awaitable[Any]],
snmp_walk: Callable[..., Awaitable[Dict[str, Any]]],
snmp_walk: Callable[..., Awaitable[dict[str, Any]]],
) -> HealthMetricsDict:
"""Collect SNMP health metrics for this switch type.
@@ -54,7 +56,7 @@ class SwitchDriver(MacAclMixin, DeviceTypeDriver):
"""
...
def get_spanning_tree(self) -> Dict[str, SpanningTreeDict]:
def get_spanning_tree(self) -> dict[str, SpanningTreeDict]:
"""
Returns spanning tree status for each STP instance.
@@ -104,7 +106,7 @@ class SwitchDriver(MacAclMixin, DeviceTypeDriver):
"""
...
def get_port_channels(self) -> Dict[str, PortChannelDict]:
def get_port_channels(self) -> dict[str, PortChannelDict]:
"""
Returns port-channel (LAG) configuration and status.
@@ -129,8 +131,7 @@ class SwitchDriver(MacAclMixin, DeviceTypeDriver):
"""
...
def get_dot1x_ports(self) -> Dict[str, Dot1XPortDict]:
def get_dot1x_ports(self) -> dict[str, Dot1XPortDict]:
"""
Returns the 802.1X / NAC configuration per switch port.
@@ -350,7 +351,7 @@ class SwitchDriver(MacAclMixin, DeviceTypeDriver):
"""
...
def set_lag_members(self, lag_name: str, members: List[str]) -> None:
def set_lag_members(self, lag_name: str, members: list[str]) -> None:
"""
Sets the full member-port list of a LAG/trunk interface.
+28 -11
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""The SMBIOS identity of a host: system UUID, product serial, board serial.
What ties a server to its BMC. The BMC reports the server's serial number and
@@ -32,19 +31,37 @@ _DMI = "/sys/class/dmi/id"
#: output cannot shift a field.
SYSTEM_IDENTITY_COMMAND = (
f"printf '{_BEGIN}\\n%s\\n%s\\n%s\\n{_END}\\n' "
f"\"$(cat {_DMI}/product_uuid 2>/dev/null)\" "
f"\"$(cat {_DMI}/product_serial 2>/dev/null)\" "
f"\"$(cat {_DMI}/board_serial 2>/dev/null)\""
f'"$(cat {_DMI}/product_uuid 2>/dev/null)" '
f'"$(cat {_DMI}/product_serial 2>/dev/null)" '
f'"$(cat {_DMI}/board_serial 2>/dev/null)"'
)
#: Lowercased defaults board vendors leave in place.
_PLACEHOLDERS = frozenset({
"", "none", "n/a", "na", "not specified", "not applicable", "not available",
"to be filled by o.e.m.", "to be filled by oem", "default string", "default",
"system serial number", "base board serial number", "chassis serial number",
"serial number", "0123456789", "1234567890", "123456789", "o.e.m.", "oem",
"03000200-0400-0500-0006-000700080009",
})
_PLACEHOLDERS = frozenset(
{
"",
"none",
"n/a",
"na",
"not specified",
"not applicable",
"not available",
"to be filled by o.e.m.",
"to be filled by oem",
"default string",
"default",
"system serial number",
"base board serial number",
"chassis serial number",
"serial number",
"0123456789",
"1234567890",
"123456789",
"o.e.m.",
"oem",
"03000200-0400-0500-0006-000700080009",
}
)
#: A value of one repeated character ("0", "00000000", "FFFF-...") or of zeros and Fs.
_REPEATED = re.compile(r"^(.)\1*$")
+19 -20
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""systemd services: listing them in one round trip, and starting and stopping them.
What systemd reports about its services, and how one is started or stopped, is
@@ -31,7 +30,7 @@ from __future__ import annotations
import re
from shlex import quote
from typing import Any, Dict, List, Set, Tuple, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from napalm_device_types.models import ServiceDict
from napalm_device_types.services import ServiceControlMixin
@@ -131,7 +130,7 @@ def service_action_command(name: str, action: str) -> str:
)
def parse_action_result(output: str) -> Dict[str, Any]:
def parse_action_result(output: str) -> dict[str, Any]:
"""``{"success", "output"}`` from what :func:`service_action_command` printed.
Only the exit status decides. A job still running when ``timeout`` gave up
@@ -151,7 +150,7 @@ def parse_action_result(output: str) -> Dict[str, Any]:
return {"success": False, "output": text or f"systemctl exited with status {status}."}
def _frame(output: str) -> List[str]:
def _frame(output: str) -> list[str]:
lines = [line.strip() for line in strip_terminal_codes(output).splitlines()]
try:
start = lines.index(_BEGIN)
@@ -161,9 +160,9 @@ def _frame(output: str) -> List[str]:
return lines[start + 1 : end]
def _sections(lines: List[str]) -> Dict[str, List[str]]:
sections: Dict[str, List[str]] = {}
current: List[str] = []
def _sections(lines: list[str]) -> dict[str, list[str]]:
sections: dict[str, list[str]] = {}
current: list[str] = []
for line in lines:
if line.startswith("[") and line.endswith("]"):
current = sections.setdefault(line[1:-1], [])
@@ -172,15 +171,15 @@ def _sections(lines: List[str]) -> Dict[str, List[str]]:
return sections
def _unit_blocks(lines: List[str]) -> List[Dict[str, str]]:
def _unit_blocks(lines: list[str]) -> list[dict[str, str]]:
"""``systemctl show``'s output, one dict per unit.
Units are separated by a blank line -- except where ``xargs`` split the
list over two runs and the blocks meet, so a key seen twice starts the next
unit as well.
"""
blocks: List[Dict[str, str]] = []
current: Dict[str, str] = {}
blocks: list[dict[str, str]] = []
current: dict[str, str] = {}
for line in lines:
key, sep, value = line.partition("=")
if not sep or key in current:
@@ -198,17 +197,17 @@ def _base(unit: str) -> str:
return unit[: -len(_SUFFIX)]
def _main_pid(block: Dict[str, str]) -> int:
def _main_pid(block: dict[str, str]) -> int:
try:
return int(block.get("MainPID") or 0)
except ValueError:
return 0
def _loaded(blocks: List[Dict[str, str]]) -> Tuple[Dict[str, ServiceDict], Set[str]]:
def _loaded(blocks: list[dict[str, str]]) -> tuple[dict[str, ServiceDict], set[str]]:
"""The loaded services, and every name they go by (aliases included)."""
services: Dict[str, ServiceDict] = {}
names: Set[str] = set()
services: dict[str, ServiceDict] = {}
names: set[str] = set()
for block in blocks:
unit = block.get("Id", "")
if not unit.endswith(_SUFFIX) or block.get("LoadState") == "not-found":
@@ -224,14 +223,14 @@ def _loaded(blocks: List[Dict[str, str]]) -> Tuple[Dict[str, ServiceDict], Set[s
return services, names
def _installed(lines: List[str], known: Set[str]) -> Dict[str, ServiceDict]:
def _installed(lines: list[str], known: set[str]) -> dict[str, ServiceDict]:
"""Installed services that are not loaded: neither running nor starting now.
Templates, static units and aliases are left out -- the last also when an
older systemd lists an alias as ``enabled``, which is why every name a
loaded unit goes by is skipped.
"""
services: Dict[str, ServiceDict] = {}
services: dict[str, ServiceDict] = {}
for line in lines:
parts = line.split()
if len(parts) < 2:
@@ -253,7 +252,7 @@ def _installed(lines: List[str], known: Set[str]) -> Dict[str, ServiceDict]:
return services
def _apply_generated(services: Dict[str, ServiceDict], lines: List[str]) -> None:
def _apply_generated(services: dict[str, ServiceDict], lines: list[str]) -> None:
"""Take a generated unit's boot state from ``is-enabled``'s answer."""
for line in lines:
parts = line.split()
@@ -261,7 +260,7 @@ def _apply_generated(services: Dict[str, ServiceDict], lines: List[str]) -> None
services[_base(parts[0])]["enabled"] = parts[1] in _ENABLED
def parse_systemd_services(output: str) -> List[ServiceDict]:
def parse_systemd_services(output: str) -> list[ServiceDict]:
"""Parse what :data:`SYSTEMD_SERVICES_COMMAND` printed, sorted by name.
Lists every loaded service unit but those that are not found, and every
@@ -299,7 +298,7 @@ class SystemdServicesMixin(ServiceControlMixin):
"""
...
def get_services(self) -> List[ServiceDict]:
def get_services(self) -> list[ServiceDict]:
"""
Returns the services systemd knows, in one round trip.
@@ -316,7 +315,7 @@ class SystemdServicesMixin(ServiceControlMixin):
)
return parse_systemd_services(output)
def manage_service(self, name: str, action: str) -> Dict[str, Any]:
def manage_service(self, name: str, action: str) -> dict[str, Any]:
"""
Applies *action* (start, stop, restart, enable, disable) to the service *name*.
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""What a pseudo-terminal adds to a command's output, taken out again.
A screen-scraping transport (netmiko) gives the remote command a terminal. Tools
+4 -5
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Pending package updates and applying them.
The two device types that offer this used to declare it under two different
@@ -13,7 +12,7 @@ this class in can never shadow a working implementation from a sibling base.
from __future__ import annotations
from typing import Any, Dict, List, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from napalm_device_types.models import ApplyUpdatesResultDict, UpdateDict, UpdateProgressDict
@@ -21,7 +20,7 @@ from napalm_device_types.models import ApplyUpdatesResultDict, UpdateDict, Updat
class UpdateMixin:
if TYPE_CHECKING:
def get_available_updates(self) -> List[UpdateDict]:
def get_available_updates(self) -> list[UpdateDict]:
"""
Returns a list of packages that have a newer version available.
@@ -51,7 +50,7 @@ class UpdateMixin:
"""
...
def refresh_available_updates(self) -> Dict[str, Any]:
def refresh_available_updates(self) -> dict[str, Any]:
"""
Refreshes the host's package index, so that :meth:`get_available_updates`
reports what the repositories offer now (``apt-get update``,
@@ -73,7 +72,7 @@ class UpdateMixin:
"""
...
def apply_updates(self, packages: List[str]) -> ApplyUpdatesResultDict:
def apply_updates(self, packages: list[str]) -> ApplyUpdatesResultDict:
"""
Upgrades the given packages to the newest available version.
+27 -26
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Virtual IPs: the addresses a host's HA configuration lets float between machines.
vip-manager and keepalived put a service address on whichever member is master
@@ -51,9 +50,10 @@ from __future__ import annotations
import ipaddress
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from shlex import quote
from typing import Callable, Dict, List, Optional, Set, Tuple, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.models import VirtualIpDict, VirtualIpsDict
from napalm_device_types.terminal import strip_terminal_codes
@@ -108,7 +108,7 @@ _AWK = " ".join(
r" if (!ok(f)) return; seen[f] = 1;"
r' while (0 < (r = (getline l < f))) { sub(/[#!].*/, "", l); gsub(/[{}]/, " & ", l);'
r' n = split(l, t, " "); if (n && nw && t[1] != "{") { stmt(w, nw, f, d); nw = 0 }'
r' for (k = 1; k <= n; k++) {'
r" for (k = 1; k <= n; k++) {"
r' if (t[k] == "{") { push(nw ? w[1] : "", 1 < nw ? w[2] : ""); nw = 0 }'
r' else if (t[k] == "}") { stmt(w, nw, f, d); nw = 0; pop() } else w[++nw] = t[k] } }'
r" stmt(w, nw, f, d);"
@@ -157,10 +157,10 @@ VIRTUAL_IPS_COMMAND = (
_COMMENT_RE = re.compile(r"\s+#.*$")
_VARIABLE_RE = re.compile(r"^\$\{?(\w+)\}?$")
_Record = Tuple[str, str]
_Record = tuple[str, str]
def _frame(output: str) -> List[str]:
def _frame(output: str) -> list[str]:
lines = [line.strip() for line in strip_terminal_codes(output).splitlines()]
try:
start = lines.index(_BEGIN)
@@ -170,9 +170,9 @@ def _frame(output: str) -> List[str]:
return lines[start + 1 : end]
def _sections(lines: List[str]) -> List[Tuple[str, str, List[_Record]]]:
def _sections(lines: list[str]) -> list[tuple[str, str, list[_Record]]]:
"""``[<mechanism> <unit>]`` headers, each with its records as ``(kind, rest)``."""
sections: List[Tuple[str, str, List[_Record]]] = []
sections: list[tuple[str, str, list[_Record]]] = []
for line in lines:
if line.startswith("[") and line.endswith("]"):
mechanism, _, unit = line[1:-1].partition(" ")
@@ -191,7 +191,7 @@ def _clean(value: str) -> str:
return value.strip()
def _address(text: str) -> Optional[Tuple[str, Optional[int]]]:
def _address(text: str) -> tuple[str, int | None] | None:
"""``"10.0.0.10/24"`` -> ``("10.0.0.10", 24)``; None for what is no address."""
host, slash, prefix = text.partition("/")
try:
@@ -205,7 +205,7 @@ def _address(text: str) -> Optional[Tuple[str, Optional[int]]]:
return str(address), int(prefix)
def _prefix(mask: Optional[str]) -> Optional[int]:
def _prefix(mask: str | None) -> int | None:
"""``"24"`` or ``"255.255.255.0"`` -> 24."""
if not mask:
return None
@@ -217,27 +217,27 @@ def _prefix(mask: Optional[str]) -> Optional[int]:
return None
def _running(records: List[_Record]) -> Optional[bool]:
def _running(records: list[_Record]) -> bool | None:
states = [rest for kind, rest in records if kind == "active"]
return states[-1] == "active" if states else None
def _vip_manager(unit: str, records: List[_Record]) -> List[VirtualIpDict]:
values: Dict[str, Dict[str, str]] = {"arg": {}, "env": {}, "cfg": {}}
def _vip_manager(unit: str, records: list[_Record]) -> list[VirtualIpDict]:
values: dict[str, dict[str, str]] = {"arg": {}, "env": {}, "cfg": {}}
for kind, rest in records:
if kind in values:
key, _, value = rest.partition(" ")
values[kind][key] = value
env = values["env"]
def resolve(value: str) -> Optional[str]:
def resolve(value: str) -> str | None:
value = _clean(value)
variable = _VARIABLE_RE.match(value)
if variable:
value = _clean(env.get(variable.group(1), ""))
return value if value and "$" not in value else None
def pick(args: Tuple[str, ...], envs: Tuple[str, ...], cfgs: Tuple[str, ...]) -> Optional[str]:
def pick(args: tuple[str, ...], envs: tuple[str, ...], cfgs: tuple[str, ...]) -> str | None:
for kind, names in (("arg", args), ("env", envs), ("cfg", cfgs)):
for name in names:
if name in values[kind]:
@@ -272,15 +272,15 @@ def _vip_manager(unit: str, records: List[_Record]) -> List[VirtualIpDict]:
@dataclass
class _Instance:
name: str
interface: Optional[str] = None
interface: str | None = None
vrid: str = ""
vips: List[str] = field(default_factory=list)
vips: list[str] = field(default_factory=list)
def _keepalived(unit: str, records: List[_Record]) -> List[VirtualIpDict]:
def _keepalived(unit: str, records: list[_Record]) -> list[VirtualIpDict]:
running = _running(records)
instances: List[_Instance] = []
current: Optional[_Instance] = None
instances: list[_Instance] = []
current: _Instance | None = None
for kind, rest in records:
if kind == "instance":
current = _Instance(rest or unit)
@@ -298,8 +298,8 @@ def _keepalived(unit: str, records: List[_Record]) -> List[VirtualIpDict]:
return [entry for instance in instances for entry in _instance_entries(instance, running)]
def _instance_entries(instance: _Instance, running: Optional[bool]) -> List[VirtualIpDict]:
entries: List[VirtualIpDict] = []
def _instance_entries(instance: _Instance, running: bool | None) -> list[VirtualIpDict]:
entries: list[VirtualIpDict] = []
for vip in instance.vips:
tokens = vip.split()
parsed = _address(tokens[0]) if tokens else None
@@ -326,13 +326,13 @@ def _host_prefix(address: str) -> int:
return ipaddress.ip_address(address).max_prefixlen
_READERS: Dict[str, Tuple[str, Callable[[str, List[_Record]], List[VirtualIpDict]]]] = {
_READERS: dict[str, tuple[str, Callable[[str, list[_Record]], list[VirtualIpDict]]]] = {
"vip-manager": ("vip_manager", _vip_manager),
"keepalived": ("keepalived", _keepalived),
}
def _order(entry: VirtualIpDict) -> Tuple[int, int, str]:
def _order(entry: VirtualIpDict) -> tuple[int, int, str]:
address = ipaddress.ip_address(entry["address"])
return address.version, int(address), entry["instance"]
@@ -346,8 +346,8 @@ def parse_virtual_ips(output: str) -> VirtualIpsDict:
sections = _sections(_frame(output))
if any(mechanism == _NO_SYSTEMD for mechanism, _, _ in sections):
return {"vip_manager": None, "keepalived": None}
found: Dict[str, List[VirtualIpDict]] = {"vip_manager": [], "keepalived": []}
blind: Set[str] = set()
found: dict[str, list[VirtualIpDict]] = {"vip_manager": [], "keepalived": []}
blind: set[str] = set()
for mechanism, unit, records in sections:
if mechanism not in _READERS:
continue
@@ -356,7 +356,8 @@ def parse_virtual_ips(output: str) -> VirtualIpsDict:
blind.add(key)
else:
found[key].extend(read(unit, records))
def result(key: str) -> Optional[List[VirtualIpDict]]:
def result(key: str) -> list[VirtualIpDict] | None:
return None if key in blind else sorted(found[key], key=_order)
return {"vip_manager": result("vip_manager"), "keepalived": result("keepalived")}
+7
View File
@@ -64,6 +64,13 @@ include = ["napalm_device_types*"]
[tool.setuptools.package-data]
napalm_device_types = ["py.typed"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.mypy]
python_version = "3.10"
strict = true
+1 -3
View File
@@ -8,8 +8,6 @@ paramiko channel is faked, so the tests pin behaviour, not paramiko.
from __future__ import annotations
import socket
import pytest
from napalm_device_types.channel import (
@@ -56,7 +54,7 @@ class FakeChannel:
if self.out:
return self.out.pop(0)
if self.hang:
raise socket.timeout()
raise TimeoutError()
return b""
def recv_stderr_ready(self):
+6 -2
View File
@@ -32,7 +32,9 @@ class FakeDriver(ContainerEngineMixin):
def run_command(self, command, *, privileged=False, timeout=60, stdin=None):
self.commands.append((command, privileged, timeout, stdin))
found = any(command == f"command -v {b}" for b in self._binaries())
return CommandResult(stdout="/usr/bin/x\n" if found else "", stderr="", exit_code=0 if found else 1)
return CommandResult(
stdout="/usr/bin/x\n" if found else "", stderr="", exit_code=0 if found else 1
)
def open_stream(self, command, *, privileged=False):
self.streams.append((command, privileged))
@@ -86,7 +88,9 @@ def test_cli_arguments_are_quoted_and_prefixed_by_the_binary():
def test_cli_stdin_and_timeout_reach_the_channel():
driver = FakeDriver()
driver.open_container_engine("docker").run_cli(["login", "--password-stdin"], stdin=b"x", timeout=30)
driver.open_container_engine("docker").run_cli(
["login", "--password-stdin"], stdin=b"x", timeout=30
)
_command, _privileged, timeout, stdin = driver.commands[-1]
assert (timeout, stdin) == (30, b"x")
+8 -7
View File
@@ -9,9 +9,10 @@ principle: generic vs. device-specific logic" for why this logic lives here
and not in a vendor driver.
"""
from typing import Any, Dict, List, Optional
from typing import Any
import pytest
from napalm_device_types import DhcpServerMixin, FirewallDriver, ResidentialGatewayDriver
from napalm_device_types.dhcp import normalize_mac
from napalm_device_types.models import DhcpReservationDict
@@ -33,21 +34,21 @@ def _reservation(**overrides: Any) -> DhcpReservationDict:
class _FakeDhcpServer(DhcpServerMixin):
"""In-memory fake — no network, no Kea/UCI specifics."""
def __init__(self, live: Optional[List[DhcpReservationDict]] = None) -> None:
def __init__(self, live: list[DhcpReservationDict] | None = None) -> None:
self.live = live or []
self.applied: List[Dict[str, Any]] = []
self.applied: list[dict[str, Any]] = []
self.commits = 0
def get_dhcp_reservations(self) -> List[DhcpReservationDict]:
def get_dhcp_reservations(self) -> list[DhcpReservationDict]:
return self.live
def apply_dhcp_reservation(
self, reservation: DhcpReservationDict, *, uuid: Optional[str] = None
) -> Dict[str, Any]:
self, reservation: DhcpReservationDict, *, uuid: str | None = None
) -> dict[str, Any]:
self.applied.append({"reservation": reservation, "uuid": uuid})
return {"success": True}
def commit_dhcp_reservations(self) -> Dict[str, Any]:
def commit_dhcp_reservations(self) -> dict[str, Any]:
self.commits += 1
return {"success": True}
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Two facts an HA pair of servers reports about itself.
`get_dhcp_ha` is what a DHCP server's own HA configuration says: the peers it
+9 -10
View File
@@ -11,9 +11,10 @@ A subnet is a heavier object than a reservation: a wrong `pools` or
below lean on the never-delete and preserve-unmanaged-options guarantees.
"""
from typing import Any, Dict, List, Optional
from typing import Any
import pytest
from napalm_device_types import DhcpServerMixin
from napalm_device_types.models import DhcpSubnetDict
@@ -35,21 +36,21 @@ def _subnet(**overrides: Any) -> DhcpSubnetDict:
class _FakeDhcpServer(DhcpServerMixin):
def __init__(self, live: Optional[List[DhcpSubnetDict]] = None) -> None:
def __init__(self, live: list[DhcpSubnetDict] | None = None) -> None:
self.live = live or []
self.applied: List[Dict[str, Any]] = []
self.applied: list[dict[str, Any]] = []
self.commits = 0
def get_dhcp_subnets(self) -> List[DhcpSubnetDict]:
def get_dhcp_subnets(self) -> list[DhcpSubnetDict]:
return self.live
def apply_dhcp_subnet(
self, subnet: DhcpSubnetDict, *, uuid: Optional[str] = None
) -> Dict[str, Any]:
self, subnet: DhcpSubnetDict, *, uuid: str | None = None
) -> dict[str, Any]:
self.applied.append({"subnet": subnet, "uuid": uuid})
return {"success": True}
def commit_dhcp_subnets(self) -> Dict[str, Any]:
def commit_dhcp_subnets(self) -> dict[str, Any]:
self.commits += 1
return {"success": True}
@@ -215,9 +216,7 @@ class _BareDriver(DhcpServerMixin):
pass
@pytest.mark.parametrize(
"method", ["get_dhcp_subnets", "apply_dhcp_subnet", "commit_dhcp_subnets"]
)
@pytest.mark.parametrize("method", ["get_dhcp_subnets", "apply_dhcp_subnet", "commit_dhcp_subnets"])
def test_device_specific_methods_are_absent_until_implemented(method: str) -> None:
"""Declared under TYPE_CHECKING, so they do not exist until a driver adds them."""
assert not hasattr(_BareDriver, method)
+1 -1
View File
@@ -31,7 +31,7 @@ def test_a_missing_license_may_say_what_the_device_withholds_without_it():
# firmware inventory until DCMS is activated: partly licensed, not unlicensed.
hints = get_type_hints(LicenseDict)
# Python 3.10 keeps NotRequired[...] around the type; later versions strip it.
assert "List[str]" in str(hints["withholds"])
assert "list[str]" in str(hints["withholds"])
assert "withholds" not in LicenseDict.__required_keys__
+8 -3
View File
@@ -3,21 +3,21 @@
from __future__ import annotations
import pytest
from napalm.base import NetworkDriver
from napalm_device_types import (
AccessPointDriver,
DeviceTypeDriver,
FingerprintRule,
FirewallDriver,
rules_max_weight,
HypervisorDriver,
OSDriver,
PortSpec,
ResidentialGatewayDriver,
StorageDriver,
SwitchDriver,
rules_max_weight,
)
from napalm.base import NetworkDriver
# ── Hierarchy ─────────────────────────────────────────────────────────────────
@@ -49,6 +49,7 @@ def test_device_type_driver_inherits_network_driver():
# ── Default attributes ────────────────────────────────────────────────────────
def test_defaults_on_device_type_driver():
assert DeviceTypeDriver.VENDOR == ""
assert DeviceTypeDriver.DRIVER_NAME == ""
@@ -72,6 +73,7 @@ def test_fingerprint_attributes_inherited(cls):
# ── FingerprintRule ───────────────────────────────────────────────────────────
def test_fingerprint_rule_defaults():
r = FingerprintRule("fritz!box")
assert r.pattern == "fritz!box"
@@ -102,6 +104,7 @@ def test_fingerprint_rule_group():
# ── rules_max_weight: the most a set of rules can add ─────────────────────────
def test_max_weight_sums_rules_that_can_match_together():
rules = [FingerprintRule("proxmox virtual environment", 9.0), FingerprintRule("pve", 2.0)]
assert rules_max_weight(rules) == 11.0
@@ -146,6 +149,7 @@ def test_fingerprint_rule_is_immutable():
# ── PortSpec ──────────────────────────────────────────────────────────────────
def test_port_spec_defaults():
p = PortSpec("https", 8006)
assert p.scheme == "https"
@@ -169,6 +173,7 @@ def test_port_spec_is_immutable():
# ── Concrete driver subclass ──────────────────────────────────────────────────
def test_concrete_driver_overrides_fingerprint():
class MyDriver(FirewallDriver):
VENDOR = "Acme"
+8 -7
View File
@@ -9,9 +9,10 @@ device-specific logic" for why this logic lives here and not in a vendor
driver.
"""
from typing import Any, Dict, List, Optional
from typing import Any
import pytest
from napalm_device_types import FirewallDriver
from napalm_device_types.models import FirewallRuleDict
@@ -39,21 +40,21 @@ def _rule(**overrides: Any) -> FirewallRuleDict:
class _FakeFirewall(FirewallDriver):
"""In-memory fake — no network, no OPNsense/vendor specifics."""
def __init__(self, live_rules: Optional[List[FirewallRuleDict]] = None) -> None:
def __init__(self, live_rules: list[FirewallRuleDict] | None = None) -> None:
self.live_rules = live_rules or []
self.applied: List[Dict[str, Any]] = []
self.applied: list[dict[str, Any]] = []
self.committed = False
def get_firewall_rules(self) -> List[FirewallRuleDict]:
def get_firewall_rules(self) -> list[FirewallRuleDict]:
return self.live_rules
def apply_firewall_rule(
self, rule: FirewallRuleDict, *, uuid: Optional[str] = None
) -> Dict[str, Any]:
self, rule: FirewallRuleDict, *, uuid: str | None = None
) -> dict[str, Any]:
self.applied.append({"rule": rule, "uuid": uuid})
return {"success": True}
def commit_firewall_rules(self) -> Dict[str, Any]:
def commit_firewall_rules(self) -> dict[str, Any]:
self.committed = True
return {"success": True}
+18 -5
View File
@@ -17,12 +17,12 @@ from napalm_device_types import (
ManagementControllerDriver,
MediaDriver,
OSDriver,
PackageProxyMixin,
PhoneDriver,
ResidentialGatewayDriver,
SnmpAgentMixin,
StorageDriver,
SwitchDriver,
PackageProxyMixin,
SnmpAgentMixin,
run_fix_steps,
)
@@ -97,7 +97,12 @@ class _Agent(SnmpAgentMixin):
class TestSnmpAgent:
SETTINGS = {"community": "c0mm", "allowed_sources": ["10.0.0.5"], "contact": "ops", "location": "rack 1"}
SETTINGS = {
"community": "c0mm",
"allowed_sources": ["10.0.0.5"],
"contact": "ops",
"location": "rack 1",
}
def test_install_configure_open_and_start_with_the_caller_s_values(self):
agent = _Agent()
@@ -153,7 +158,10 @@ class TestEnsurePackage:
def test_an_install_that_reports_failure_fails(self):
driver = _Packages(installed=[])
driver.install_package = lambda name, version="": {"success": False, "output": "E: no candidate"}
driver.install_package = lambda name, version="": {
"success": False,
"output": "E: no candidate",
}
result = driver.ensure_package("lldpd")
@@ -164,7 +172,12 @@ class TestEnsurePackage:
@pytest.mark.parametrize("base", ROLE_BASES, ids=lambda b: b.__name__)
@pytest.mark.parametrize(
"method",
["configure_snmp_agent", "ensure_package", "configure_package_proxy", "grant_container_engine_access"],
[
"configure_snmp_agent",
"ensure_package",
"configure_package_proxy",
"grant_container_engine_access",
],
)
def test_no_role_base_offers_a_fix(base, method):
assert getattr(base, method, None) is None
+4 -1
View File
@@ -452,7 +452,10 @@ class TestBusyCheck:
UBUNTU_IDLE = [
("systemd", "/sbin/init"),
("unattended-upgr", "/usr/bin/python3 /usr/share/unattended-upgrades/unattended-upgrade-shutdown --wait-for-signal"),
(
"unattended-upgr",
"/usr/bin/python3 /usr/share/unattended-upgrades/unattended-upgrade-shutdown --wait-for-signal",
),
("sshd", "sshd: netork [priv]"),
]
+3 -1
View File
@@ -3,7 +3,9 @@
from napalm_device_types import add_lag_interfaces
def _port(is_up: bool = True, is_enabled: bool = True, speed: float = 1000.0, trunk_group: str = "") -> dict:
def _port(
is_up: bool = True, is_enabled: bool = True, speed: float = 1000.0, trunk_group: str = ""
) -> dict:
port = {
"is_up": is_up,
"is_enabled": is_enabled,
+2 -2
View File
@@ -89,7 +89,7 @@ class TestParsing:
],
)
def test_every_address_form(self, local: str, address: str, interface):
line = f"tcp LISTEN 0 128 {local} *:* users:((\"x\",pid=1,fd=3))\n"
line = f'tcp LISTEN 0 128 {local} *:* users:(("x",pid=1,fd=3))\n'
[socket] = parse_listening_sockets(_wire(line, "1 0::/system.slice/x.service\n"))
@@ -188,7 +188,7 @@ class TestFailures:
def test_a_report_cut_short_raises(self):
with pytest.raises(ValueError):
parse_listening_sockets(_wire()[:-len("SOCK_END\n")])
parse_listening_sockets(_wire()[: -len("SOCK_END\n")])
def test_a_host_without_ss_or_netstat_is_unavailable(self):
with pytest.raises(ListeningSocketsUnavailable):
+6 -2
View File
@@ -5,7 +5,6 @@ from napalm.base import NetworkDriver
from napalm_device_types import DeviceTypeDriver, PingSweepMixin, driver_supports_ping
# ── Fakes ─────────────────────────────────────────────────────────────────────
@@ -225,4 +224,9 @@ def test_ping_sweep_stops_when_stop_requested():
def test_a_malformed_success_is_no_answer_not_an_abort(success):
entry = PingSweepMixin._parse_ping_reply("192.0.2.1", {"success": success})
assert entry == {"ip": "192.0.2.1", "alive": False, "rtt_ms": None, "error": "malformed ping reply"}
assert entry == {
"ip": "192.0.2.1",
"alive": False,
"rtt_ms": None,
"error": "malformed ping reply",
}
+1 -1
View File
@@ -19,7 +19,7 @@ from napalm_device_types.nat_vpn import NatVpnMixin
def test_a_firewall_and_a_gateway_share_the_declaration():
assert issubclass(FirewallDriver, NatVpnMixin)
assert issubclass(ResidentialGatewayDriver, NatVpnMixin)
assert "def get_port_forwards(self) -> List[PortForwardDict]" in inspect.getsource(NatVpnMixin)
assert "def get_port_forwards(self) -> list[PortForwardDict]" in inspect.getsource(NatVpnMixin)
def test_it_is_declared_once():
+1
View File
@@ -4,6 +4,7 @@ the same for every hypervisor (NetOrk/netork#794)."""
from __future__ import annotations
import pytest
from napalm_device_types import HypervisorDriver
from napalm_device_types.provisioning import (
QEMU_GUEST_AGENTS,
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""The contract that makes multi-role drivers safe.
A role base declares what a device of that kind can be asked for. It must not
+1
View File
@@ -9,6 +9,7 @@ sibling base.
"""
import pytest
from napalm_device_types import FirewallDriver
+146 -41
View File
@@ -35,14 +35,29 @@ SECRET = "s3cr3t-do-not-leak"
def _wire(*sections: str, systemd: bool = True, docker: str = "[docker]", noise: str = "") -> str:
head = "[systemd]" if systemd else "[no-systemd]"
body = "\n".join([head, *sections[:1], docker, *sections[1:]]) if sections else f"{head}\n{docker}"
body = (
"\n".join([head, *sections[:1], docker, *sections[1:]]) if sections else f"{head}\n{docker}"
)
return f"{noise}SVCC_BEGIN\n{body}\nSVCC_END\n"
DB01_CLUSTER = {
"members": [
{"name": "db-01", "role": "leader", "state": "running", "host": "10.7.234.12", "port": 5432},
{"name": "db-02", "role": "sync_standby", "state": "streaming", "host": "10.7.234.11", "port": 5432, "lag": 0},
{
"name": "db-01",
"role": "leader",
"state": "running",
"host": "10.7.234.12",
"port": 5432,
},
{
"name": "db-02",
"role": "sync_standby",
"state": "streaming",
"host": "10.7.234.11",
"port": 5432,
"lag": 0,
},
],
"scope": "netork-db",
}
@@ -79,10 +94,24 @@ class TestPatroni:
"cluster": "netork-db",
"running": True,
"members": [
{"name": "db-01", "address": "10.7.234.12", "port": 5432, "role": "leader",
"state": "running", "this_host": True, "config_from": None},
{"name": "db-02", "address": "10.7.234.11", "port": 5432, "role": "sync_standby",
"state": "streaming", "this_host": False, "config_from": None},
{
"name": "db-01",
"address": "10.7.234.12",
"port": 5432,
"role": "leader",
"state": "running",
"this_host": True,
"config_from": None,
},
{
"name": "db-02",
"address": "10.7.234.11",
"port": 5432,
"role": "sync_standby",
"state": "streaming",
"this_host": False,
"config_from": None,
},
],
}
]
@@ -91,8 +120,11 @@ class TestPatroni:
reading = parse_service_clusters(
_wire(
_patroni(
"active active", "scope 'netork-db' # the cluster", "name db-01",
"restapi 10.7.234.12:8008", "rest-unreachable",
"active active",
"scope 'netork-db' # the cluster",
"name db-01",
"restapi 10.7.234.12:8008",
"rest-unreachable",
)
)
)
@@ -100,8 +132,15 @@ class TestPatroni:
(cluster,) = reading["patroni"]
assert cluster["cluster"] == "netork-db"
assert cluster["members"] == [
{"name": "db-01", "address": "10.7.234.12", "port": None, "role": None,
"state": None, "this_host": True, "config_from": None}
{
"name": "db-01",
"address": "10.7.234.12",
"port": None,
"role": None,
"state": None,
"this_host": True,
"config_from": None,
}
]
def test_an_unreadable_config_is_not_looked_at(self):
@@ -112,7 +151,9 @@ class TestPatroni:
assert reading["patroni"] is None
def test_a_config_without_a_scope_declares_no_cluster(self):
assert parse_service_clusters(_wire(_patroni("active active", "name db-01")))["patroni"] == []
assert (
parse_service_clusters(_wire(_patroni("active active", "name db-01")))["patroni"] == []
)
def test_a_host_without_systemd_is_not_looked_at(self):
assert parse_service_clusters(_wire(systemd=False))["patroni"] is None
@@ -127,8 +168,10 @@ class TestAdguardhomeSync:
_wire(
"",
_container(
"adguardhome-sync", "ghcr.io/bakito/adguardhome-sync:v0.9.3",
"ORIGIN_URL http://10.9.100.5:3000", "REPLICA1_URL http://10.9.100.6:3000",
"adguardhome-sync",
"ghcr.io/bakito/adguardhome-sync:v0.9.3",
"ORIGIN_URL http://10.9.100.5:3000",
"REPLICA1_URL http://10.9.100.6:3000",
"REPLICA2_URL https://dns-03.example.org",
),
)
@@ -136,9 +179,13 @@ class TestAdguardhomeSync:
(cluster,) = reading["adguardhome_sync"]
assert (cluster["mechanism"], cluster["service"], cluster["cluster"]) == (
"adguardhome-sync", "adguardhome", "10.9.100.5:3000",
"adguardhome-sync",
"adguardhome",
"10.9.100.5:3000",
)
assert [(m["address"], m["port"], m["role"], m["config_from"]) for m in cluster["members"]] == [
assert [
(m["address"], m["port"], m["role"], m["config_from"]) for m in cluster["members"]
] == [
("10.9.100.5", 3000, "origin", None),
("10.9.100.6", 3000, "replica", 0),
("dns-03.example.org", 443, "replica", 0),
@@ -147,8 +194,15 @@ class TestAdguardhomeSync:
def test_a_single_replica_url_counts(self):
reading = parse_service_clusters(
_wire("", _container("sync", "bakito/adguardhome-sync", "ORIGIN_URL http://a:3000",
"REPLICA_URL http://b:3000"))
_wire(
"",
_container(
"sync",
"bakito/adguardhome-sync",
"ORIGIN_URL http://a:3000",
"REPLICA_URL http://b:3000",
),
)
)
assert [m["address"] for m in reading["adguardhome_sync"][0]["members"]] == ["a", "b"]
@@ -163,36 +217,68 @@ class TestAdguardhomeSync:
class TestBunkerweb:
def test_a_worker_takes_its_configuration_from_the_manager_it_admits(self):
reading = parse_service_clusters(
_wire("", _container("bunkerweb", "bunkerity/bunkerweb@sha256:2026", "API_WHITELIST_IP 10.50.100.5"))
_wire(
"",
_container(
"bunkerweb", "bunkerity/bunkerweb@sha256:2026", "API_WHITELIST_IP 10.50.100.5"
),
)
)
(cluster,) = reading["bunkerweb"]
assert (cluster["service"], cluster["cluster"]) == ("bunkerweb", "10.50.100.5")
assert cluster["members"] == [
{"name": None, "address": None, "port": 5000, "role": "worker", "state": None,
"this_host": True, "config_from": 1},
{"name": None, "address": "10.50.100.5", "port": None, "role": "manager", "state": None,
"this_host": False, "config_from": None},
{
"name": None,
"address": None,
"port": 5000,
"role": "worker",
"state": None,
"this_host": True,
"config_from": 1,
},
{
"name": None,
"address": "10.50.100.5",
"port": None,
"role": "manager",
"state": None,
"this_host": False,
"config_from": None,
},
]
def test_the_default_whitelist_names_no_manager(self):
"""127.0.0.0/8 and a docker network are BunkerWeb's default, not a peer."""
reading = parse_service_clusters(
_wire("", _container("bw", "bunkerity/bunkerweb:1.6", "API_WHITELIST_IP 127.0.0.0/8 10.20.30.0/24"))
_wire(
"",
_container(
"bw", "bunkerity/bunkerweb:1.6", "API_WHITELIST_IP 127.0.0.0/8 10.20.30.0/24"
),
)
)
assert reading["bunkerweb"] == []
def test_a_manager_names_its_instances(self):
reading = parse_service_clusters(
_wire("", _container("bw", "bunkerity/bunkerweb-all-in-one:1.6",
"BUNKERWEB_INSTANCES 10.50.100.6:5000 bw-03")),
_wire(
"",
_container(
"bw",
"bunkerity/bunkerweb-all-in-one:1.6",
"BUNKERWEB_INSTANCES 10.50.100.6:5000 bw-03",
),
),
)
(cluster,) = reading["bunkerweb"]
assert cluster["cluster"] is None # the manager cannot name its own address
assert [(m["address"], m["port"], m["role"], m["this_host"], m["config_from"])
for m in cluster["members"]] == [
assert [
(m["address"], m["port"], m["role"], m["this_host"], m["config_from"])
for m in cluster["members"]
] == [
(None, None, "manager", True, None),
("10.50.100.6", 5000, "worker", False, 0),
("bw-03", 5000, "worker", False, 0),
@@ -200,7 +286,9 @@ class TestBunkerweb:
def test_a_manager_without_instances_is_no_cluster(self):
"""proxy-01's all-in-one keeps its instances in its database."""
reading = parse_service_clusters(_wire("", _container("bw", "bunkerity/bunkerweb-all-in-one@sha256:2b34")))
reading = parse_service_clusters(
_wire("", _container("bw", "bunkerity/bunkerweb-all-in-one@sha256:2b34"))
)
assert reading["bunkerweb"] == []
@@ -298,7 +386,7 @@ class _Host:
"docker",
'case "$1" in\n'
f' ps) {cat} "$d/containers" ;;\n'
' inspect) for last; do :; done;\n'
" inspect) for last; do :; done;\n"
' case "$3" in *Config.Image*) '
f'{cat} "$d/image-$last" ;; *) {cat} "$d/env-$last" ;; esac ;;\n'
"esac\n",
@@ -362,7 +450,9 @@ def host(request, tmp_path) -> _Host:
class TestTheCommandOnAHost:
def test_patroni_as_on_db_01(self, host):
config = host.file("etc/patroni/config.yml", PATRONI_YML)
host.unit("patroni.service", "ActiveState=active", _exec_start("/usr/bin/patroni", str(config)))
host.unit(
"patroni.service", "ActiveState=active", _exec_start("/usr/bin/patroni", str(config))
)
host.rest(DB01_CLUSTER)
out = host.run()
@@ -379,7 +469,9 @@ class TestTheCommandOnAHost:
def test_patroni_whose_rest_api_does_not_answer(self, host):
config = host.file("etc/patroni/config.yml", PATRONI_YML)
host.unit("patroni.service", "ActiveState=active", _exec_start("/usr/bin/patroni", str(config)))
host.unit(
"patroni.service", "ActiveState=active", _exec_start("/usr/bin/patroni", str(config))
)
(cluster,) = parse_service_clusters(host.run())["patroni"]
@@ -391,7 +483,9 @@ class TestTheCommandOnAHost:
def test_a_patroni_config_it_cannot_read_is_not_looked_at(self, host):
config = host.file("etc/patroni/config.yml", PATRONI_YML)
config.chmod(0)
host.unit("patroni.service", "ActiveState=active", _exec_start("/usr/bin/patroni", str(config)))
host.unit(
"patroni.service", "ActiveState=active", _exec_start("/usr/bin/patroni", str(config))
)
out = host.run()
@@ -400,14 +494,19 @@ class TestTheCommandOnAHost:
def test_the_sync_and_a_worker_without_their_secrets(self, host):
host.container(
"adguardhome-sync", "ghcr.io/bakito/adguardhome-sync:v0.9.3",
"ORIGIN_URL=http://10.9.100.5:3000", f"ORIGIN_PASSWORD={SECRET}",
f"REPLICA1_URL=http://admin:{SECRET}@10.9.100.6:3000", f"REPLICA1_PASSWORD={SECRET}",
"adguardhome-sync",
"ghcr.io/bakito/adguardhome-sync:v0.9.3",
"ORIGIN_URL=http://10.9.100.5:3000",
f"ORIGIN_PASSWORD={SECRET}",
f"REPLICA1_URL=http://admin:{SECRET}@10.9.100.6:3000",
f"REPLICA1_PASSWORD={SECRET}",
"CRON=*/5 * * * *",
)
host.container(
"bunkerweb", "bunkerity/bunkerweb@sha256:2026",
"API_WHITELIST_IP=10.50.100.5", f"DATABASE_URI=mariadb+pymysql://bw:{SECRET}@db/bw",
"bunkerweb",
"bunkerity/bunkerweb@sha256:2026",
"API_WHITELIST_IP=10.50.100.5",
f"DATABASE_URI=mariadb+pymysql://bw:{SECRET}@db/bw",
)
host.container("zabbix", "zabbix/zabbix-agent2:alpine-latest", f"ZBX_PSK={SECRET}")
@@ -417,7 +516,8 @@ class TestTheCommandOnAHost:
assert "admin" not in out
reading = parse_service_clusters(out)
assert [m["address"] for m in reading["adguardhome_sync"][0]["members"]] == [
"10.9.100.5", "10.9.100.6",
"10.9.100.5",
"10.9.100.6",
]
assert reading["bunkerweb"][0]["cluster"] == "10.50.100.5"
assert reading["patroni"] == []
@@ -483,8 +583,13 @@ class TestTheSource:
def test_a_container_by_its_name(self):
reading = parse_service_clusters(
_wire("", _container("adguardhome-sync", "bakito/adguardhome-sync", "ORIGIN_URL http://a:3000"),
_container("bw", "bunkerity/bunkerweb", "API_WHITELIST_IP 10.0.0.5"))
_wire(
"",
_container(
"adguardhome-sync", "bakito/adguardhome-sync", "ORIGIN_URL http://a:3000"
),
_container("bw", "bunkerity/bunkerweb", "API_WHITELIST_IP 10.0.0.5"),
)
)
assert reading["adguardhome_sync"][0]["source"] == "adguardhome-sync"
+3 -1
View File
@@ -261,7 +261,9 @@ class TestNetmiko:
def test_any_other_failure_passes_unchanged(self):
with pytest.raises(ConnectionException, match="timed out"):
with HostKeyCheck({KNOWN_KEYS_ARG: {"22": ED25519.as_dict()}}, "10.0.0.5", 22).netmiko({}):
with HostKeyCheck({KNOWN_KEYS_ARG: {"22": ED25519.as_dict()}}, "10.0.0.5", 22).netmiko(
{}
):
raise ConnectionException("timed out")
def test_observe_reports_the_key_netmiko_saw(self, host_key):
+79 -28
View File
@@ -9,7 +9,6 @@ cannot get through -- runs without a machine.
from __future__ import annotations
import re
from typing import Dict, List, Optional
import pytest
@@ -69,7 +68,11 @@ def test_parse_reads_the_four_lists_in_server_order():
eff = parse_sshd_effective(SSHD_T)
assert eff["kex"][:2] == ["sntrup761x25519-sha512@openssh.com", "curve25519-sha256"]
assert eff["hostkey"] == ["ssh-ed25519", "ecdsa-sha2-nistp256", "rsa-sha2-512", "rsa-sha2-256"]
assert eff["cipher"] == ["chacha20-poly1305@openssh.com", "aes128-ctr", "aes256-gcm@openssh.com"]
assert eff["cipher"] == [
"chacha20-poly1305@openssh.com",
"aes128-ctr",
"aes256-gcm@openssh.com",
]
assert eff["mac"][-1] == "hmac-sha2-256"
@@ -82,7 +85,9 @@ def test_parse_refuses_output_without_the_lists():
def test_plan_removes_what_was_asked_and_keeps_the_order():
plan = plan_ssh_hardening(parse_sshd_effective(SSHD_T), {"mac": ["hmac-sha1", "umac-64-etm@openssh.com"]})
plan = plan_ssh_hardening(
parse_sshd_effective(SSHD_T), {"mac": ["hmac-sha1", "umac-64-etm@openssh.com"]}
)
assert plan.remove == {"mac": ["umac-64-etm@openssh.com", "hmac-sha1"]}
assert plan.keep["mac"] == ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-256"]
assert plan.changed_classes == ["mac"]
@@ -113,20 +118,38 @@ def test_plan_keeps_one_algorithm_the_managing_client_still_speaks():
know would leave the server reachable, but not by netOrk."""
plan = plan_ssh_hardening(
parse_sshd_effective(SSHD_T),
{"kex": ["curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384",
"diffie-hellman-group14-sha256"]},
{
"kex": [
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"diffie-hellman-group14-sha256",
]
},
client=PARAMIKO,
)
assert "curve25519-sha256@libssh.org" in plan.keep["kex"]
assert [(r.algorithm, r.reason) for r in plan.refused] == [("curve25519-sha256@libssh.org", "client")]
assert plan.remove["kex"] == ["ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "diffie-hellman-group14-sha256"]
assert [(r.algorithm, r.reason) for r in plan.refused] == [
("curve25519-sha256@libssh.org", "client")
]
assert plan.remove["kex"] == [
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"diffie-hellman-group14-sha256",
]
def test_plan_without_a_client_list_only_guards_the_last_algorithm():
plan = plan_ssh_hardening(
parse_sshd_effective(SSHD_T),
{"kex": ["curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384",
"diffie-hellman-group14-sha256"]},
{
"kex": [
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"diffie-hellman-group14-sha256",
]
},
)
assert plan.refused == []
assert plan.keep["kex"] == ["sntrup761x25519-sha512@openssh.com", "curve25519-sha256"]
@@ -171,12 +194,18 @@ def test_plan_removes_a_host_key_algorithm_when_a_usable_one_stays():
def test_plan_leaves_host_keys_alone_when_the_keys_are_unknown():
plan = plan_ssh_hardening(parse_sshd_effective(SSHD_T), {"hostkey": ["ecdsa-sha2-nistp256"]})
assert plan.changed_classes == []
assert [(r.algorithm, r.reason) for r in plan.refused] == [("ecdsa-sha2-nistp256", "host_keys_unknown")]
assert [(r.algorithm, r.reason) for r in plan.refused] == [
("ecdsa-sha2-nistp256", "host_keys_unknown")
]
def test_host_key_algorithms_from_ssh_keygen():
assert host_key_algorithms("\n".join(KEYGEN.values())) == {
"ecdsa-sha2-nistp256", "ssh-ed25519", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa",
"ecdsa-sha2-nistp256",
"ssh-ed25519",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
}
assert host_key_algorithms("521 SHA256:x no comment (ECDSA)") == {"ecdsa-sha2-nistp521"}
assert host_key_algorithms("") is None
@@ -204,11 +233,15 @@ def test_include_is_found_only_in_the_global_section():
assert has_dropin_include("Include /etc/ssh/sshd_config.d/*.conf\nPort 22\n")
assert has_dropin_include(" include /etc/ssh/sshd_config.d/*.conf\n")
assert not has_dropin_include("Port 22\n")
assert not has_dropin_include("Port 22\nMatch User x\n Include /etc/ssh/sshd_config.d/*.conf\n")
assert not has_dropin_include(
"Port 22\nMatch User x\n Include /etc/ssh/sshd_config.d/*.conf\n"
)
def test_main_config_gets_the_lists_first_and_the_old_lines_disabled():
main = "# defaults\nPort 22\nMACs hmac-sha1,hmac-sha2-256\nMatch User backup\n MACs hmac-sha1\n"
main = (
"# defaults\nPort 22\nMACs hmac-sha1,hmac-sha2-256\nMatch User backup\n MACs hmac-sha1\n"
)
text = render_main_config(main, {"mac": ["hmac-sha2-256"]}, ["mac"])
lines = text.splitlines()
first_directive = next(line for line in lines if line and not line.startswith("#"))
@@ -227,28 +260,34 @@ def test_main_config_rewrite_is_idempotent():
# ── the mixin, against a fake host ───────────────────────────────────────
_DIRECTIVE = {"kexalgorithms": "kex", "hostkeyalgorithms": "hostkey", "ciphers": "cipher", "macs": "mac"}
_DIRECTIVE = {
"kexalgorithms": "kex",
"hostkeyalgorithms": "hostkey",
"ciphers": "cipher",
"macs": "mac",
}
class FakeHost(OpenSshServerMixin):
"""A host with files, an sshd that reads them first-match-wins, and a log of what ran."""
def __init__(self, main: str = "Include /etc/ssh/sshd_config.d/*.conf\nPort 22\n",
sshd_t: str = SSHD_T) -> None:
self.files: Dict[str, str] = {MAIN_CONFIG: main}
self.modes: Dict[str, str] = {MAIN_CONFIG: "644"}
def __init__(
self, main: str = "Include /etc/ssh/sshd_config.d/*.conf\nPort 22\n", sshd_t: str = SSHD_T
) -> None:
self.files: dict[str, str] = {MAIN_CONFIG: main}
self.modes: dict[str, str] = {MAIN_CONFIG: "644"}
self.sshd_t = sshd_t
self.defaults = parse_sshd_effective(sshd_t)
self.keys = {p: KEYGEN[p] for p in parse_sshd_hostkeys(sshd_t)}
self.keygen_ok = True
self.ran: List[str] = []
self.ran: list[str] = []
self.syntax_ok = True
self.reloads = 0
def _effective(self) -> Dict[str, List[str]]:
def _effective(self) -> dict[str, list[str]]:
eff = {k: list(v) for k, v in self.defaults.items()}
seen = set()
lines: List[str] = []
lines: list[str] = []
for line in self.files[MAIN_CONFIG].splitlines():
if re.match(r"\s*include\s", line, re.I):
lines += self.files.get(DROPIN_PATH, "").splitlines()
@@ -269,8 +308,14 @@ class FakeHost(OpenSshServerMixin):
#: or a cached timestamp): the password the driver put in front of the data.
SUDO_PASSWORD = "hunter2"
def run_command(self, command: str, *, privileged: bool = False, timeout: float = 60,
stdin: Optional[bytes] = None) -> CommandResult:
def run_command(
self,
command: str,
*,
privileged: bool = False,
timeout: float = 60,
stdin: bytes | None = None,
) -> CommandResult:
self.ran.append(command)
if privileged and stdin is not None:
stdin = f"{self.SUDO_PASSWORD}\n".encode() + stdin
@@ -294,14 +339,20 @@ class FakeHost(OpenSshServerMixin):
if not self.keygen_ok:
return CommandResult("", "Permission denied", 1)
paths = re.search(r"for k in (.*?); do", command).group(1).replace("'", "").split()
return CommandResult("".join(self.keys[p] + "\n" for p in paths if p in self.keys), "", 0)
return CommandResult(
"".join(self.keys[p] + "\n" for p in paths if p in self.keys), "", 0
)
if command.startswith("sshd -T") or "sshd -T" in command:
eff = self._effective()
out = "".join(f"{d} {','.join(eff[k])}\n" for d, k in _DIRECTIVE.items())
out += "".join(line + "\n" for line in self.sshd_t.splitlines() if line.startswith("hostkey "))
out += "".join(
line + "\n" for line in self.sshd_t.splitlines() if line.startswith("hostkey ")
)
return CommandResult(out, "", 0)
if "sshd -t" in command:
return CommandResult("", "" if self.syntax_ok else "bad line 3", 0 if self.syntax_ok else 255)
return CommandResult(
"", "" if self.syntax_ok else "bad line 3", 0 if self.syntax_ok else 255
)
m = re.match(r"cat -- '?([^' ]+)'?$", command)
if m:
path = m.group(1)
@@ -323,7 +374,7 @@ class FakeHost(OpenSshServerMixin):
def test_mixin_writes_the_dropin_checks_it_and_reloads():
host = FakeHost()
lines: List[str] = []
lines: list[str] = []
result = host.harden_ssh_server({"mac": ["hmac-sha1"]}, client=PARAMIKO, log=lines.append)
assert result.applied and not result.rolled_back
assert result.path == DROPIN_PATH
@@ -439,7 +490,7 @@ def test_mixin_reports_a_failed_way_back_instead_of_raising():
def test_mixin_refuses_to_lock_out_a_host_with_only_ed25519_and_rsa_keys():
host = FakeHost(sshd_t=SSHD_T_FEW_KEYS)
lines: List[str] = []
lines: list[str] = []
result = host.harden_ssh_server(
{"hostkey": ["ssh-ed25519", "rsa-sha2-256", "rsa-sha2-512"]}, log=lines.append
)
+24 -6
View File
@@ -38,8 +38,20 @@ def test_a_supermicro_placeholder_serial_is_dropped():
@pytest.mark.parametrize(
"value",
["", "None", "N/A", "Not Specified", "To be filled by O.E.M.", "Default string",
"System Serial Number", "0123456789", "1234567890", "0", "00000000", "Not Applicable"],
[
"",
"None",
"N/A",
"Not Specified",
"To be filled by O.E.M.",
"Default string",
"System Serial Number",
"0123456789",
"1234567890",
"0",
"00000000",
"Not Applicable",
],
)
def test_placeholders(value):
assert is_placeholder_identity(value)
@@ -47,9 +59,12 @@ def test_placeholders(value):
@pytest.mark.parametrize(
"uuid",
["00000000-0000-0000-0000-000000000000", "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF",
# A UUID many cheap boards ship, the same on every one of them.
"03000200-0400-0500-0006-000700080009"],
[
"00000000-0000-0000-0000-000000000000",
"FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF",
# A UUID many cheap boards ship, the same on every one of them.
"03000200-0400-0500-0006-000700080009",
],
)
def test_placeholder_uuids(uuid):
assert parse_system_identity(_out(uuid, "", ""))["system_uuid"] == ""
@@ -63,7 +78,10 @@ def test_real_values_are_not_placeholders():
def test_unreadable_files_read_as_empty():
# product_serial and product_uuid are root's alone; without root they come back empty.
assert parse_system_identity(_out("", "", "BS1")) == {
"system_uuid": "", "product_serial": "", "board_serial": "BS1"}
"system_uuid": "",
"product_serial": "",
"board_serial": "BS1",
}
def test_output_without_its_frame_is_not_a_reading():
+1 -1
View File
@@ -409,4 +409,4 @@ class TestSystemdServicesMixin:
def test_not_every_os_driver_has_it(self):
assert not hasattr(OSDriver, "manage_service")
assert callable(getattr(SystemdServicesMixin, "manage_service"))
assert callable(SystemdServicesMixin.manage_service)
+1 -1
View File
@@ -1,6 +1,7 @@
"""Tests for TYPE_LABEL class attribute on all base driver classes."""
import pytest
from napalm_device_types import (
AccessPointDriver,
FirewallDriver,
@@ -12,7 +13,6 @@ from napalm_device_types import (
SwitchDriver,
)
BASE_CLASSES = [
(AccessPointDriver, "Access Point"),
(FirewallDriver, "Firewall"),
+8 -5
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import asyncio
from typing import Dict, Optional
from napalm_device_types._ucd_metrics import (
OID_IF_DESCR,
@@ -24,11 +23,11 @@ LEGACY = {
}
def _collect(tables: Dict[str, Dict[str, str]]) -> dict:
async def get(oid: str) -> Optional[str]:
def _collect(tables: dict[str, dict[str, str]]) -> dict:
async def get(oid: str) -> str | None:
return None
async def walk(oid: str) -> Dict[str, str]:
async def walk(oid: str) -> dict[str, str]:
return tables.get(oid, {})
return asyncio.run(collect_ucd_metrics(get, walk))
@@ -44,7 +43,11 @@ def test_the_64_bit_counters_and_high_speed_win():
eth0 = _collect(tables)["interfaces"]["eth0"]
assert (eth0["rx_bytes"], eth0["tx_bytes"], eth0["speed_mbps"]) == (8589934592, 17179869184, 10000)
assert (eth0["rx_bytes"], eth0["tx_bytes"], eth0["speed_mbps"]) == (
8589934592,
17179869184,
10000,
)
def test_an_agent_without_ifxtable_falls_back_to_the_32_bit_counters():
+9 -7
View File
@@ -152,9 +152,7 @@ class TestVipManager:
assert parse_virtual_ips(_wire(section))["vip_manager"] == []
def test_a_template_unit_is_named_after_its_instance(self):
section = _vip_manager(
"active active", "cfg ip 10.0.0.10", unit="vip-manager@pg16.service"
)
section = _vip_manager("active active", "cfg ip 10.0.0.10", unit="vip-manager@pg16.service")
[entry] = parse_virtual_ips(_wire(section))["vip_manager"]
@@ -496,8 +494,10 @@ class TestTheCommandOnAHost:
assert SECRET not in out
assert "MASTER" not in out and "priority" not in out
reading = parse_virtual_ips(out)
assert [(e["instance"], e["address"], e["prefix_length"], e["interface"], e["group_key"])
for e in reading["keepalived"]] == [
assert [
(e["instance"], e["address"], e["prefix_length"], e["interface"], e["group_key"])
for e in reading["keepalived"]
] == [
("VI_WEB", "192.0.2.10", 24, "eth0", "vrid:51"),
("VI_DB", "192.0.2.20", 32, "eth1", "vrid:52"),
("VI_DB", "2001:db8::20", 64, "eth1", "vrid:52"),
@@ -593,8 +593,10 @@ class TestTheCommandOnAHost:
out = host.run()
assert SECRET not in out
assert [(e["instance"], e["address"], e["prefix_length"], e["interface"], e["group_key"])
for e in parse_virtual_ips(out)["keepalived"]] == [
assert [
(e["instance"], e["address"], e["prefix_length"], e["interface"], e["group_key"])
for e in parse_virtual_ips(out)["keepalived"]
] == [
("VI_1", "192.0.2.50", 32, "eth0", None),
("VI_2", "192.0.2.60", 24, "eth1", "vrid:53"),
]
+2 -2
View File
@@ -15,7 +15,7 @@ from __future__ import annotations
import ast
import inspect
from typing import List, get_type_hints
from typing import get_type_hints
import napalm_device_types.hypervisor as hypervisor_module
from napalm_device_types import HypervisorDriver
@@ -52,7 +52,7 @@ class TestCpuTypeListing:
assert get_type_hints(VMCpuTypeDict) == {
"name": str,
"description": str,
"features": List[str],
"features": list[str],
"available": bool,
"default": bool,
}
+1
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
from typing import get_type_hints
import pytest
from napalm_device_types.models import (
VMConfigDict,
VMDict,