Merge pull request 'chore: adopt the NAPALM driver standard (pilot)' (#52) from chore/driver-standard into main
driver / standard (push) Successful in 13s
driver / lint (push) Successful in 11s
driver / types (push) Successful in 43s
driver / test (3.10) (push) Successful in 51s
driver / test (3.11) (push) Successful in 48s
driver / test (3.12) (push) Successful in 49s
driver / test (3.13) (push) Successful in 56s
driver / lowest (push) Successful in 36s
driver / security (push) Successful in 55s
driver / build (push) Successful in 26s
CI / driver (push) Successful in 6m28s
driver / publish (push) Skipped
driver / standard (push) Successful in 13s
driver / lint (push) Successful in 11s
driver / types (push) Successful in 43s
driver / test (3.10) (push) Successful in 51s
driver / test (3.11) (push) Successful in 48s
driver / test (3.12) (push) Successful in 49s
driver / test (3.13) (push) Successful in 56s
driver / lowest (push) Successful in 36s
driver / security (push) Successful in 55s
driver / build (push) Successful in 26s
CI / driver (push) Successful in 6m28s
driver / publish (push) Skipped
This commit was merged in pull request #52.
This commit is contained in:
commit
22e4377c2b
81 files changed
+1489
-1167
No files matched your search
@@ -0,0 +1,15 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
driver:
|
||||
uses: NAPALM/driver-standard/.gitea/workflows/driver.yml@main
|
||||
with:
|
||||
standard-ref: main
|
||||
secrets:
|
||||
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||
@@ -1,49 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
|
||||
- name: Install package with dev extras
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
|
||||
- name: Type-check
|
||||
run: |
|
||||
python -m mypy napalm_device_types
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
python -m pytest -q --tb=short
|
||||
|
||||
- name: Build wheel and sdist
|
||||
run: |
|
||||
python -m pip install build
|
||||
python -m build
|
||||
|
||||
- name: Upload dist artifacts
|
||||
# v4 refuses to run on Gitea ("not currently supported on GHES").
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: dist-${{ matrix.python-version }}
|
||||
path: dist/*
|
||||
@@ -0,0 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to napalm-device-types. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/), versions follow
|
||||
[Semantic Versioning](https://semver.org/). Versions up to 4.21.0 were not
|
||||
recorded here; their history is the git log.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [4.21.1] - 2026-10-22
|
||||
|
||||
The first release on PyPI since 0.1.0.
|
||||
|
||||
### Changed
|
||||
- The package follows the NAPALM driver standard
|
||||
(https://git.netork.io/NAPALM/driver-standard): CI on Gitea runs ruff, strict
|
||||
mypy, the tests on Python 3.10–3.13 and at the lowest declared versions,
|
||||
bandit, pip-audit and a build check, and a release tag publishes to PyPI.
|
||||
- Type annotations use builtin generics (`list[str]`, `X | None`) instead of
|
||||
`typing.List` and `Optional`. They mean the same to a type checker.
|
||||
- The project links point at git.netork.io instead of GitHub.
|
||||
@@ -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]
|
||||
...
|
||||
@@ -560,7 +569,7 @@ from napalm_device_types.models import (
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/chrismanivong/napalm-device-types.git
|
||||
git clone https://git.netork.io/NAPALM/napalm-device-types.git
|
||||
cd napalm-device-types
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
@@ -573,10 +582,15 @@ mypy napalm_device_types
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/router-driver`)
|
||||
3. Implement your changes
|
||||
4. Open a Pull Request
|
||||
This package follows the
|
||||
[NAPALM driver standard](https://git.netork.io/NAPALM/driver-standard/src/branch/main/STANDARDS.md):
|
||||
test first, ruff and strict mypy, Conventional Commits, one pull request per change
|
||||
against `main`. CI checks what a tool can check:
|
||||
|
||||
```bash
|
||||
pip install "napalm-standard @ git+https://git.netork.io/NAPALM/driver-standard.git"
|
||||
napalm-standard check && napalm-standard ratchet
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -186,7 +190,8 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa
|
||||
Keys are SSID names. Each value contains:
|
||||
|
||||
* ssid (string) - SSID name (repeated for convenience)
|
||||
* bridge (string) - bridge interface the VAP is attached to (e.g. ``"br-lan"``, ``"br-guest"``)
|
||||
* bridge (string) - bridge interface the VAP is attached to
|
||||
(e.g. ``"br-lan"``, ``"br-guest"``)
|
||||
* vlan_id (int) - 802.1Q VLAN ID (0 = untagged / no VLAN separation)
|
||||
* tagged (bool) - whether traffic is 802.1Q-tagged on the uplink port
|
||||
* client_isolation (bool) - whether clients on this SSID are isolated from each other
|
||||
@@ -219,9 +224,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 +271,3 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa
|
||||
}
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
@@ -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)")
|
||||
@@ -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."""
|
||||
...
|
||||
|
||||
|
||||
@@ -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".
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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]"):
|
||||
@@ -166,6 +166,9 @@ def _sections(lines: List[str]) -> Dict[str, Tuple[List[str], Optional[int]]]:
|
||||
|
||||
# ── iptables-save ────────────────────────────────────────────────────────────
|
||||
|
||||
#: The token that negates the option after it (``! -s 10.0.0.0/8``).
|
||||
_NEGATION = "!"
|
||||
|
||||
#: Options that start a new part of a rule, outside any ``-m`` module.
|
||||
_TOP_LEVEL = frozenset(
|
||||
{
|
||||
@@ -197,7 +200,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 +213,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,19 +241,19 @@ 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
|
||||
while rt.more():
|
||||
token = rt.peek()
|
||||
if token in _TOP_LEVEL or (token == "!" and rt.peek(1) in _TOP_LEVEL):
|
||||
if token in _TOP_LEVEL or (token == _NEGATION and rt.peek(1) in _TOP_LEVEL):
|
||||
break
|
||||
negated = False
|
||||
if token == "!":
|
||||
if token == _NEGATION:
|
||||
rt.take()
|
||||
negated = True
|
||||
text.append("!")
|
||||
text.append(_NEGATION)
|
||||
option = rt.take()
|
||||
value = rt.take() if rt.more() and not str(rt.peek()).startswith("-") else ""
|
||||
text += [option] + ([value] if value else [])
|
||||
@@ -266,7 +269,10 @@ def _module_options(module: str, rt: _RuleTokens, matches: List[NetfilterMatchDi
|
||||
|
||||
|
||||
def _module_match(module: str, option: str, value: str, negated: bool) -> Any:
|
||||
"""The match one module option makes; False for one that matches nothing new; None if not read."""
|
||||
"""The match one module option makes.
|
||||
|
||||
False for one that matches nothing new; None if not read.
|
||||
"""
|
||||
if module == "comment":
|
||||
return False
|
||||
if module in ("tcp", "udp", "sctp", "udplite"):
|
||||
@@ -291,7 +297,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 +307,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 = {
|
||||
@@ -323,7 +329,7 @@ def _iptables_rule(line: str, chains: Set[str]) -> Tuple[str, NetfilterRuleDict]
|
||||
}
|
||||
while rt.more():
|
||||
token = rt.take()
|
||||
if token == "!":
|
||||
if token == _NEGATION:
|
||||
negated = True
|
||||
continue
|
||||
if token in simple:
|
||||
@@ -351,7 +357,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 +365,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 +427,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 +448,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 +482,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 +515,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 +530,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 +542,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 +582,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 +624,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 +635,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 +647,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 +679,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 +698,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -329,7 +330,8 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
:param name: VM name or its ``vmid``.
|
||||
:param snapshot: Name of the snapshot to delete.
|
||||
:raises ValueError: If the VM or snapshot does not exist.
|
||||
:raises RuntimeError: If other snapshots depend on this one (must delete children first).
|
||||
:raises RuntimeError: If other snapshots depend on this one
|
||||
(must delete children first).
|
||||
|
||||
Example::
|
||||
|
||||
@@ -359,7 +361,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 +405,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 +445,6 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
# Package management (hypervisor extensions / plugins)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Virtual machine provisioning
|
||||
# ------------------------------------------------------------------
|
||||
@@ -459,10 +456,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 +599,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 +617,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 +635,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -42,7 +41,8 @@ KERNEL_FACTS_COMMAND = (
|
||||
"if [ -r $m/modules.builtin ]; then echo '[builtin]'; cat $m/modules.builtin; fi; "
|
||||
"if [ -r $m/modules.dep ]; then echo '[available]'; cut -d: -f1 $m/modules.dep; fi; "
|
||||
"if [ -r /boot/config-$r ]; then echo '[config]'; grep '^CONFIG_' /boot/config-$r; "
|
||||
"elif [ -r /proc/config.gz ]; then echo '[config]'; zcat /proc/config.gz | grep '^CONFIG_'; fi; "
|
||||
"elif [ -r /proc/config.gz ]; then echo '[config]'; "
|
||||
"zcat /proc/config.gz | grep '^CONFIG_'; fi; "
|
||||
"} 2>/dev/null | gzip -c | base64; "
|
||||
"printf '%s%s\\n' KFACTS_ END"
|
||||
)
|
||||
@@ -78,9 +78,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 +90,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 +112,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]})
|
||||
|
||||
@@ -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)})",
|
||||
|
||||
@@ -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::
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -44,22 +43,25 @@ class MacAclMixin:
|
||||
"name": "CorpWiFi",
|
||||
"policy": "allow",
|
||||
"entries": [
|
||||
{"mac": "AA:BB:CC:DD:EE:01", "action": "allow", "description": "CEO-Laptop"},
|
||||
{"mac": "AA:BB:CC:DD:EE:02", "action": "allow", "description": "CFO-Laptop"},
|
||||
{"mac": "AA:BB:CC:DD:EE:01", "action": "allow",
|
||||
"description": "CEO-Laptop"},
|
||||
{"mac": "AA:BB:CC:DD:EE:02", "action": "allow",
|
||||
"description": "CFO-Laptop"},
|
||||
],
|
||||
},
|
||||
"GuestNet": {
|
||||
"name": "GuestNet",
|
||||
"policy": "deny",
|
||||
"entries": [
|
||||
{"mac": "DE:AD:BE:EF:00:01", "action": "deny", "description": "blocked device"},
|
||||
{"mac": "DE:AD:BE:EF:00:01", "action": "deny",
|
||||
"description": "blocked device"},
|
||||
],
|
||||
},
|
||||
}
|
||||
"""
|
||||
...
|
||||
|
||||
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.
|
||||
|
||||
@@ -73,7 +75,8 @@ class MacAclMixin:
|
||||
|
||||
Example::
|
||||
|
||||
driver.push_mac_acl("CorpWiFi", "whitelist", ["AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"])
|
||||
macs = ["AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"]
|
||||
driver.push_mac_acl("CorpWiFi", "whitelist", macs)
|
||||
driver.push_mac_acl("GuestNet", "off", [])
|
||||
"""
|
||||
...
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+224
-220
@@ -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):
|
||||
@@ -1491,10 +1493,12 @@ class ManagementProtocolsDict(TypedDict):
|
||||
|
||||
|
||||
class SystemIdentityDict(TypedDict):
|
||||
"""A host's SMBIOS identity, what ties it to its BMC (:mod:`napalm_device_types.system_identity`).
|
||||
"""A host's SMBIOS identity, what ties it to its BMC.
|
||||
|
||||
See :mod:`napalm_device_types.system_identity`.
|
||||
|
||||
Each value is empty when unknown or a vendor placeholder."""
|
||||
|
||||
system_uuid: str # lowercase
|
||||
system_uuid: str # lowercase
|
||||
product_serial: str
|
||||
board_serial: str
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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."):
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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``.
|
||||
|
||||
@@ -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))
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -212,7 +212,7 @@ class HostKeyCheck:
|
||||
if self.expected is None:
|
||||
client.set_missing_host_key_policy(_AcceptFirst())
|
||||
return
|
||||
with self._known_hosts_file() as path:
|
||||
with self._known_hosts_file(self.expected) as path:
|
||||
client.get_host_keys().load(path)
|
||||
client.set_missing_host_key_policy(_RefuseUnknown())
|
||||
|
||||
@@ -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
|
||||
@@ -243,7 +243,7 @@ class HostKeyCheck:
|
||||
with self.refusing():
|
||||
yield kwargs
|
||||
return
|
||||
with self._known_hosts_file() as path:
|
||||
with self._known_hosts_file(self.expected) as path:
|
||||
kwargs.update(ssh_strict=True, alt_host_keys=True, alt_key_file=path)
|
||||
with self.refusing():
|
||||
yield kwargs
|
||||
@@ -285,13 +285,12 @@ class HostKeyCheck:
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def _known_hosts_file(self) -> Iterator[str]:
|
||||
"""A private ``known_hosts`` file holding only the expected key."""
|
||||
assert self.expected is not None
|
||||
def _known_hosts_file(self, expected: SshHostKey) -> Iterator[str]:
|
||||
"""A private ``known_hosts`` file holding only *expected*."""
|
||||
fd, path = tempfile.mkstemp(prefix="known_hosts-")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(self.expected.known_hosts_line(self.host, self.port) + "\n")
|
||||
f.write(expected.known_hosts_line(self.host, self.port) + "\n")
|
||||
yield path
|
||||
finally:
|
||||
try:
|
||||
|
||||
@@ -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
|
||||
@@ -367,18 +369,24 @@ class OpenSshServerMixin:
|
||||
stdin=content.encode(),
|
||||
)
|
||||
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()}")
|
||||
# A path on the device, made by mktemp under umask 077: not a local temp file.
|
||||
if staged.exit_code != 0 or not tmp.startswith("/tmp/netork-ssh."): # nosec B108
|
||||
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' install -m "$m" -- {quote(tmp)} {quote(part)}'
|
||||
f" && 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 +397,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 +410,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 +426,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 +437,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 +455,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 +487,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 +523,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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -62,7 +63,8 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
|
||||
* rpm (int) - rotational speed; ``0`` for SSD/NVMe
|
||||
* temperature (int) - current temperature in Celsius; ``-1`` if unavailable
|
||||
* health (string) - ``"healthy"``, ``"warning"``, ``"failed"``, or ``"unknown"``
|
||||
* pool (string) - name of the pool this disk belongs to; empty string if unassigned/spare
|
||||
* pool (string) - name of the pool this disk belongs to;
|
||||
empty string if unassigned/spare
|
||||
|
||||
Example::
|
||||
|
||||
@@ -95,7 +97,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.
|
||||
@@ -106,12 +108,14 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
|
||||
* type (string) - ``"zfs"``, ``"lvm"``, ``"md"``, ``"hardware-raid"``, ``"btrfs"``
|
||||
* level (string) - RAID/redundancy level: ``"mirror"``, ``"raidz1"``, ``"raidz2"``,
|
||||
``"raidz3"``, ``"stripe"``, ``"raid0"`` … ``"raid60"``, ``"single"``
|
||||
* status (string) - ``"online"``, ``"degraded"``, ``"faulted"``, ``"offline"``, ``"unknown"``
|
||||
* status (string) - ``"online"``, ``"degraded"``, ``"faulted"``,
|
||||
``"offline"``, ``"unknown"``
|
||||
* total (int) - usable capacity in bytes
|
||||
* used (int) - used space in bytes
|
||||
* available (int) - free space in bytes
|
||||
* disks (list of strings) - slot identifiers of member drives
|
||||
* auto_expand (bool) - whether the pool grows automatically when disks are replaced with larger ones
|
||||
* auto_expand (bool) - whether the pool grows automatically when disks
|
||||
are replaced with larger ones
|
||||
* dedup (bool) - whether deduplication is enabled
|
||||
* compression (string) - pool-level compression algorithm: ``"off"``, ``"lz4"``,
|
||||
``"gzip"``, ``"zstd"``, etc.
|
||||
@@ -140,7 +144,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.
|
||||
|
||||
@@ -154,7 +158,8 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
|
||||
* used (int) - space currently used in bytes
|
||||
* available (int) - space available in bytes
|
||||
* mountpoint (string) - local mount path; empty string for block volumes / LUNs
|
||||
* compression (string) - active compression algorithm (``"off"``, ``"lz4"``, ``"zstd"``, etc.)
|
||||
* compression (string) - active compression algorithm
|
||||
(``"off"``, ``"lz4"``, ``"zstd"``, etc.)
|
||||
* dedup (bool) - whether deduplication is active on this volume
|
||||
* readonly (bool) - whether the volume is mounted read-only
|
||||
* snapshots (int) - number of snapshots currently held
|
||||
@@ -196,7 +201,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 +249,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.
|
||||
|
||||
@@ -253,7 +258,8 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
|
||||
|
||||
Each entry contains:
|
||||
|
||||
* name (string) - snapshot name (e.g. ``"auto-2026-05-11"`` or full ``"tank/media@auto-2026-05-11"``)
|
||||
* name (string) - snapshot name (e.g. ``"auto-2026-05-11"``
|
||||
or full ``"tank/media@auto-2026-05-11"``)
|
||||
* volume (string) - parent volume / dataset path
|
||||
* created (float) - creation timestamp (Unix epoch)
|
||||
* size (int) - bytes of unique data referenced only by this snapshot
|
||||
@@ -339,7 +345,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 +386,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 +449,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 +499,3 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
|
||||
# ------------------------------------------------------------------
|
||||
# Package management (plugins / extensions)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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*$")
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
+30
-11
@@ -1,14 +1,14 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
requires = ["setuptools>=77"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "napalm-device-types"
|
||||
version = "4.21.0"
|
||||
version = "4.21.1"
|
||||
description = "Abstract device-type base classes for NAPALM drivers"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "Apache-2.0" }
|
||||
license = "Apache-2.0"
|
||||
authors = [
|
||||
{ name = "Christian Manivong", email = "christian@manivong.de" },
|
||||
]
|
||||
@@ -28,7 +28,6 @@ classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: System Administrators",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
@@ -45,16 +44,16 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/chrismanivong/napalm-device-types"
|
||||
Repository = "https://github.com/chrismanivong/napalm-device-types"
|
||||
Issues = "https://github.com/chrismanivong/napalm-device-types/issues"
|
||||
Repository = "https://git.netork.io/NAPALM/napalm-device-types"
|
||||
Issues = "https://git.netork.io/NAPALM/napalm-device-types/issues"
|
||||
Changelog = "https://git.netork.io/NAPALM/napalm-device-types/src/branch/main/CHANGELOG.md"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest",
|
||||
"mypy",
|
||||
"build",
|
||||
"twine",
|
||||
"pytest>=8",
|
||||
"pytest-cov>=5",
|
||||
"mypy>=1.11",
|
||||
"ruff>=0.6",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
@@ -64,7 +63,27 @@ 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.ruff.lint.per-file-ignores]
|
||||
# Recorded device output keeps its lines.
|
||||
"tests/**" = ["E501"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
strict = true
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["napalm_device_types"]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 95
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"coverage_floor": 95.0,
|
||||
"function_complexity": {
|
||||
"napalm_device_types/_ucd_metrics.py::build_if_metrics": 20,
|
||||
"napalm_device_types/_ucd_metrics.py::collect_ucd_metrics": 17,
|
||||
"napalm_device_types/host_firewall.py::_iptables_rule": 11,
|
||||
"napalm_device_types/host_firewall.py::_module_match": 13,
|
||||
"napalm_device_types/host_firewall.py::_module_options": 14,
|
||||
"napalm_device_types/host_firewall.py::_nft_field": 15,
|
||||
"napalm_device_types/host_firewall.py::_nft_match": 12,
|
||||
"napalm_device_types/host_firewall.py::_nft_rule": 16,
|
||||
"napalm_device_types/host_firewall.py::_parse_nft": 14,
|
||||
"napalm_device_types/host_firewall.py::rule_text": 14,
|
||||
"napalm_device_types/host_status.py::_reboot": 12,
|
||||
"napalm_device_types/lag.py::add_lag_interfaces": 11,
|
||||
"napalm_device_types/listening.py::parse_listening_sockets": 13,
|
||||
"napalm_device_types/service_clusters.py::_patroni": 11,
|
||||
"napalm_device_types/ssh_server.py::plan_ssh_hardening": 38,
|
||||
"napalm_device_types/virtual_ips.py::_keepalived": 12
|
||||
},
|
||||
"function_lines": {
|
||||
"napalm_device_types/_ucd_metrics.py::collect_ucd_metrics": 119,
|
||||
"napalm_device_types/dhcp.py::DhcpServerMixin.diff_dhcp_reservations": 54,
|
||||
"napalm_device_types/dhcp.py::DhcpServerMixin.diff_dhcp_subnets": 60,
|
||||
"napalm_device_types/firewall_rules.py::FirewallRuleMixin.diff_firewall_rules": 56,
|
||||
"napalm_device_types/hypervisor.py::HypervisorDriver.create_vm_from_cloud_init": 84,
|
||||
"napalm_device_types/hypervisor.py::HypervisorDriver.get_vm_config": 77,
|
||||
"napalm_device_types/ssh_server.py::OpenSshServerMixin.harden_ssh_server": 52,
|
||||
"napalm_device_types/ssh_server.py::plan_ssh_hardening": 60,
|
||||
"napalm_device_types/storage.py::StorageDriver.get_volumes": 52,
|
||||
"napalm_device_types/switch.py::SwitchDriver.get_dot1x_ports": 51,
|
||||
"napalm_device_types/switch.py::SwitchDriver.get_poe_status": 63
|
||||
},
|
||||
"mypy_relaxed": [],
|
||||
"python_file_lines": {
|
||||
"napalm_device_types/models.py": 1504
|
||||
},
|
||||
"untyped_public": []
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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__
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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():
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -9,6 +9,7 @@ sibling base.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types import FirewallDriver
|
||||
|
||||
|
||||
|
||||
+146
-41
@@ -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"
|
||||
|
||||
@@ -252,7 +252,7 @@ class TestNetmiko:
|
||||
other = paramiko.ECDSAKey.generate()
|
||||
raise paramiko.BadHostKeyException("10.0.0.5", host_key, other)
|
||||
except paramiko.SSHException as exc:
|
||||
raise RuntimeError(f"A paramiko SSHException occurred: {exc}")
|
||||
raise RuntimeError(f"A paramiko SSHException occurred: {exc}") from exc
|
||||
except RuntimeError as exc:
|
||||
raise ConnectionException(str(exc)) from exc
|
||||
assert caught.value.observation.seen == SshHostKey.of(host_key)
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,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"),
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -33,7 +33,10 @@ def _declared(name: str) -> ast.FunctionDef:
|
||||
class TestCreateVmTakesACpuType:
|
||||
def test_cpu_type_is_an_optional_keyword(self):
|
||||
fn = _declared("create_vm_from_cloud_init")
|
||||
kwonly = {arg.arg: default for arg, default in zip(fn.args.kwonlyargs, fn.args.kw_defaults)}
|
||||
kwonly = {
|
||||
arg.arg: default
|
||||
for arg, default in zip(fn.args.kwonlyargs, fn.args.kw_defaults, strict=True)
|
||||
}
|
||||
assert "cpu_type" in kwonly
|
||||
default = kwonly["cpu_type"]
|
||||
assert isinstance(default, ast.Constant) and default.value is None
|
||||
@@ -52,7 +55,7 @@ class TestCpuTypeListing:
|
||||
assert get_type_hints(VMCpuTypeDict) == {
|
||||
"name": str,
|
||||
"description": str,
|
||||
"features": List[str],
|
||||
"features": list[str],
|
||||
"available": bool,
|
||||
"default": bool,
|
||||
}
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
from typing import get_type_hints
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types.models import (
|
||||
VMConfigDict,
|
||||
VMDict,
|
||||
|
||||
Reference in new issue
Block a user