Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9324a5b8f1 | ||
|
|
b4c4ec0b0b | ||
|
|
07b50f6a48 | ||
|
|
90367c1370 | ||
|
|
2739f10ecd | ||
|
|
0330f17a67 | ||
|
|
d3034bc8ea | ||
|
|
4f3b88db0e | ||
|
|
fde6d717a8 | ||
|
|
a1da0337e2 | ||
|
|
7afa590c0c | ||
|
|
155b5bbbe4 | ||
|
|
e509445572 | ||
|
|
dd8977750d | ||
|
|
e58b65a3db | ||
|
|
7ec427c743 | ||
|
|
d43e15e8db | ||
|
|
23c352aa8a | ||
|
|
a710a85de6 | ||
|
|
c947250179 | ||
|
|
ff6dbe0206 | ||
|
|
7aa731ad34 | ||
|
|
72679ff4f8 | ||
|
|
7b44f689bc | ||
|
|
ba2f18ea7d | ||
|
|
37c09d0fa0 | ||
|
|
8111c4cde3 | ||
|
|
e1ce72cc5c | ||
|
|
7ba84acad3 | ||
|
|
8eb7dcd871 | ||
|
|
95d9031c8d | ||
|
|
885c7e1f53 | ||
|
|
eb80d5cb0d | ||
|
|
e50e497939 | ||
|
|
3aed0b48d7 | ||
|
|
735b683028 | ||
|
|
08ec32e93a | ||
|
|
bd43bd75fa | ||
|
|
1112191aec | ||
|
|
b8b89acee1 | ||
|
|
f833e23422 | ||
|
|
b97ec654a0 | ||
|
|
c2d8d4a0d2 | ||
|
|
18676a573f | ||
|
|
4071f35050 | ||
|
|
17d8dabb4d | ||
|
|
d55b036a8e | ||
|
|
536ffcf6e1 | ||
|
|
97e7ede131 | ||
|
|
b5c40019af | ||
|
|
36b7852bce | ||
|
|
31949eca0a | ||
|
|
7b491164a2 | ||
|
|
f3fa75bbca |
No files matched your search
@@ -0,0 +1,49 @@
|
||||
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/*
|
||||
@@ -36,6 +36,16 @@ class QnapQtsDriver(StorageDriver, HypervisorDriver, LinuxDriver):
|
||||
`roles_of(cls)`, `role_keys_of(cls)` and `primary_role_of(cls)` read that back. Nothing
|
||||
restates the ranking: there is no precedence table and no attribute to override.
|
||||
|
||||
A server's baseboard management controller (Supermicro BMC, Dell iDRAC, HPE iLO,
|
||||
Lenovo XCC) is a role of its own, `ManagementControllerDriver` (`management_controller`),
|
||||
reached over Redfish or IPMI (`USES_SSH = False`). It is not the server it manages:
|
||||
`reboot_host` restarts the BMC (Redfish `Manager.Reset`), while `set_power_state`
|
||||
(`On`, `GracefulShutdown`, `ForceOff`, `GracefulRestart`, `ForceRestart`, `PowerCycle`)
|
||||
acts on the server. `get_hardware_inventory` carries the server's serial number, system
|
||||
UUID and NIC MACs -- what ties the BMC to the host it manages. Its sensors come through
|
||||
NAPALM's `get_environment`, with `cpu` and `memory` empty, since a BMC does not know the
|
||||
host OS's load; it has no `HealthMetricsMixin` (SNMP) and no command channel.
|
||||
|
||||
### Role bases declare; they never implement
|
||||
|
||||
**A role base must not contain a single runtime method — not even a
|
||||
@@ -64,6 +74,14 @@ Two things follow, and both are improvements:
|
||||
- **A method that was never implemented raises `AttributeError`, not
|
||||
`NotImplementedError`.** Ask before calling.
|
||||
|
||||
The rule covers this package's methods, not NAPALM's. `NetworkDriver` still carries
|
||||
a runtime `NotImplementedError` stub for each of its getters (`get_interfaces`,
|
||||
`get_environment`, `get_users`, …), and every role base inherits it. So a NAPALM getter
|
||||
must come from the driver class itself or from a mixin listed **before** the first role
|
||||
base; a mixin listed after it loses the MRO to NAPALM's stub. For the same reason
|
||||
`hasattr` says nothing about NAPALM's getters, and the OS role's account listing is
|
||||
`get_os_users`, not `get_users` -- NAPALM's `get_users` has a different shape.
|
||||
|
||||
## Function classes: what a device *can do*
|
||||
|
||||
Behaviour shared across roles lives in a function class, exactly once, and a role base
|
||||
@@ -73,6 +91,142 @@ is a thin bundle over them — `PackageManagementMixin`, `HealthMetricsMixin`,
|
||||
`HostRebootMixin` (`reboot_host`) is mixed into `DeviceTypeDriver` itself, since any
|
||||
device may be restartable; like the others it only declares.
|
||||
|
||||
**Fixes are templates** (`napalm_device_types.fixes`, since 4.13). A change the
|
||||
application wants made on a device runs as fixed steps the driver implements, with values
|
||||
the caller supplies: `SnmpAgentMixin.configure_snmp_agent(settings)` (install, configure,
|
||||
firewall, start), `EnsurePackageMixin.ensure_package(name)`,
|
||||
`PackageProxyMixin.configure_package_proxy(url)` and
|
||||
`ContainerEngineMixin.grant_container_engine_access(user)`. A driver whose device takes the
|
||||
change in one call overrides the template method. A driver mixes in the fixes its device
|
||||
can take; none is on a role base. They replace `run_device_action`, which is deprecated.
|
||||
|
||||
**SNMP is the application's** (`docs/STRUCTURE.md`). `HealthMetricsMixin` is deprecated
|
||||
since 4.12 and goes in 5.0. A driver does not collect over SNMP; a driver whose device
|
||||
publishes its health values under OIDs of its own declares them as data:
|
||||
|
||||
```python
|
||||
class MySwitchDriver(SwitchDriver):
|
||||
SNMP_HEALTH_PROFILE: ClassVar[SnmpHealthProfile] = {
|
||||
"uptime": {"oids": ["1.3.6.1.2.1.1.3.0"]},
|
||||
"cpu": {"oids": ["<vendor cpu oid>", "<older firmware's>"]},
|
||||
"memory": {"total": {"oids": ["<oid>"]}, "free": {"oids": ["<oid>"]}, "unit": "KiB"},
|
||||
}
|
||||
```
|
||||
|
||||
A value is `oids` (the first that answers wins) or a `table` folded by `first`, `average`
|
||||
or `max`, with an optional `pattern` for an agent that answers with text. What the profile
|
||||
does not name, the application reads from the standard UCD-MIB and IF-MIB.
|
||||
|
||||
`KernelFactsMixin` (`get_kernel_facts`) is the exception that is mixed in by a driver
|
||||
rather than by a role base: what a Linux kernel has built and loaded is read the same way
|
||||
everywhere, so the command and its parse are concrete here and a driver supplies only
|
||||
`_run_kernel_facts_command`. `OSDriver` does not carry it — a Windows host is an OS driver
|
||||
too, and `hasattr(driver, "get_kernel_facts")` has to stay truthful.
|
||||
|
||||
`HostStatusMixin` (`get_host_status`) is mixed in the same way: whether a Linux host needs
|
||||
a reboot to finish an update (`/var/run/reboot-required`, `needs-restarting -r`, or a newer
|
||||
kernel of the running flavour installed), whether it patches itself (unattended-upgrades,
|
||||
dnf-automatic) and when that last succeeded (apt's `upgrade-stamp`), and whether dpkg was
|
||||
left halfway (a journal in `/var/lib/dpkg/updates` or a half-configured package; unknown
|
||||
while a package manager is running), and how full `/`, `/boot` and `/var` are (`df -Pk`,
|
||||
each filesystem once, in bytes; how full is too full is the caller's decision). `package_updates` holds the shared apt and dnf parsers: apt's suites become
|
||||
an update's `origin`, a `-security` suite makes it a security update, and dnf's security
|
||||
advisories do the same.
|
||||
|
||||
`ListeningSocketsMixin` (`get_listening_sockets`) is mixed in the same way: every listening
|
||||
TCP and bound UDP socket from `ss -lntup`, with the systemd service or container behind it
|
||||
from `/proc/<pid>/cgroup`, in one round trip. A driver supplies
|
||||
`_run_listening_sockets_command(command, privileged=)`; the command arrives as one `sh -c`
|
||||
argument, so a `sudo -n` prefix covers all of it. Without root `ss` names only the login
|
||||
user's processes, and the reading says so (`attributed: false`) instead of failing. A host
|
||||
without `ss` is read with `netstat -lntup` (OpenWrt's busybox, old net-tools); on OpenWrt the
|
||||
cgroup names the procd service (`/services/<name>/<instance>`). A host with neither raises
|
||||
`ListeningSocketsUnavailable`.
|
||||
|
||||
`VirtualIpsMixin` (`get_virtual_ips`) reads the addresses a host's HA configuration lets
|
||||
float between machines: vip-manager (5.x's `--config` YAML, 1.x's `-ip=${VIP_IP}` resolved
|
||||
through the unit's environment) and keepalived (`virtual_ipaddress` of every
|
||||
`vrrp_instance`, `include` followed four levels deep). It reports what is *declared*, not
|
||||
what the host holds right now -- that is in its interface addresses. The same files hold
|
||||
the etcd password and `auth_pass`, so an awk program filters **on the host** and prints
|
||||
allowlisted fields only; a secret never reaches the caller. Root first, then without it.
|
||||
A mechanism it could not read completely is `None` ("did not look"), a host without it
|
||||
`[]`. The command is about 4 kB, so a driver sends it on an exec channel
|
||||
(`_run_virtual_ips_command(command, privileged=)`), not typed into a shell.
|
||||
|
||||
`get_dhcp_ha` (declared on `DhcpServerMixin`, optional) is what a DHCP server's own HA
|
||||
configuration says: whether HA is on, which peer this server is, each peer's name, role --
|
||||
as the server words it, not a shared vocabulary -- and, where the server has one, the URL
|
||||
its HA listener answers on. It is configuration, not state: whether the pair is in hot
|
||||
standby right now is not in it. `ConfigSyncMixin` (`get_config_sync`) is where a host
|
||||
pushes its configuration (`peer`, None for no one) and for which services, by the names
|
||||
`get_services` reports; sync items the driver has no service name for stay visible under
|
||||
`unmapped`. A driver mixes it in -- no role promises it. Both getters raise when they could
|
||||
not read; neither answers "off" for "could not ask". What the facts mean for a pair (hot
|
||||
standby, which side to write to) is the caller's to work out.
|
||||
|
||||
A firewall's CARP addresses are a third mechanism, `carp`: a firewall driver reads them from
|
||||
its own API and reports them under the reading's `carp` key (`vip_manager` and `keepalived`
|
||||
then None). What a CARP state means is protocol knowledge and stays in that driver. The
|
||||
contract only carries the result: unlike the other two, a CARP entry says who holds the
|
||||
address (`holds`), because a firewall's interface list need not show it. `holds` None
|
||||
leaves the holder to the interfaces.
|
||||
|
||||
`SystemIdentityMixin` (`get_system_identity`) reads a host's SMBIOS identity -- system UUID,
|
||||
product serial and board serial from `/sys/class/dmi/id` -- which is what ties a server to
|
||||
its BMC (`ManagementControllerDriver.get_hardware_inventory` reports the same values from
|
||||
the other side). Vendor placeholders (`0123456789` on Supermicro boards, `To be filled by
|
||||
O.E.M.`, `Default string`, all-zero or the common `03000200-…-000700080009` UUID) read as
|
||||
empty: matching on them would join unrelated machines. `is_placeholder_identity` is the
|
||||
same filter for a BMC driver's side. `product_uuid` and `product_serial` are root's, so a
|
||||
driver supplies `_run_system_identity_command(command, privileged=)`.
|
||||
|
||||
`OpenSshServerMixin` (`harden_ssh_server`, `get_ssh_server_algorithms`) takes algorithms
|
||||
out of what an OpenSSH server offers -- the ones ssh-audit flags. It reads the lists sshd
|
||||
really uses (`sshd -T`), takes out what was asked and writes the rest to
|
||||
`/etc/ssh/sshd_config.d/00-netork-ssh-hardening.conf`; only where sshd_config has no such
|
||||
`Include` is sshd_config itself rewritten, after a copy. It never removes the last algorithm
|
||||
of a class, nor -- given the managing client's lists -- the last one that client speaks.
|
||||
`sshd -t` must accept the result and `sshd -T` must show it; after the reload the caller's
|
||||
`verify` opens a new session, and if that fails the still-open one puts the old state back.
|
||||
A driver mixes it in and supplies `run_command`; a platform that regenerates sshd_config
|
||||
itself (an appliance with its own database) must not, or opts out with
|
||||
`harden_ssh_server = None`.
|
||||
|
||||
**Update readers raise when they cannot read.** `get_available_updates` returns an empty
|
||||
list only when nothing is pending; netOrk keeps "pending since" per package, and an empty
|
||||
list for "don't know" would reset it.
|
||||
|
||||
`SystemdServicesMixin` (`get_services`, `manage_service`) is mixed in the same way, by
|
||||
the drivers whose host runs systemd. Listing the services, checking a unit name and
|
||||
reading an action's exit status are the same on every such host, so they are concrete
|
||||
here, and a driver supplies only `_run_service_command(command, *, privileged, timeout)`
|
||||
— how a command reaches its host and how it gains root there. The listing is one round
|
||||
trip (`list-unit-files` plus one `systemctl show` over every loaded unit) instead of an
|
||||
`is-enabled` and a `show` per unit. A host without systemd raises `SystemdUnavailable`,
|
||||
a `NotImplementedError`, so a driver can fall back to another init system.
|
||||
|
||||
### Access channels: why there is no container model here
|
||||
|
||||
`CommandChannelMixin` declares a driver's public channel to its host:
|
||||
- `run_command(command, *, privileged=False, timeout=60, stdin=None)` returns a `CommandResult(stdout, stderr, exit_code)`;
|
||||
- `open_stream(command, *, privileged=False)` returns a `ByteStream` to the command's stdin and stdout.
|
||||
|
||||
The mixin sits in `DeviceTypeDriver` and only declares, so `hasattr(driver, "run_command")` is true exactly where a driver implements it. For SSH drivers, `channel.run_on_transport` and `channel.open_stream_on_transport` are the implementation over a paramiko exec channel: no PTY, stderr kept apart, a real exit code. How a command gains root stays with the driver.
|
||||
|
||||
**`ContainerEngineMixin` is deliberately different from `SystemdServicesMixin`.** The systemd mixin owns its command and its parse; this one owns neither.
|
||||
- `container_engines()` says which engines the host offers (`[{"engine": "docker", "api": "docker-engine"}]`).
|
||||
- `open_container_engine(engine)` returns a `ContainerEngineConnection`:
|
||||
- `open_api()` is a stream to the engine's API (`docker system dial-stdio`);
|
||||
- `run_cli(args)` and `stream_cli(args)` run the engine's CLI with arguments the caller chooses. With `privileged=True` the driver runs it the way `run_command` gains root, for an engine that refuses the login user.
|
||||
- The driver decides only *how* the engine is reached. Its hook `_container_engine_binary(engine)` returns, for QNAP, the Container Station path, and the caller never sees it.
|
||||
|
||||
**What runs on the engine, and what to do with it, is netOrk's** (NetOrk/netork#765): the container model, the Engine API requests, compose, updates. Above the connection everything is specific to the service, so this package abstracts the connection and nothing more.
|
||||
|
||||
A driver mixes `ContainerEngineMixin` in itself; `OSDriver` does not carry it. A Windows host is an OS driver too, and has no `dial-stdio` to offer over WinRM.
|
||||
|
||||
**Never half-close early.** `ByteStream.write` does not close anything; only `close_write` does. `dial-stdio` hands the daemon a half-close as "client gone", and the daemon then answers an unfinished request with HTTP 499.
|
||||
|
||||
A function class may use the **template form** — public method concrete, the
|
||||
device-specific part a `_hook` declared under `if TYPE_CHECKING` — *when the base
|
||||
genuinely does work* on the result: normalising, sorting, validating, or orchestrating
|
||||
@@ -100,14 +254,15 @@ Concretely: matching/comparison/reconciliation algorithms, orchestration flows,
|
||||
generic data shapes belong here. Only the actual device communication belongs in
|
||||
`vendor/napalm-<name>`.
|
||||
|
||||
**Worked example — firewall rule diff/apply** (`FirewallDriver`):
|
||||
**Worked example — firewall rule diff/apply** (`FirewallRuleMixin`, in `FirewallDriver`):
|
||||
|
||||
```python
|
||||
class FirewallDriver(DeviceTypeDriver):
|
||||
# Abstract — every driver implements its own device communication.
|
||||
def get_firewall_rules(self) -> List[FirewallRuleDict]: raise NotImplementedError
|
||||
def apply_firewall_rule(self, rule: FirewallRuleDict, *, uuid: Optional[str] = None) -> Dict[str, Any]: raise NotImplementedError
|
||||
def commit_firewall_rules(self) -> Dict[str, Any]: raise NotImplementedError
|
||||
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 commit_firewall_rules(self) -> Dict[str, Any]: ...
|
||||
|
||||
# Concrete — the matching/comparison/orchestration algorithm is identical
|
||||
# for every firewall vendor, so it lives here once.
|
||||
@@ -159,7 +314,7 @@ detection vs. presentation there.
|
||||
pip install napalm-device-types
|
||||
```
|
||||
|
||||
Requires Python ≥ 3.9 and NAPALM ≥ 4.0.
|
||||
Requires Python ≥ 3.10 (tested up to 3.13) and NAPALM ≥ 4.0.
|
||||
|
||||
## Available base classes
|
||||
|
||||
@@ -172,12 +327,37 @@ Requires Python ≥ 3.9 and NAPALM ≥ 4.0.
|
||||
| `OSDriver` | General-purpose operating systems | Linux, BSD, macOS |
|
||||
| `StorageDriver` | Storage appliances & NAS/SAN | TrueNAS, Synology DSM, QNAP QTS |
|
||||
| `ResidentialGatewayDriver` | Router + firewall + AP in one box | OpenWrt, FritzBox |
|
||||
| `PhoneDriver` | IP telephony endpoints | Yealink |
|
||||
| `MediaDriver` | Speakers, streamers, media renderers | Sonos, Chromecast |
|
||||
| `ManagementControllerDriver` | Server baseboard management controllers | Supermicro BMC, Dell iDRAC, HPE iLO, Lenovo XCC |
|
||||
|
||||
Mixins mixed into the classes above rather than used on their own:
|
||||
`ConfigLifecycleMixin` (config load/compare/commit/rollback), `PingSweepMixin`
|
||||
(subnet sweeps), and `DhcpServerMixin` (static DHCP reservations — mixed into
|
||||
`FirewallDriver` and `ResidentialGatewayDriver`, since both commonly run the
|
||||
DHCP server for their networks).
|
||||
Mixins that come with the classes above: `PingSweepMixin` (subnet sweeps, in
|
||||
every driver through `DeviceTypeDriver`) and `DhcpServerMixin` (static DHCP
|
||||
reservations — mixed into `FirewallDriver` and `ResidentialGatewayDriver`,
|
||||
since both commonly run the DHCP server for their networks).
|
||||
`ConfigLifecycleMixin` (config load/compare/commit/rollback) is mixed in by a
|
||||
driver itself.
|
||||
|
||||
### Fingerprints
|
||||
|
||||
`DeviceTypeDriver` carries what discovery matches a device against: `PORT_SPECS`,
|
||||
`SNMP_OBJECT_ID_PREFIX`, `SNMP_FINGERPRINT`, `SSH_FINGERPRINT`, `HTTP_FINGERPRINT` and
|
||||
`OUI_PREFIXES`. A `FingerprintRule` is a lowercase substring with a weight; `mandatory`
|
||||
disqualifies the driver when it is missing, `negative` subtracts.
|
||||
|
||||
Discovery compares drivers by score divided by the most a driver could score. Rules that
|
||||
can never match together, such as one vendor's landing page or another's, share a
|
||||
`group`: only the strongest rule of a group counts towards that maximum
|
||||
(`rules_max_weight`). Without it, a driver that recognises three vendors' pages would
|
||||
score a third as well as one that recognises a single page:
|
||||
|
||||
```python
|
||||
HTTP_FINGERPRINT = [
|
||||
FingerprintRule("aten international", 6.0, group="landing_page"), # Supermicro
|
||||
FingerprintRule("idrac", 6.0, group="landing_page"),
|
||||
FingerprintRule("integrated lights-out", 6.0, group="landing_page"), # HPE iLO
|
||||
]
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -227,6 +407,11 @@ class PfSenseDriver(FirewallDriver):
|
||||
def get_vpn_tunnels(self):
|
||||
# return Dict[str, VPNTunnelDict]
|
||||
...
|
||||
|
||||
def get_port_forwards(self):
|
||||
# return List[PortForwardDict] — forwards from the WAN only, never a
|
||||
# redirect between internal networks (shared with home gateways)
|
||||
...
|
||||
```
|
||||
|
||||
### Hypervisor
|
||||
@@ -242,8 +427,24 @@ class ProxmoxDriver(HypervisorDriver):
|
||||
|
||||
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
|
||||
# on this node, each with its cpuinfo flags and whether the node can run
|
||||
# it; the name goes to create_vm_from_cloud_init(cpu_type=...)
|
||||
...
|
||||
```
|
||||
|
||||
A hypervisor that provisions VMs from cloud images declares, per guest OS,
|
||||
the agent cloud-init installs so the hypervisor can read the new VM's IP:
|
||||
`GUEST_AGENTS = {guest_os: (packages, runcmd)}`. Its keys are the guests the
|
||||
driver can provision, and `create_vm_from_cloud_init(guest_os=...)` refuses
|
||||
any other. A QEMU-based driver takes `provisioning.QEMU_GUEST_AGENTS`
|
||||
(Linux, FreeBSD, OpenBSD). `provisioning.network_config()` writes the
|
||||
network-config v2 every cloud-init flavour reads (FreeBSD's nuageinit reads
|
||||
no other), and `provisioning.split_compression()` says how a packed image is
|
||||
unpacked; both are the same for every hypervisor.
|
||||
|
||||
### OS / Linux
|
||||
|
||||
```python
|
||||
@@ -259,8 +460,8 @@ class LinuxDriver(OSDriver):
|
||||
# return List[ServiceDict]
|
||||
...
|
||||
|
||||
def get_users(self):
|
||||
# return List[UserDict]
|
||||
def get_os_users(self):
|
||||
# return List[UserDict] -- not NAPALM's get_users, whose shape differs
|
||||
...
|
||||
|
||||
def get_processes(self):
|
||||
|
||||
@@ -29,70 +29,209 @@ Role bases -- what a device *is*:
|
||||
* :class:`~napalm_device_types.storage.StorageDriver`
|
||||
* :class:`~napalm_device_types.phone.PhoneDriver`
|
||||
* :class:`~napalm_device_types.media.MediaDriver`
|
||||
* :class:`~napalm_device_types.management_controller.ManagementControllerDriver`
|
||||
* :class:`~napalm_device_types.residential_gateway.ResidentialGatewayDriver`
|
||||
|
||||
Function classes -- what a device *can do*. Shared behaviour lives here once
|
||||
instead of being restated on every role that happens to need it:
|
||||
|
||||
* :class:`~napalm_device_types.channel.CommandChannelMixin`
|
||||
* :class:`~napalm_device_types.config_lifecycle.ConfigLifecycleMixin`
|
||||
* :class:`~napalm_device_types.config_sync.ConfigSyncMixin`
|
||||
* :class:`~napalm_device_types.container_engine.ContainerEngineMixin`
|
||||
* :class:`~napalm_device_types.dhcp.DhcpServerMixin`
|
||||
* :class:`~napalm_device_types.firewall_rules.FirewallRuleMixin`
|
||||
* :class:`~napalm_device_types.health_metrics.HealthMetricsMixin`
|
||||
* :class:`~napalm_device_types.host_status.HostStatusMixin`
|
||||
* :class:`~napalm_device_types.host_reboot.HostRebootMixin`
|
||||
* :class:`~napalm_device_types.interface_filter.InterfaceFilterMixin`
|
||||
* :class:`~napalm_device_types.kernel.KernelFactsMixin`
|
||||
* :class:`~napalm_device_types.listening.ListeningSocketsMixin`
|
||||
* :class:`~napalm_device_types.mac_acl.MacAclMixin`
|
||||
* :class:`~napalm_device_types.nat_vpn.NatVpnMixin`
|
||||
* :class:`~napalm_device_types.packages.PackageManagementMixin`
|
||||
* :class:`~napalm_device_types.ping_sweep.PingSweepMixin`
|
||||
* :class:`~napalm_device_types.services.ServiceControlMixin`
|
||||
* :class:`~napalm_device_types.systemd.SystemdServicesMixin`
|
||||
* :class:`~napalm_device_types.updates.UpdateMixin`
|
||||
* :class:`~napalm_device_types.virtual_ips.VirtualIpsMixin`
|
||||
|
||||
Introspection -- :func:`~napalm_device_types.roles.roles_of`,
|
||||
:func:`~napalm_device_types.roles.role_keys_of` and
|
||||
:func:`~napalm_device_types.roles.primary_role_of`.
|
||||
"""
|
||||
|
||||
from napalm_device_types.base import DeviceTypeDriver, FingerprintRule, PortSpec
|
||||
from napalm_device_types.base import DeviceTypeDriver, FingerprintRule, PortSpec, rules_max_weight
|
||||
from napalm_device_types.access_point import AccessPointDriver
|
||||
from napalm_device_types.channel import (
|
||||
ByteStream,
|
||||
CommandChannelMixin,
|
||||
CommandResult,
|
||||
ParamikoExecStream,
|
||||
open_stream_on_transport,
|
||||
run_on_transport,
|
||||
)
|
||||
from napalm_device_types.config_lifecycle import ConfigLifecycleMixin
|
||||
from napalm_device_types.config_sync import ConfigSyncMixin
|
||||
from napalm_device_types.container_engine import (
|
||||
ContainerEngineConnection,
|
||||
ContainerEngineMixin,
|
||||
ContainerEngineUnavailable,
|
||||
)
|
||||
from napalm_device_types.dhcp import DhcpServerMixin, normalize_cidr, normalize_mac
|
||||
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.fixes import (
|
||||
EnsurePackageMixin,
|
||||
PackageProxyMixin,
|
||||
SnmpAgentMixin,
|
||||
run_fix_steps,
|
||||
)
|
||||
from napalm_device_types.health_metrics import HealthMetricsMixin
|
||||
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.lag import add_lag_interfaces
|
||||
from napalm_device_types.listening import (
|
||||
LISTENING_SOCKETS_COMMAND,
|
||||
ListeningSocketsMixin,
|
||||
ListeningSocketsUnavailable,
|
||||
parse_listening_sockets,
|
||||
)
|
||||
from napalm_device_types.mac_acl import MacAclMixin
|
||||
from napalm_device_types.management_controller import ManagementControllerDriver
|
||||
from napalm_device_types.media import MediaDriver
|
||||
from napalm_device_types.models import (
|
||||
POWER_ACTIONS,
|
||||
ConfigSyncDict,
|
||||
DhcpHaDict,
|
||||
DhcpHaPeerDict,
|
||||
EventLogEntryDict,
|
||||
FirmwareDict,
|
||||
HardwareInventoryDict,
|
||||
ManagementProtocolsDict,
|
||||
PowerAction,
|
||||
PowerStateDict,
|
||||
FixResultDict,
|
||||
FixStepDict,
|
||||
FixStepOutcomeDict,
|
||||
SnmpAgentSettingsDict,
|
||||
SnmpHealthProfile,
|
||||
SnmpInterfacesSpec,
|
||||
SnmpMemorySpec,
|
||||
SnmpValueSpec,
|
||||
)
|
||||
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.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.virtual_ips import (
|
||||
VIRTUAL_IPS_COMMAND,
|
||||
VirtualIpsMixin,
|
||||
parse_virtual_ips,
|
||||
)
|
||||
from napalm_device_types.residential_gateway import ResidentialGatewayDriver
|
||||
from napalm_device_types.ssh_server import (
|
||||
SSH_ALGORITHM_CLASSES,
|
||||
OpenSshServerMixin,
|
||||
SshHardeningPlan,
|
||||
SshHardeningResult,
|
||||
plan_ssh_hardening,
|
||||
)
|
||||
from napalm_device_types.storage import StorageDriver
|
||||
from napalm_device_types.switch import SwitchDriver
|
||||
|
||||
__all__ = [
|
||||
"EnsurePackageMixin",
|
||||
"FixResultDict",
|
||||
"FixStepDict",
|
||||
"FixStepOutcomeDict",
|
||||
"PackageProxyMixin",
|
||||
"SnmpAgentMixin",
|
||||
"SnmpAgentSettingsDict",
|
||||
"run_fix_steps",
|
||||
"SnmpHealthProfile",
|
||||
"SnmpInterfacesSpec",
|
||||
"SnmpMemorySpec",
|
||||
"SnmpValueSpec",
|
||||
"AccessPointDriver",
|
||||
"ByteStream",
|
||||
"CommandChannelMixin",
|
||||
"CommandResult",
|
||||
"ContainerEngineConnection",
|
||||
"ContainerEngineMixin",
|
||||
"ContainerEngineUnavailable",
|
||||
"ParamikoExecStream",
|
||||
"open_stream_on_transport",
|
||||
"run_on_transport",
|
||||
"ConfigLifecycleMixin",
|
||||
"ConfigSyncDict",
|
||||
"ConfigSyncMixin",
|
||||
"DhcpHaDict",
|
||||
"DhcpHaPeerDict",
|
||||
"DeviceTypeDriver",
|
||||
"DhcpServerMixin",
|
||||
"FingerprintRule",
|
||||
"rules_max_weight",
|
||||
"FirewallDriver",
|
||||
"FirewallRuleMixin",
|
||||
"HealthMetricsMixin",
|
||||
"HOST_STATUS_COMMAND",
|
||||
"HostStatusMixin",
|
||||
"HostRebootMixin",
|
||||
"HypervisorDriver",
|
||||
"InterfaceFilterMixin",
|
||||
"MacAclMixin",
|
||||
"ManagementControllerDriver",
|
||||
"POWER_ACTIONS",
|
||||
"PowerAction",
|
||||
"PowerStateDict",
|
||||
"HardwareInventoryDict",
|
||||
"EventLogEntryDict",
|
||||
"FirmwareDict",
|
||||
"ManagementProtocolsDict",
|
||||
"MediaDriver",
|
||||
"NatVpnMixin",
|
||||
"OSDriver",
|
||||
"PackageManagementMixin",
|
||||
"APT_UPGRADABLE_COMMAND",
|
||||
"DNF_SECURITY_COMMAND",
|
||||
"parse_apt_upgradable",
|
||||
"parse_dnf_security",
|
||||
"parse_host_status",
|
||||
"strip_terminal_codes",
|
||||
"KernelFactsMixin",
|
||||
"KERNEL_FACTS_COMMAND",
|
||||
"parse_kernel_facts",
|
||||
"LISTENING_SOCKETS_COMMAND",
|
||||
"ListeningSocketsMixin",
|
||||
"ListeningSocketsUnavailable",
|
||||
"parse_listening_sockets",
|
||||
"PhoneDriver",
|
||||
"PingSweepMixin",
|
||||
"PortSpec",
|
||||
@@ -100,7 +239,24 @@ __all__ = [
|
||||
"ServiceControlMixin",
|
||||
"StorageDriver",
|
||||
"SwitchDriver",
|
||||
"SYSTEMD_SERVICES_COMMAND",
|
||||
"SystemdServicesMixin",
|
||||
"SystemdUnavailable",
|
||||
"parse_systemd_services",
|
||||
"UpdateMixin",
|
||||
"VIRTUAL_IPS_COMMAND",
|
||||
"VirtualIpsMixin",
|
||||
"SystemIdentityMixin",
|
||||
"SYSTEM_IDENTITY_COMMAND",
|
||||
"is_placeholder_identity",
|
||||
"parse_system_identity",
|
||||
"OpenSshServerMixin",
|
||||
"SSH_ALGORITHM_CLASSES",
|
||||
"SshHardeningPlan",
|
||||
"SshHardeningResult",
|
||||
"plan_ssh_hardening",
|
||||
"parse_virtual_ips",
|
||||
"add_lag_interfaces",
|
||||
"driver_supports_ping",
|
||||
"normalize_cidr",
|
||||
"normalize_mac",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- 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
|
||||
reservation's MAC, a subnet's CIDR). An empty key or one held twice cannot
|
||||
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
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
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()
|
||||
for entry in entries:
|
||||
k = key(entry)
|
||||
if not k:
|
||||
continue
|
||||
if k in by_key:
|
||||
shared.add(k)
|
||||
by_key[k] = entry
|
||||
for k in shared:
|
||||
del by_key[k]
|
||||
return by_key, shared
|
||||
|
||||
|
||||
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()
|
||||
for entry in entries:
|
||||
k = key(entry)
|
||||
if not k:
|
||||
raise ValueError(f"a desired entry has no {what}")
|
||||
if k in seen:
|
||||
raise ValueError(f"{what} {k!r} is given twice")
|
||||
if k in shared:
|
||||
raise ValueError(f"{what} {k!r} names more than one entry on the device")
|
||||
seen.add(k)
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Awaitable, Callable, Dict, Optional
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
# ── UCD-MIB OIDs ──────────────────────────────────────────────────────────────
|
||||
OID_SYS_UPTIME = "1.3.6.1.2.1.25.1.1.0" # hrSystemUptime (centiseconds)
|
||||
@@ -25,6 +25,12 @@ 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_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_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)
|
||||
|
||||
@@ -42,7 +48,7 @@ def ticks_to_seconds(raw: str | None) -> int | None:
|
||||
|
||||
|
||||
def build_if_metrics(
|
||||
metrics: dict,
|
||||
metrics: Dict[str, Any],
|
||||
descr: Dict[str, str],
|
||||
speed: Dict[str, str],
|
||||
in_oct: Dict[str, str],
|
||||
@@ -50,19 +56,22 @@ def build_if_metrics(
|
||||
in_err: Dict[str, str],
|
||||
out_err: Dict[str, str],
|
||||
*,
|
||||
if_skip: re.Pattern | None = None,
|
||||
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,
|
||||
) -> None:
|
||||
if if_skip is None:
|
||||
if_skip = IF_SKIP_DEFAULT
|
||||
interfaces: dict = {}
|
||||
interfaces: Dict[str, Any] = {}
|
||||
for idx, name in descr.items():
|
||||
if if_skip.match(name):
|
||||
continue
|
||||
iface: dict = {"name": name}
|
||||
iface: Dict[str, Any] = {"name": name}
|
||||
try:
|
||||
iface["rx_bytes"] = int(in_oct.get(idx, 0) or 0)
|
||||
iface["tx_bytes"] = int(out_oct.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:
|
||||
@@ -70,8 +79,12 @@ def build_if_metrics(
|
||||
iface["tx_drops"] = tx_val
|
||||
else:
|
||||
iface["tx_errors"] = tx_val
|
||||
high = (high_speed or {}).get(idx)
|
||||
spd = speed.get(idx)
|
||||
iface["speed_mbps"] = int(spd) // 1_000_000 if spd and int(spd) > 0 else 0
|
||||
if high and int(high) > 0:
|
||||
iface["speed_mbps"] = int(high)
|
||||
else:
|
||||
iface["speed_mbps"] = int(spd) // 1_000_000 if spd and int(spd) > 0 else 0
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
interfaces[name] = iface
|
||||
@@ -84,15 +97,17 @@ async def collect_ucd_metrics(
|
||||
snmp_walk: SnmpWalkFn,
|
||||
*,
|
||||
tx_err_is_drop: bool = False,
|
||||
if_skip: re.Pattern | None = None,
|
||||
) -> dict:
|
||||
if_skip: re.Pattern[str] | None = None,
|
||||
) -> 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) = await asyncio.gather(
|
||||
), (
|
||||
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),
|
||||
snmp_get(OID_CPU_IDLE),
|
||||
@@ -113,10 +128,13 @@ async def collect_ucd_metrics(
|
||||
snmp_walk(OID_IF_OUT_OCT),
|
||||
snmp_walk(OID_IF_IN_ERR),
|
||||
snmp_walk(OID_IF_OUT_ERR),
|
||||
snmp_walk(OID_IF_HC_IN_OCT),
|
||||
snmp_walk(OID_IF_HC_OUT_OCT),
|
||||
snmp_walk(OID_IF_HIGH_SPEED),
|
||||
),
|
||||
)
|
||||
|
||||
metrics: dict = {}
|
||||
metrics: Dict[str, Any] = {}
|
||||
|
||||
secs = ticks_to_seconds(uptime_raw)
|
||||
if secs is not None:
|
||||
@@ -161,5 +179,6 @@ async def collect_ucd_metrics(
|
||||
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)
|
||||
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
|
||||
@@ -8,10 +8,12 @@ live connection.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import NamedTuple
|
||||
|
||||
from napalm.base import NetworkDriver
|
||||
|
||||
from napalm_device_types.channel import CommandChannelMixin
|
||||
from napalm_device_types.host_reboot import HostRebootMixin
|
||||
from napalm_device_types.ping_sweep import PingSweepMixin
|
||||
|
||||
@@ -23,12 +25,36 @@ class FingerprintRule(NamedTuple):
|
||||
weight — evidence added to the driver's score on match
|
||||
mandatory — pattern absent → driver immediately disqualified
|
||||
negative — pattern present → weight subtracted instead of added
|
||||
group — rules sharing a group are alternatives that never match
|
||||
together (one vendor's landing page *or* another's); only the
|
||||
strongest of them counts towards the most a driver can score
|
||||
"""
|
||||
|
||||
pattern: str
|
||||
weight: float = 1.0
|
||||
mandatory: bool = False
|
||||
negative: bool = False
|
||||
group: str | None = None
|
||||
|
||||
|
||||
def rules_max_weight(rules: Iterable[FingerprintRule]) -> float:
|
||||
"""The most evidence *rules* can add together.
|
||||
|
||||
Every positive rule counts, except that a group of alternatives counts once,
|
||||
with its strongest rule. Discovery divides a driver's score by its maximum to
|
||||
compare drivers; summing alternatives would make a driver that recognises
|
||||
three vendors' pages lose to one that recognises none.
|
||||
"""
|
||||
total = 0.0
|
||||
group_best: dict[str, float] = {}
|
||||
for rule in rules:
|
||||
if rule.negative or rule.weight <= 0:
|
||||
continue
|
||||
if rule.group is None:
|
||||
total += rule.weight
|
||||
else:
|
||||
group_best[rule.group] = max(group_best.get(rule.group, 0.0), rule.weight)
|
||||
return total + sum(group_best.values())
|
||||
|
||||
|
||||
class PortSpec(NamedTuple):
|
||||
@@ -48,7 +74,7 @@ class PortSpec(NamedTuple):
|
||||
mandatory: bool = False
|
||||
|
||||
|
||||
class DeviceTypeDriver(PingSweepMixin, HostRebootMixin, NetworkDriver):
|
||||
class DeviceTypeDriver(PingSweepMixin, HostRebootMixin, CommandChannelMixin, NetworkDriver): # type: ignore[misc]
|
||||
"""Common base for all netOrk device-type drivers.
|
||||
|
||||
Sits between napalm.base.NetworkDriver and the type-specific abstract
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
# -*- 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
|
||||
not offer was a *public* way for the layer above to use that reach: netOrk sent
|
||||
shell strings through napalm-linux's private ``_send``, an interactive PTY that
|
||||
merges stdout and stderr and has no exit code.
|
||||
|
||||
:class:`CommandChannelMixin` declares two methods, and a driver that can
|
||||
implements them:
|
||||
|
||||
* :meth:`run_command` runs a command and returns stdout, stderr and the exit code.
|
||||
* :meth:`open_stream` starts a command and returns a :class:`ByteStream` to its
|
||||
stdin and stdout. That is how netOrk speaks the Docker Engine API, over
|
||||
``docker system dial-stdio`` (see :mod:`napalm_device_types.container_engine`).
|
||||
|
||||
Like the role bases, the mixin only declares (under ``TYPE_CHECKING``), so
|
||||
``hasattr(driver, "run_command")`` stays a truthful answer.
|
||||
|
||||
:func:`run_on_transport` and :func:`open_stream_on_transport` are the SSH
|
||||
implementation over a paramiko ``Transport``, for any SSH driver: an exec
|
||||
channel, so no PTY, separate stderr and a real exit status. How a command gains
|
||||
root (``sudo -S`` with the password on stdin, ``sudo -n``, or nothing as root)
|
||||
stays with the driver.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import time
|
||||
from typing import Any, NamedTuple, Optional, Protocol, Tuple, TYPE_CHECKING, 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.
|
||||
STDERR_TAIL = 64 * 1024
|
||||
|
||||
_READ = 32 * 1024
|
||||
_POLL = 0.01
|
||||
|
||||
|
||||
class CommandResult(NamedTuple):
|
||||
"""What a command printed, and how it ended."""
|
||||
|
||||
stdout: str
|
||||
stderr: str
|
||||
exit_code: int
|
||||
|
||||
|
||||
class ByteStream(Protocol):
|
||||
"""A running command's stdin and stdout, as bytes.
|
||||
|
||||
``read`` returns ``b""`` at end of stream and raises :class:`TimeoutError`
|
||||
when nothing arrives in time. ``write`` never closes anything:
|
||||
``dial-stdio`` hands the daemon a half-close as "client gone", and the
|
||||
daemon then answers an unfinished request with HTTP 499 (netork#771). Only
|
||||
``close_write`` closes the writing side.
|
||||
"""
|
||||
|
||||
def read(self, max_bytes: int, timeout: Optional[float] = None) -> bytes: ...
|
||||
|
||||
def write(self, data: bytes) -> None: ...
|
||||
|
||||
def close_write(self) -> None: ...
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
@property
|
||||
def exit_status(self) -> Optional[int]: ...
|
||||
|
||||
@property
|
||||
def stderr(self) -> str: ...
|
||||
|
||||
|
||||
class CommandChannelMixin:
|
||||
"""Declares the channel; a driver that can reach a shell on its host implements it."""
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
|
||||
|
||||
def run_command(
|
||||
self,
|
||||
command: str,
|
||||
*,
|
||||
privileged: bool = False,
|
||||
timeout: float = 60,
|
||||
stdin: Optional[bytes] = None,
|
||||
) -> CommandResult:
|
||||
"""Run *command* with the host's shell; *stdin*, if given, is written first."""
|
||||
...
|
||||
|
||||
def open_stream(self, command: str, *, privileged: bool = False) -> ByteStream:
|
||||
"""Start *command* and return a stream to its stdin and stdout."""
|
||||
...
|
||||
|
||||
|
||||
class ParamikoExecStream:
|
||||
"""A :class:`ByteStream` over a paramiko exec channel.
|
||||
|
||||
stderr is drained on every read: stdout and stderr share one window, and an
|
||||
unread stderr would stall the stream.
|
||||
"""
|
||||
|
||||
def __init__(self, channel: Any, *, stderr_limit: int = STDERR_TAIL) -> None:
|
||||
self._channel = channel
|
||||
self._stderr = b""
|
||||
self._limit = stderr_limit
|
||||
|
||||
def _drain_stderr(self) -> None:
|
||||
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:
|
||||
self._channel.settimeout(timeout)
|
||||
try:
|
||||
return cast(bytes, self._channel.recv(max_bytes))
|
||||
except socket.timeout:
|
||||
raise TimeoutError(f"no data within {timeout}s") from None
|
||||
finally:
|
||||
self._drain_stderr()
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
self._channel.sendall(data)
|
||||
|
||||
def close_write(self) -> None:
|
||||
self._channel.shutdown_write()
|
||||
|
||||
def close(self) -> None:
|
||||
self._channel.close()
|
||||
|
||||
@property
|
||||
def exit_status(self) -> Optional[int]:
|
||||
if not self._channel.exit_status_ready():
|
||||
return None
|
||||
return cast(int, self._channel.recv_exit_status())
|
||||
|
||||
@property
|
||||
def stderr(self) -> str:
|
||||
self._drain_stderr()
|
||||
return self._stderr.decode("utf-8", "replace")
|
||||
|
||||
|
||||
def _collect(channel: Any, command: str, timeout: float) -> Tuple[bytes, bytes]:
|
||||
out, err = b"", b""
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
if channel.recv_ready():
|
||||
out += channel.recv(_READ)
|
||||
elif channel.recv_stderr_ready():
|
||||
err += channel.recv_stderr(_READ)
|
||||
elif channel.exit_status_ready():
|
||||
# sshd may send the exit status before the last output: read both to EOF.
|
||||
try:
|
||||
while chunk := channel.recv(_READ):
|
||||
out += chunk
|
||||
while chunk := channel.recv_stderr(_READ):
|
||||
err += chunk
|
||||
except socket.timeout:
|
||||
raise TimeoutError(f"{command!r} did not finish within {timeout}s") from None
|
||||
return out, err
|
||||
elif time.monotonic() > deadline:
|
||||
raise TimeoutError(f"{command!r} did not finish within {timeout}s")
|
||||
else:
|
||||
time.sleep(_POLL)
|
||||
|
||||
|
||||
def run_on_transport(
|
||||
transport: Any, command: str, *, stdin: Optional[bytes] = None, timeout: float = 60
|
||||
) -> CommandResult:
|
||||
"""Run *command* on an exec channel of the paramiko *transport*."""
|
||||
channel = transport.open_session()
|
||||
try:
|
||||
channel.settimeout(timeout)
|
||||
channel.exec_command(command)
|
||||
if stdin is not None:
|
||||
channel.sendall(stdin)
|
||||
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()
|
||||
)
|
||||
finally:
|
||||
channel.close()
|
||||
|
||||
|
||||
def open_stream_on_transport(
|
||||
transport: Any, command: str, *, stdin_prefix: Optional[bytes] = None
|
||||
) -> ParamikoExecStream:
|
||||
"""Start *command* on an exec channel and return its stream.
|
||||
|
||||
*stdin_prefix* is written before anything else, which is how a sudo password
|
||||
reaches ``sudo -S`` ahead of the stream's own bytes.
|
||||
"""
|
||||
channel = transport.open_session()
|
||||
channel.exec_command(command)
|
||||
if stdin_prefix:
|
||||
channel.sendall(stdin_prefix)
|
||||
return ParamikoExecStream(channel)
|
||||
@@ -0,0 +1,35 @@
|
||||
# -*- 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)
|
||||
makes the peer's configuration of the synced services a copy of its own: a
|
||||
change written on the receiving side lasts only until the next push. Who pushes
|
||||
what is therefore a fact a caller needs before it writes to either host.
|
||||
|
||||
Declared under ``if TYPE_CHECKING``: a contract, not a placeholder. A driver
|
||||
mixes it in; no role promises it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from napalm_device_types.models import ConfigSyncDict
|
||||
|
||||
|
||||
class ConfigSyncMixin:
|
||||
"""Where a host pushes its configuration, and for which of its services."""
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def get_config_sync(self) -> ConfigSyncDict:
|
||||
"""
|
||||
Returns the peer this host pushes its configuration to (None when it
|
||||
pushes to no one) and the services whose configuration it pushes, by
|
||||
the name ``get_services`` reports. Sync items the driver has no
|
||||
service name for are listed under ``unmapped`` as the host names them.
|
||||
|
||||
:raises Exception: When the setting could not be read -- never
|
||||
``peer=None`` for "could not ask".
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,147 @@
|
||||
# -*- 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
|
||||
the talking (NetOrk/netork#765). So there is no container model here and no
|
||||
parsing:
|
||||
|
||||
* :meth:`ContainerEngineMixin.container_engines` says which engines the host
|
||||
offers, and which API each speaks.
|
||||
* :meth:`ContainerEngineMixin.open_container_engine` returns a
|
||||
:class:`ContainerEngineConnection`. It opens a stream to the engine's API
|
||||
(``docker system dial-stdio``), and it runs the engine's CLI with arguments
|
||||
the caller chooses. The CLI is there for what the API cannot do: compose,
|
||||
pulls that need the host user's registry login, and private registry digests.
|
||||
|
||||
The driver decides *how* the engine is reached. The hook is
|
||||
:meth:`ContainerEngineMixin._container_engine_binary`. QNAP returns its
|
||||
Container Station path, so callers never see where the binary lives. The
|
||||
command runs through the driver's own :class:`~napalm_device_types.channel.CommandChannelMixin`,
|
||||
whose privilege handling applies unchanged; Docker normally needs none, because
|
||||
the login user is in the ``docker`` group.
|
||||
|
||||
Mixed in by a driver, not by a role base: a Windows host is an
|
||||
:class:`~napalm_device_types.os.OSDriver` too, and without a shell channel it
|
||||
has no ``dial-stdio`` to offer. ``hasattr(driver, "open_container_engine")``
|
||||
stays truthful.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING, 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"}
|
||||
|
||||
_PROBE_TIMEOUT = 15
|
||||
_CLI_TIMEOUT = 120
|
||||
|
||||
|
||||
class ContainerEngineUnavailable(NotImplementedError):
|
||||
"""The host offers no such engine, or this package does not know how to reach it."""
|
||||
|
||||
|
||||
class ContainerEngineConnection:
|
||||
"""The way to one container engine on one host.
|
||||
|
||||
Built by :meth:`ContainerEngineMixin.open_container_engine`. Callers pass
|
||||
arguments; binary, quoting and the channel are the driver's.
|
||||
"""
|
||||
|
||||
def __init__(self, driver: Any, engine: str, binary: str) -> None:
|
||||
self._driver = driver
|
||||
self._binary = binary
|
||||
self.engine = engine
|
||||
|
||||
def _command(self, args: Sequence[str]) -> str:
|
||||
return shlex.join([self._binary, *args])
|
||||
|
||||
def open_api(self) -> ByteStream:
|
||||
"""A stream to the engine's API: HTTP/1.1 over ``system dial-stdio``."""
|
||||
return cast(ByteStream, self._driver.open_stream(self._command(["system", "dial-stdio"])))
|
||||
|
||||
def run_cli(
|
||||
self,
|
||||
args: Sequence[str],
|
||||
*,
|
||||
stdin: Optional[bytes] = None,
|
||||
timeout: float = _CLI_TIMEOUT,
|
||||
privileged: bool = False,
|
||||
) -> CommandResult:
|
||||
"""Run the engine's CLI with *args* and wait for it.
|
||||
|
||||
*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
|
||||
))
|
||||
|
||||
def stream_cli(
|
||||
self, args: Sequence[str], *, merge_stderr: bool = False, privileged: bool = False
|
||||
) -> ByteStream:
|
||||
"""Start the engine's CLI with *args* and stream its output.
|
||||
|
||||
*merge_stderr* folds stderr into the stream, for tools that report
|
||||
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
|
||||
))
|
||||
|
||||
|
||||
class ContainerEngineMixin:
|
||||
"""Adds container engine access to a driver that implements the channel."""
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
|
||||
|
||||
def run_command(
|
||||
self,
|
||||
command: str,
|
||||
*,
|
||||
privileged: bool = False,
|
||||
timeout: float = 60,
|
||||
stdin: Optional[bytes] = None,
|
||||
) -> CommandResult: ...
|
||||
|
||||
def open_stream(self, command: str, *, privileged: bool = False) -> ByteStream: ...
|
||||
|
||||
def grant_container_engine_access(self, user: str) -> FixResultDict:
|
||||
"""Let *user* reach the container engine without privilege (the
|
||||
``docker`` group on most hosts). A fix template: see
|
||||
:mod:`napalm_device_types.fixes`. Declared only; a driver implements it."""
|
||||
...
|
||||
|
||||
def _container_engine_binary(self, engine: str) -> str:
|
||||
"""The host command that is *engine*'s CLI. Override where it is not on PATH."""
|
||||
return engine
|
||||
|
||||
def container_engines(self) -> List[ContainerEngineDict]:
|
||||
"""
|
||||
Returns the container engines the host offers:
|
||||
|
||||
* engine (string) - e.g. "docker"
|
||||
* api (string) - what its API is, e.g. "docker-engine"
|
||||
|
||||
An engine is listed when its CLI exists on the host. Whether the login
|
||||
user may use it is decided by the first API call, which says
|
||||
"permission denied" in the stream's stderr.
|
||||
"""
|
||||
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)
|
||||
if result.exit_code == 0 and result.stdout.strip():
|
||||
found.append({"engine": engine, "api": api})
|
||||
return found
|
||||
|
||||
def open_container_engine(self, engine: str) -> ContainerEngineConnection:
|
||||
"""The connection to *engine*; :class:`ContainerEngineUnavailable` if unknown."""
|
||||
if engine not in ENGINES:
|
||||
raise ContainerEngineUnavailable(f"no way to reach container engine {engine!r}")
|
||||
return ContainerEngineConnection(self, engine, self._container_engine_binary(engine))
|
||||
@@ -26,7 +26,9 @@ from __future__ import annotations
|
||||
import re
|
||||
from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING
|
||||
|
||||
from napalm_device_types._matching import check_desired, index_live
|
||||
from napalm_device_types.models import (
|
||||
DhcpHaDict,
|
||||
DhcpReservationDiffDict,
|
||||
DhcpReservationDict,
|
||||
DhcpReservationUpdateDict,
|
||||
@@ -218,6 +220,20 @@ class DhcpServerMixin:
|
||||
"""
|
||||
...
|
||||
|
||||
def get_dhcp_ha(self) -> DhcpHaDict:
|
||||
"""
|
||||
Returns what the server's own HA configuration declares: whether HA
|
||||
is switched on, which peer this server is, and each peer with its
|
||||
role as the server words it.
|
||||
|
||||
Optional: a driver without it has no ``get_dhcp_ha`` at all, and a
|
||||
caller asks ``callable(getattr(driver, "get_dhcp_ha", None))``.
|
||||
|
||||
:raises Exception: When the configuration could not be read -- never
|
||||
``enabled=False`` for "could not ask".
|
||||
"""
|
||||
...
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Generic, vendor-neutral algorithms.
|
||||
# ------------------------------------------------------------------
|
||||
@@ -238,20 +254,26 @@ class DhcpServerMixin:
|
||||
those apart from ones simply no longer wanted. Callers wanting
|
||||
delete/cleanup semantics must implement that themselves, deliberately.
|
||||
|
||||
Live reservations without a MAC are never matched. A desired one
|
||||
without a MAC, a MAC given twice, or one more than one live
|
||||
reservation holds raise ``ValueError``.
|
||||
|
||||
:param desired: The complete desired reservation set.
|
||||
:returns: ``{"add": [...], "update": [{"uuid", "reservation",
|
||||
"changed_fields"}, ...]}``.
|
||||
:raises ValueError: When a desired reservation cannot name a single live one.
|
||||
"""
|
||||
live_by_mac: Dict[str, DhcpReservationDict] = {
|
||||
normalize_mac(reservation.get("mac")): reservation
|
||||
for reservation in self.get_dhcp_reservations()
|
||||
}
|
||||
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] = []
|
||||
|
||||
for desired_reservation in desired:
|
||||
live = live_by_mac.get(normalize_mac(desired_reservation.get("mac")))
|
||||
live = live_by_mac.get(key(desired_reservation))
|
||||
if live is None:
|
||||
add.append(desired_reservation)
|
||||
continue
|
||||
@@ -333,19 +355,24 @@ class DhcpServerMixin:
|
||||
subnet takes DHCP down for a whole VLAN, and this method cannot tell
|
||||
"no longer wanted" from "was never netOrk's to describe".
|
||||
|
||||
An empty or repeated subnet raises ``ValueError``, as for reservations.
|
||||
|
||||
:param desired: The complete desired subnet set.
|
||||
:returns: ``{"add": [...], "update": [{"uuid", "subnet",
|
||||
"changed_fields"}, ...]}``.
|
||||
:raises ValueError: When a desired subnet cannot name a single live one.
|
||||
"""
|
||||
live_by_cidr: Dict[str, DhcpSubnetDict] = {
|
||||
normalize_cidr(live.get("subnet")): live for live in self.get_dhcp_subnets()
|
||||
}
|
||||
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] = []
|
||||
|
||||
for desired_subnet in desired:
|
||||
live = live_by_cidr.get(normalize_cidr(desired_subnet.get("subnet")))
|
||||
live = live_by_cidr.get(key(desired_subnet))
|
||||
if live is None:
|
||||
add.append(desired_subnet)
|
||||
continue
|
||||
|
||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING
|
||||
|
||||
from napalm_device_types._matching import check_desired, index_live
|
||||
from napalm_device_types.models import (
|
||||
FirewallRuleDict,
|
||||
FirewallRuleDiffDict,
|
||||
@@ -102,19 +103,27 @@ class FirewallRuleMixin:
|
||||
from ones simply no longer wanted. Callers wanting delete/cleanup
|
||||
semantics must implement that themselves, deliberately.
|
||||
|
||||
Live rules without a description are never matched. A desired rule
|
||||
without one, two desired rules with the same one, or one whose
|
||||
description more than one live rule holds raise ``ValueError``: any
|
||||
guess would overwrite a rule the caller did not mean.
|
||||
|
||||
:param desired: The complete desired rule set.
|
||||
:returns: ``{"add": [...], "update": [{"uuid", "rule",
|
||||
"changed_fields"}, ...]}``.
|
||||
:raises ValueError: When a desired rule cannot name a single live one.
|
||||
"""
|
||||
live_by_description: Dict[str, FirewallRuleDict] = {
|
||||
rule["description"]: rule for rule in self.get_firewall_rules()
|
||||
}
|
||||
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] = []
|
||||
|
||||
for desired_rule in desired:
|
||||
live_rule = live_by_description.get(desired_rule["description"])
|
||||
live_rule = live_by_description.get(key(desired_rule))
|
||||
if live_rule is None:
|
||||
add.append(desired_rule)
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# -*- 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
|
||||
its device; the caller supplies every value -- the community, the allowed sources,
|
||||
the proxy URL, the user. A driver whose device takes the whole change in one call
|
||||
(a REST API) overrides the template method instead of its steps.
|
||||
|
||||
None of these is mixed into a role base. A driver mixes in the fixes its device can
|
||||
take, so ``callable(getattr(driver, "configure_snmp_agent", None))`` stays a truthful
|
||||
answer; a driver that inherits one it cannot run sets it to ``None``.
|
||||
|
||||
They replace ``run_device_action`` (docs/STRUCTURE.md):
|
||||
|
||||
========================== ===================================================
|
||||
``fix_snmp`` :meth:`SnmpAgentMixin.configure_snmp_agent`
|
||||
``fix_apt_proxy`` :meth:`PackageProxyMixin.configure_package_proxy`
|
||||
``fix_docker_permissions`` ``ContainerEngineMixin.grant_container_engine_access``
|
||||
``install_lldpd`` and co. :meth:`EnsurePackageMixin.ensure_package`
|
||||
========================== ===================================================
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, List, Optional, Sequence, Tuple, TYPE_CHECKING
|
||||
|
||||
from napalm_device_types.models import (
|
||||
FixResultDict,
|
||||
FixStepDict,
|
||||
FixStepOutcomeDict,
|
||||
PackageDict,
|
||||
SnmpAgentSettingsDict,
|
||||
)
|
||||
|
||||
#: One step of a fix: its name and the call that does it.
|
||||
FixStep = Tuple[str, Callable[[], FixStepOutcomeDict]]
|
||||
|
||||
|
||||
def run_fix_steps(steps: Sequence[FixStep]) -> FixResultDict:
|
||||
"""Run *steps* in order and stop at the first that fails.
|
||||
|
||||
A step that raises has failed, with the exception's message as its output.
|
||||
"""
|
||||
done: List[FixStepDict] = []
|
||||
for name, step in steps:
|
||||
try:
|
||||
outcome = step()
|
||||
ok, output = bool(outcome["ok"]), str(outcome.get("output", ""))
|
||||
except Exception as exc: # noqa: BLE001 - any failure of a step ends the fix
|
||||
ok, output = False, str(exc)
|
||||
done.append({"step": name, "ok": ok, "output": output})
|
||||
if not ok:
|
||||
break
|
||||
return {
|
||||
"success": bool(done) and all(s["ok"] for s in done) and len(done) == len(steps),
|
||||
"steps": done,
|
||||
"output": "\n".join(f"[{s['step']}] {s['output']}".rstrip() for s in done),
|
||||
}
|
||||
|
||||
|
||||
class SnmpAgentMixin:
|
||||
"""Run an SNMP agent on the device that answers the caller.
|
||||
|
||||
The steps: install the agent, write its configuration, let the allowed sources
|
||||
through the host's firewall, start it. Whether it answers is the caller's to ask,
|
||||
with its own SNMP client.
|
||||
"""
|
||||
|
||||
def configure_snmp_agent(self, settings: SnmpAgentSettingsDict) -> FixResultDict:
|
||||
"""Install, configure, open and start the agent with *settings*."""
|
||||
return run_fix_steps(
|
||||
[
|
||||
("install", self._install_snmp_agent),
|
||||
("configure", lambda: self._write_snmp_agent_config(settings)),
|
||||
("firewall", lambda: self._allow_snmp_sources(settings)),
|
||||
("start", self._start_snmp_agent),
|
||||
]
|
||||
)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
|
||||
|
||||
def get_management_peer(self) -> Optional[str]:
|
||||
"""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 --
|
||||
a driver that cannot tell does not implement it."""
|
||||
...
|
||||
|
||||
# The steps a driver implements:
|
||||
|
||||
def _install_snmp_agent(self) -> 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
|
||||
to do where the host has no firewall of its own."""
|
||||
...
|
||||
|
||||
def _start_snmp_agent(self) -> FixStepOutcomeDict:
|
||||
"""(Re)start the agent so it reads the new configuration, and enable it at boot."""
|
||||
...
|
||||
|
||||
|
||||
class EnsurePackageMixin:
|
||||
"""Make sure a package is installed, installing it only where it is missing.
|
||||
|
||||
Works over :class:`~napalm_device_types.packages.PackageManagementMixin`'s
|
||||
``get_packages`` and ``install_package``, which the driver implements.
|
||||
"""
|
||||
|
||||
def ensure_package(self, name: str) -> FixResultDict:
|
||||
"""Install *name* unless it is installed already."""
|
||||
|
||||
def install() -> FixStepOutcomeDict:
|
||||
if any(p.get("name") == name and p.get("installed") for p in self.get_packages()):
|
||||
return {"ok": True, "output": f"{name} is already installed"}
|
||||
result: Any = self.install_package(name) # type: ignore[func-returns-value]
|
||||
# The contract says None (and raise on failure); some drivers still
|
||||
# return {"success", "output"}.
|
||||
if isinstance(result, dict):
|
||||
return {"ok": bool(result.get("success")), "output": str(result.get("output", ""))}
|
||||
return {"ok": True, "output": f"installed {name}"}
|
||||
|
||||
return run_fix_steps([("install", install)])
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
|
||||
def get_packages(self) -> List[PackageDict]: ...
|
||||
|
||||
def install_package(self, name: str, version: str = "") -> None: ...
|
||||
|
||||
|
||||
class PackageProxyMixin:
|
||||
"""Point the device's package manager at a caching proxy."""
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
|
||||
def configure_package_proxy(self, url: str) -> FixResultDict:
|
||||
"""Have the package manager fetch through *url*; an empty *url* removes it."""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EnsurePackageMixin",
|
||||
"FixStep",
|
||||
"PackageProxyMixin",
|
||||
"SnmpAgentMixin",
|
||||
"run_fix_steps",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# -*- 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
|
||||
application polls; a driver whose device publishes values under its own OIDs
|
||||
declares them as ``SNMP_HEALTH_PROFILE`` (:class:`~napalm_device_types.models.SnmpHealthProfile`).
|
||||
This mixin goes in 5.0.
|
||||
|
||||
The collection itself is generic: CPU, memory, uptime, load and per-interface
|
||||
counters come from the same standard OIDs on any UCD-MIB/IF-MIB capable device.
|
||||
Only two details vary by device type, and both are class attributes rather than
|
||||
@@ -15,6 +20,7 @@ implement ``get_health_metrics`` themselves.
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import warnings
|
||||
from typing import Any, Awaitable, Callable, ClassVar, Dict, cast
|
||||
|
||||
from napalm_device_types._ucd_metrics import IF_SKIP_DEFAULT, collect_ucd_metrics
|
||||
@@ -39,7 +45,17 @@ class HealthMetricsMixin:
|
||||
snmp_get: Callable[..., Awaitable[Any]],
|
||||
snmp_walk: Callable[..., Awaitable[Dict[str, Any]]],
|
||||
) -> HealthMetricsDict:
|
||||
"""Collect SNMP health metrics (CPU, memory, uptime, load, interfaces)."""
|
||||
"""Collect SNMP health metrics (CPU, memory, uptime, load, interfaces).
|
||||
|
||||
Deprecated: the application polls; see the module docstring.
|
||||
"""
|
||||
warnings.warn(
|
||||
"HealthMetricsMixin.get_health_metrics is deprecated and goes in 5.0: "
|
||||
"the application polls over SNMP; a driver declares only its OIDs, "
|
||||
"as SNMP_HEALTH_PROFILE",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
# collect_ucd_metrics is untyped shared plumbing; the shape it returns is
|
||||
# the contract HealthMetricsDict describes.
|
||||
return cast(
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
# -*- 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
|
||||
the host restarts, so "reboot required" is part of being patched. Whether the
|
||||
host installs updates on its own (unattended-upgrades, dnf-automatic) decides
|
||||
how far netOrk's maintenance window reaches. Both are read the same way on every
|
||||
Linux host, so the command and its parse live here once and a driver only
|
||||
carries the command across.
|
||||
|
||||
**Reboot required** is any of:
|
||||
|
||||
- ``/var/run/reboot-required`` exists. Ubuntu always writes it; Debian does when
|
||||
update-notifier or unattended-upgrades is installed.
|
||||
- ``needs-restarting -r`` exits 1 (dnf-utils).
|
||||
- A kernel newer than the running one is installed, of the same flavour. A
|
||||
Raspberry Pi carries ``rpi-v8`` and ``rpi-2712`` builds side by side, and only
|
||||
the running one's counts.
|
||||
- On FreeBSD, ``freebsd-version -k`` (the installed kernel) differs from
|
||||
``freebsd-version -r`` (the running one): a patch replaced the kernel.
|
||||
|
||||
It is ``None`` when none of these could be read, for example in a container
|
||||
without a ``/lib/modules`` of its own.
|
||||
|
||||
**Auto updates** is apt's ``APT::Periodic::Unattended-Upgrade`` (set, not "0",
|
||||
``apt-daily-upgrade.timer`` not disabled, and unattended-upgrades installed: a
|
||||
package removed but not purged leaves its "1" behind) or an enabled
|
||||
``dnf-automatic-install.timer``. ``dnf-automatic.timer`` installs only with
|
||||
``apply_updates = yes`` in ``automatic.conf`` (dnf5's in ``dnf5-plugins/``
|
||||
wins); by default it only downloads. It is ``None`` on a host with neither apt
|
||||
nor dnf-automatic. When they last ran
|
||||
successfully is ``/var/lib/apt/periodic/upgrade-stamp``, which
|
||||
``apt.systemd.daily`` touches only after unattended-upgrade succeeded.
|
||||
``update-success-stamp`` would say nothing: every ``apt-get update`` touches it,
|
||||
whoever runs it. How often they are meant to run is the same setting, in apt's
|
||||
units (a bare number is days).
|
||||
|
||||
**An interrupted package manager** is dpkg left halfway, the way apt checks it:
|
||||
numbered files left in ``/var/lib/dpkg/updates``, or a package in a state only
|
||||
a run cut off leaves behind (half-installed, unpacked, half-configured, waiting
|
||||
for triggers, or flagged for reinstallation). apt then refuses to install
|
||||
anything until ``dpkg --configure -a`` has run, while ``apt.systemd.daily``
|
||||
exits quietly every day (NetOrk/netork#738). While dpkg, apt, aptitude or
|
||||
unattended-upgrade is running, packages are halfway on purpose -- also between
|
||||
two of apt's dpkg calls -- so the answer is then ``None``, as on a host without
|
||||
dpkg. unattended-upgrade is told from Ubuntu's idle shutdown helper by its
|
||||
command line (:data:`BUSY_CHECK`).
|
||||
|
||||
**Filesystems** are how full ``/``, ``/boot`` and ``/var`` are, from POSIX
|
||||
``df -Pk``: where an upgrade unpacks a kernel, builds its initrd and keeps the
|
||||
package files. A kernel upgrade that ran out of room there left dpkg halfway on
|
||||
a Proxmox host whose root had been 94 % full for a week (NetOrk/netork#862). A
|
||||
directory that is not a mount of its own is reported as the filesystem it lives
|
||||
on, so each filesystem appears once. ``available`` is what df says an ordinary
|
||||
user can still write, which on ext4 is less than total minus used. How full is
|
||||
too full is not decided here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
from napalm_device_types.models import (
|
||||
FilesystemUsageDict,
|
||||
HostStatusDict,
|
||||
InterruptedPackageDict,
|
||||
)
|
||||
from napalm_device_types.terminal import strip_terminal_codes
|
||||
|
||||
_BEGIN = "HSTAT_BEGIN"
|
||||
_END = "HSTAT_END"
|
||||
_REBOOT_FILE = "/var/run/reboot-required"
|
||||
_APT_TIMER = "apt-daily-upgrade.timer"
|
||||
_DNF_TIMER = "dnf-automatic.timer"
|
||||
_DNF_INSTALL_TIMER = "dnf-automatic-install.timer"
|
||||
_DNF_TIMERS = (_DNF_TIMER, _DNF_INSTALL_TIMER)
|
||||
#: dnf4's, then dnf5's: the last ``apply_updates`` printed is the one that holds.
|
||||
_DNF_AUTOMATIC_CONF = "/etc/dnf/automatic.conf /etc/dnf/dnf5-plugins/automatic.conf"
|
||||
_UPGRADE_STAMP = "/var/lib/apt/periodic/upgrade-stamp"
|
||||
_DPKG_JOURNAL = "/var/lib/dpkg/updates"
|
||||
#: Where an upgrade writes: the kernel's modules, its initrd, the package files.
|
||||
_FILESYSTEMS = "/ /boot /var"
|
||||
#: Prints ``[dpkg-busy]`` while a process that leaves packages halfway is working:
|
||||
#: dpkg, apt, apt-get or aptitude by name, unattended-upgrade by its command line.
|
||||
#: Its name is cut to 15 characters, ``unattended-upgr``, the same as that of
|
||||
#: ``unattended-upgrade-shutdown --wait-for-signal``, which Ubuntu keeps running all
|
||||
#: the time and which installs nothing.
|
||||
BUSY_CHECK = (
|
||||
"{ ps -e -o comm= 2>/dev/null | grep -qxE 'dpkg|apt|apt-get|aptitude' || "
|
||||
"ps -e -o args= 2>/dev/null | grep -qE '(^|[ /])unattended-upgrade( |$)'; } "
|
||||
"&& echo '[dpkg-busy]'; "
|
||||
)
|
||||
|
||||
#: One line, POSIX ``sh``, read-only, no privileges. The frame markers are
|
||||
#: printed in two halves so that an echoing transport does not show them early.
|
||||
#: Each timer is asked on its own: older systemd prints nothing for an unknown
|
||||
#: unit, which would shift a combined answer. Run through a pipe, so nothing in
|
||||
#: it sees a terminal and colours its output.
|
||||
HOST_STATUS_COMMAND = (
|
||||
"{ printf '%s%s\\n' HSTAT_ BEGIN; "
|
||||
f"[ -f {_REBOOT_FILE} ] && echo '[reboot-required]'; "
|
||||
"if command -v needs-restarting >/dev/null 2>&1; then echo '[needs-restarting]'; "
|
||||
"needs-restarting -r >/dev/null 2>&1; echo $?; fi; "
|
||||
"echo '[kernel]'; uname -r; echo '[modules]'; ls -1 /lib/modules 2>/dev/null; "
|
||||
"if command -v freebsd-version >/dev/null 2>&1; then "
|
||||
"echo '[freebsd-kernel]'; freebsd-version -k; "
|
||||
"echo '[freebsd-running]'; freebsd-version -r; fi; "
|
||||
"if command -v apt-config >/dev/null 2>&1; then echo '[apt-config]'; "
|
||||
"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 "
|
||||
"&& 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 "
|
||||
f"{BUSY_CHECK}"
|
||||
f"echo '[dpkg-journal]'; ls -1 {_DPKG_JOURNAL} 2>/dev/null | head -n 20; "
|
||||
"echo '[dpkg-audit]'; dpkg-query -W -f='${db:Status-Abbrev} ${Package}\\n' 2>/dev/null "
|
||||
"| awk 'substr($0, 2, 1) ~ /[HUFWt]/ || substr($0, 3, 1) == \"R\"' | head -n 50; fi; "
|
||||
f"echo '[filesystems]'; df -Pk {_FILESYSTEMS}; "
|
||||
f"echo '[timers]'; for u in {_APT_TIMER} {' '.join(_DNF_TIMERS)}; do "
|
||||
'printf \'%s %s\\n\' "$u" "$(systemctl is-enabled "$u" 2>/dev/null)"; done; '
|
||||
"echo '[dnf-automatic]'; grep -h '^[[:space:]]*apply_updates[[:space:]]*=' "
|
||||
f"{_DNF_AUTOMATIC_CONF} 2>/dev/null; "
|
||||
"printf '%s%s\\n' HSTAT_ END; } 2>/dev/null | cat"
|
||||
)
|
||||
|
||||
_PERIODIC = re.compile(r'^APT::Periodic::Unattended-Upgrade\s+"([^"]*)"')
|
||||
_APPLY_UPDATES = re.compile(r"^apply_updates\s*=\s*(\S*)")
|
||||
#: What libdnf takes for a true boolean.
|
||||
_TRUE = frozenset({"1", "yes", "true", "on"})
|
||||
_OFF_STATES = frozenset({"disabled", "masked"})
|
||||
_INTERVAL = re.compile(r"(\d+)([smhd]?)")
|
||||
_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "": 86400}
|
||||
_JOURNAL_ENTRY = re.compile(r"\d+")
|
||||
#: One line of ``df -P``: device, 1024-blocks, used, available, capacity, mountpoint.
|
||||
#: The device and the mountpoint may hold blanks; the four numbers between them do not.
|
||||
_DF_LINE = re.compile(r"^.+?\s+(\d+)\s+(\d+)\s+(\d+)\s+(?:\d+%|-)\s+(/.*)$")
|
||||
#: dpkg's second status letter -> its word, for the states a cut-off run leaves.
|
||||
_HALFWAY = {
|
||||
"H": "half-installed",
|
||||
"U": "unpacked",
|
||||
"F": "half-configured",
|
||||
"W": "triggers-awaited",
|
||||
"t": "triggers-pending",
|
||||
}
|
||||
|
||||
|
||||
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] = []
|
||||
for line in lines[start + 1 : end]:
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
current = sections.setdefault(line[1:-1], [])
|
||||
elif line:
|
||||
current.append(line)
|
||||
return sections
|
||||
|
||||
|
||||
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]:
|
||||
"""The newest installed kernel of the running flavour, if it is newer than the
|
||||
running one; otherwise None.
|
||||
|
||||
The flavour is what follows the last ``-`` (``generic``, ``amd64``, ``pve``,
|
||||
``v8``), or on an RPM kernel (``5.14.0-427.13.1.el9_4.x86_64``) the arch
|
||||
after the release; a kernel of another flavour is never a reason to reboot.
|
||||
"""
|
||||
version, flavour = _kernel_flavour(running)
|
||||
same = [k for k in installed if _kernel_flavour(k)[1] == flavour]
|
||||
if not same:
|
||||
return None
|
||||
newest = max(same, key=lambda k: _version_key(_kernel_flavour(k)[0]))
|
||||
if _version_key(_kernel_flavour(newest)[0]) > _version_key(version):
|
||||
return newest
|
||||
return None
|
||||
|
||||
|
||||
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("-")
|
||||
if not sep:
|
||||
return release, ""
|
||||
if tail[:1].isdigit() and "." in tail:
|
||||
rel, _, arch = tail.rpartition(".")
|
||||
return f"{base}-{rel}", arch
|
||||
return base, tail
|
||||
|
||||
|
||||
def _freebsd_reboot(sections: Dict[str, List[str]]) -> Optional[Tuple[bool, Optional[str]]]:
|
||||
"""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]
|
||||
if not (installed and running):
|
||||
return None
|
||||
if installed != running:
|
||||
return True, f"kernel {installed} installed, {running} running"
|
||||
return False, None
|
||||
|
||||
|
||||
def _reboot(sections: Dict[str, List[str]]) -> Tuple[Optional[bool], Optional[str]]:
|
||||
if "reboot-required" in sections:
|
||||
return True, f"{_REBOOT_FILE} is present"
|
||||
needs = sections.get("needs-restarting")
|
||||
if needs and needs[0] == "1":
|
||||
return True, "needs-restarting -r reports a reboot"
|
||||
freebsd = _freebsd_reboot(sections)
|
||||
if freebsd is not None:
|
||||
return freebsd
|
||||
running = (sections.get("kernel") or [""])[0]
|
||||
modules = sections.get("modules") or []
|
||||
newer = kernel_reboot_pending(running, modules) if running and modules else None
|
||||
if newer:
|
||||
return True, f"kernel {newer} installed, {running} running"
|
||||
if needs or modules:
|
||||
return False, None
|
||||
return None, None
|
||||
|
||||
|
||||
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]:
|
||||
"""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:
|
||||
"""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]:
|
||||
timers = _timer_states(sections)
|
||||
if timers.get(_DNF_INSTALL_TIMER) == "enabled":
|
||||
return True
|
||||
downloads_only = timers.get(_DNF_TIMER) == "enabled"
|
||||
if downloads_only and _dnf_applies(sections):
|
||||
return True
|
||||
if "apt-config" not in sections:
|
||||
return False if downloads_only else None
|
||||
switched_on = _periodic(sections) not in (None, "", "0")
|
||||
installed = "unattended-upgrade" in sections
|
||||
return switched_on and installed and timers.get(_APT_TIMER) not in _OFF_STATES
|
||||
|
||||
|
||||
def _interval(sections: Dict[str, List[str]]) -> Optional[int]:
|
||||
"""apt's interval in seconds, the way ``apt.systemd.daily`` reads it; 0 for "always"."""
|
||||
value = _periodic(sections)
|
||||
if value == "always":
|
||||
return 0
|
||||
match = _INTERVAL.fullmatch(value or "")
|
||||
if not match or int(match.group(1)) == 0:
|
||||
return None
|
||||
return int(match.group(1)) * _UNIT_SECONDS[match.group(2)]
|
||||
|
||||
|
||||
def _last_success(sections: Dict[str, List[str]]) -> Optional[int]:
|
||||
stamp = (sections.get("upgrade-stamp") or [""])[0]
|
||||
return int(stamp) if stamp.isdigit() else None
|
||||
|
||||
|
||||
def _halfway(line: str) -> Optional[InterruptedPackageDict]:
|
||||
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]]:
|
||||
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]
|
||||
journal = any(_JOURNAL_ENTRY.fullmatch(f) for f in sections.get("dpkg-journal") or [])
|
||||
return bool(packages) or journal, packages
|
||||
|
||||
|
||||
def _filesystems(sections: Dict[str, List[str]]) -> Optional[List[FilesystemUsageDict]]:
|
||||
"""Each filesystem df named, once and in its order; None when it named none."""
|
||||
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:
|
||||
continue
|
||||
total, used, available = (int(match.group(i)) * 1024 for i in (1, 2, 3))
|
||||
mountpoint = match.group(4)
|
||||
found.setdefault(
|
||||
mountpoint,
|
||||
{"mountpoint": mountpoint, "total": total, "used": used, "available": available},
|
||||
)
|
||||
return list(found.values()) or None
|
||||
|
||||
|
||||
def parse_host_status(output: str) -> HostStatusDict:
|
||||
"""Parse what :data:`HOST_STATUS_COMMAND` printed.
|
||||
|
||||
:raises ValueError: when the output carries no intact report.
|
||||
"""
|
||||
sections = _sections(output)
|
||||
required, reason = _reboot(sections)
|
||||
interrupted, packages = _dpkg(sections)
|
||||
return {
|
||||
"reboot_required": required,
|
||||
"reboot_reason": reason,
|
||||
"auto_updates": _auto_updates(sections),
|
||||
"auto_updates_last_success": _last_success(sections),
|
||||
"auto_updates_interval": _interval(sections),
|
||||
"package_manager_interrupted": interrupted,
|
||||
"interrupted_packages": packages,
|
||||
"filesystems": _filesystems(sections),
|
||||
}
|
||||
|
||||
|
||||
class HostStatusMixin:
|
||||
"""Adds :meth:`get_host_status` to a driver that can run a command on a Linux host.
|
||||
|
||||
The template form, like :class:`~napalm_device_types.kernel.KernelFactsMixin`:
|
||||
the reading and its parse are the same everywhere, and a driver supplies only
|
||||
:meth:`_run_host_status_command`. Mixed in by the drivers that can, so
|
||||
``hasattr(driver, "get_host_status")`` stays a truthful answer.
|
||||
"""
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
|
||||
|
||||
def _run_host_status_command(self, command: str) -> str:
|
||||
"""Run *command* on the host with ``sh`` and return what it printed."""
|
||||
...
|
||||
|
||||
def get_host_status(self) -> HostStatusDict:
|
||||
"""
|
||||
Returns whether the host needs a reboot, whether it patches itself, and
|
||||
whether its package manager was left halfway.
|
||||
|
||||
* reboot_required (bool or None)
|
||||
* reboot_reason (string or None)
|
||||
* auto_updates (bool or None)
|
||||
* auto_updates_last_success (Unix time or None)
|
||||
* auto_updates_interval (seconds or None)
|
||||
* package_manager_interrupted (bool or None)
|
||||
* interrupted_packages (list of {name, state})
|
||||
* filesystems (list of {mountpoint, total, used, available} in bytes, or None)
|
||||
|
||||
:raises ValueError: if the host's output carried no intact report.
|
||||
"""
|
||||
return parse_host_status(self._run_host_status_command(HOST_STATUS_COMMAND))
|
||||
@@ -14,6 +14,7 @@ from typing import Any, Dict, List, TYPE_CHECKING
|
||||
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 (
|
||||
NICConfigDict,
|
||||
NetworkTargetDict,
|
||||
@@ -21,6 +22,7 @@ from napalm_device_types.models import (
|
||||
StorageTargetDict,
|
||||
StorageVolumeDict,
|
||||
VMConfigDict,
|
||||
VMCpuTypeDict,
|
||||
VMDict,
|
||||
VMProvisionResultDict,
|
||||
VMStatusDict,
|
||||
@@ -43,11 +45,11 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
ROLE: str = "hypervisor"
|
||||
TYPE_LABEL: str = "Hypervisor"
|
||||
|
||||
#: What cloud-init installs and starts on a VM provisioned through this
|
||||
#: driver, so the hypervisor can read the guest's IP address back.
|
||||
GUEST_AGENT_PACKAGES: tuple[str, ...] = ("qemu-guest-agent",)
|
||||
GUEST_AGENT_RUNCMD: tuple[str, ...] = ("systemctl enable --now qemu-guest-agent",)
|
||||
|
||||
#: What cloud-init installs and runs on a VM provisioned through this
|
||||
#: driver, per guest OS, so the hypervisor can read the guest's IP
|
||||
#: address back. The guest operating systems listed here are the ones
|
||||
#: the driver can provision; netOrk reads this off the class.
|
||||
GUEST_AGENTS: GuestAgents = {"linux": QEMU_GUEST_AGENTS["linux"]}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -462,6 +464,8 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
ssh_public_keys: List[str] | None = None,
|
||||
disk_resize_gb: int | None = None,
|
||||
storage: str | None = None,
|
||||
cpu_type: str | None = None,
|
||||
guest_os: str = "linux",
|
||||
download_timeout: int = 300,
|
||||
timeout: int = 180,
|
||||
) -> VMProvisionResultDict:
|
||||
@@ -503,6 +507,20 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
disk on (a name returned by ``get_image_storages()``). If None,
|
||||
the driver auto-detects the first enabled, node-available storage
|
||||
whose content includes "images".
|
||||
cpu_type (string | None) - virtual CPU model for the VM (a name
|
||||
returned by ``get_vm_cpu_types()``). If None, the driver uses the
|
||||
entry that listing marks ``default``. A driver without
|
||||
``get_vm_cpu_types()`` offers no choice and must reject any
|
||||
value other than None with ValueError; one that has it raises
|
||||
ValueError for a name it does not list or that is not
|
||||
``available`` on this node, before creating anything.
|
||||
guest_os (string) - the operating system in the image, a key of
|
||||
``GUEST_AGENTS`` (``"linux"``, ``"freebsd"``, ``"openbsd"``). The
|
||||
driver sets up the VM's hardware for it (OS type, how the guest
|
||||
agent is attached) and, where the guest needs it, writes the
|
||||
network-config itself (``provisioning.network_config``). A
|
||||
guest_os the driver does not list raises ValueError before
|
||||
anything is created. Default ``"linux"``.
|
||||
download_timeout (int) - maximum seconds to wait for the image download
|
||||
(skipped entirely if already cached on the hypervisor). Default 300.
|
||||
timeout (int) - maximum seconds to wait for the remaining provisioning
|
||||
@@ -618,3 +636,23 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
as ``create_vm_from_cloud_init``'s ``storage`` argument.
|
||||
"""
|
||||
...
|
||||
|
||||
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.
|
||||
|
||||
Optional: a hypervisor whose VMs have no per-VM CPU model (VMware
|
||||
sets CPU compatibility per cluster) does not implement it, and a
|
||||
caller then offers no choice.
|
||||
|
||||
Every model the driver knows is listed, also those this node's CPU
|
||||
cannot run -- marked ``available: False`` -- so a picker can show why
|
||||
an option is missing. Exactly one entry is ``default``: the model
|
||||
``create_vm_from_cloud_init`` uses when ``cpu_type`` is None.
|
||||
|
||||
Returns:
|
||||
List[VMCpuTypeDict] - each entry's ``name`` is directly usable as
|
||||
``create_vm_from_cloud_init``'s ``cpu_type`` argument.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,171 @@
|
||||
# -*- 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 --
|
||||
a module that is neither loaded nor shipped, a subsystem the kernel was built
|
||||
without. Reading that is identical on every Linux host, so the command and its
|
||||
parse live here once and a driver only carries the command across: SSH,
|
||||
an API's exec endpoint, whatever it has.
|
||||
|
||||
The command is read-only and needs no privileges. It frames its report and
|
||||
sends it gzipped and base64-encoded, for two reasons: nothing in the payload can
|
||||
then look like a shell prompt to a screen-scraping transport, and a kernel's
|
||||
build configuration (~300 kB on a distribution kernel) crosses as a fifth of
|
||||
that.
|
||||
|
||||
What the four lists mean for a module, and why "not loaded" alone is never
|
||||
"absent": a module that is not loaded can still be loaded on demand -- by an
|
||||
attacker too, where autoloading reaches it. Only a module that is neither
|
||||
loaded, nor compiled in, nor shipped for this kernel is one it cannot have.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import gzip
|
||||
import zlib
|
||||
from typing import Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from napalm_device_types.models import KernelFactsDict
|
||||
|
||||
_BEGIN = "KFACTS_BEGIN"
|
||||
_END = "KFACTS_END"
|
||||
|
||||
#: One line, POSIX ``sh``, read-only. The frame markers are printed in two
|
||||
#: halves so that a transport which echoes the command does not show them early.
|
||||
KERNEL_FACTS_COMMAND = (
|
||||
"r=$(uname -r); m=/lib/modules/$r; "
|
||||
"printf '%s%s\\n' KFACTS_ BEGIN; "
|
||||
"{ echo '[release]'; echo \"$r\"; "
|
||||
"if [ -r /proc/modules ]; then echo '[loaded]'; cut -d' ' -f1 /proc/modules; fi; "
|
||||
"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; "
|
||||
"} 2>/dev/null | gzip -c | base64; "
|
||||
"printf '%s%s\\n' KFACTS_ END"
|
||||
)
|
||||
|
||||
|
||||
def module_name(raw: str) -> str:
|
||||
"""A module as the kernel names it: no path, no ``.ko`` suffix, ``_`` for ``-``.
|
||||
|
||||
``kernel/net/can/can-raw.ko.zst`` and ``can_raw`` are the same module; the
|
||||
kernel itself treats dash and underscore alike.
|
||||
"""
|
||||
base = raw.strip().rsplit("/", 1)[-1]
|
||||
suffix = base.find(".ko")
|
||||
if suffix != -1:
|
||||
base = base[:suffix]
|
||||
return base.replace("-", "_").lower()
|
||||
|
||||
|
||||
def _report(output: str) -> str:
|
||||
lines = [line.strip() for line in output.splitlines()]
|
||||
try:
|
||||
start = lines.index(_BEGIN)
|
||||
end = lines.index(_END, start)
|
||||
except ValueError:
|
||||
raise ValueError("no kernel facts in the output") from None
|
||||
try:
|
||||
packed = base64.b64decode("".join(lines[start + 1 : end]), validate=True)
|
||||
report = gzip.decompress(packed).decode()
|
||||
except (binascii.Error, OSError, EOFError, zlib.error, UnicodeDecodeError) as exc:
|
||||
raise ValueError(f"the kernel facts could not be decoded: {exc}") from exc
|
||||
if "[release]" not in report.splitlines():
|
||||
raise ValueError("the kernel facts are empty: gzip or base64 is missing on the host")
|
||||
return report
|
||||
|
||||
|
||||
def _sections(report: str) -> Dict[str, List[str]]:
|
||||
sections: Dict[str, List[str]] = {}
|
||||
current: Optional[List[str]] = None
|
||||
for line in report.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
current = sections.setdefault(line[1:-1], [])
|
||||
elif line and current is not None:
|
||||
current.append(line)
|
||||
return sections
|
||||
|
||||
|
||||
def _config(lines: List[str]) -> Dict[str, str]:
|
||||
config: Dict[str, str] = {}
|
||||
for line in lines:
|
||||
option, sep, value = line.partition("=")
|
||||
if not sep:
|
||||
continue
|
||||
if len(value) >= 2 and value[0] == value[-1] == '"':
|
||||
value = value[1:-1]
|
||||
config[option] = value
|
||||
return config
|
||||
|
||||
|
||||
def parse_kernel_facts(output: str) -> KernelFactsDict:
|
||||
"""Parse what :data:`KERNEL_FACTS_COMMAND` printed.
|
||||
|
||||
A section the command did not print -- the file was missing or unreadable --
|
||||
comes back ``None``, never empty.
|
||||
|
||||
:raises ValueError: when the output carries no intact report.
|
||||
"""
|
||||
sections = _sections(_report(output))
|
||||
|
||||
def names(key: str) -> Optional[List[str]]:
|
||||
if key not in sections:
|
||||
return None
|
||||
return sorted({module_name(line) for line in sections[key]})
|
||||
|
||||
release = sections.get("release") or [""]
|
||||
return {
|
||||
"release": release[0],
|
||||
"loaded": names("loaded"),
|
||||
"builtin": names("builtin"),
|
||||
"available": names("available"),
|
||||
"config": _config(sections["config"]) if "config" in sections else None,
|
||||
}
|
||||
|
||||
|
||||
class KernelFactsMixin:
|
||||
"""Adds :meth:`get_kernel_facts` to a driver that can run a command on a Linux host.
|
||||
|
||||
The template form (README, "Function classes"): the reading and its parse are
|
||||
the same everywhere, so they are concrete here, and a driver supplies only
|
||||
:meth:`_run_kernel_facts_command` -- how a command reaches its host. Mixed in
|
||||
by the drivers that can, not by :class:`~napalm_device_types.os.OSDriver`:
|
||||
a Windows host is an OS driver too and has no Linux kernel to read, and
|
||||
``hasattr(driver, "get_kernel_facts")`` has to stay a truthful answer.
|
||||
"""
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
|
||||
|
||||
def _run_kernel_facts_command(self, command: str) -> str:
|
||||
"""Run *command* on the host with ``sh`` and return what it printed."""
|
||||
...
|
||||
|
||||
def get_kernel_facts(self) -> KernelFactsDict:
|
||||
"""
|
||||
Returns what the running kernel has built and loaded.
|
||||
|
||||
* release (string) - ``uname -r``
|
||||
* loaded (list or None) - loaded modules, from ``/proc/modules``
|
||||
* builtin (list or None) - modules compiled into the kernel image
|
||||
* available (list or None) - modules shipped for this kernel
|
||||
* config (dict or None) - the build configuration's set options
|
||||
|
||||
``None`` means the source could not be read.
|
||||
|
||||
Example::
|
||||
|
||||
{
|
||||
"release": "6.1.0-25-amd64",
|
||||
"loaded": ["nf_tables", "tipc"],
|
||||
"builtin": ["tcp_cubic"],
|
||||
"available": ["can_raw", "nf_tables", "tipc"],
|
||||
"config": {"CONFIG_TIPC": "m", "CONFIG_HZ": "250"},
|
||||
}
|
||||
|
||||
:raises ValueError: if the host's output carried no intact report.
|
||||
"""
|
||||
return parse_kernel_facts(self._run_kernel_facts_command(KERNEL_FACTS_COMMAND))
|
||||
@@ -0,0 +1,57 @@
|
||||
# -*- 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
|
||||
to, and never the trunk itself. Turning those tags into one row per trunk is
|
||||
the same for every vendor, so it lives here once; a driver only has to set
|
||||
``trunk_group`` on member ports and, where the device says so, pass the mode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
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]]:
|
||||
"""Return *interfaces* plus one logical entry per ``trunk_group``.
|
||||
|
||||
The LAG entry is up/enabled if any member is, its speed is the members'
|
||||
sum, and ``lag_members`` lists them in port order. A LAG the driver
|
||||
already reported is left as it is. *interfaces* itself is not modified.
|
||||
|
||||
:param lag_modes: ``{lag_name: "lacp" | "trunk"}``. A LAG without a known
|
||||
mode gets no ``lag_mode`` key rather than a guessed one.
|
||||
"""
|
||||
result = dict(interfaces)
|
||||
groups: Dict[str, List[str]] = {}
|
||||
for name, iface in interfaces.items():
|
||||
group = iface.get("trunk_group")
|
||||
if group:
|
||||
groups.setdefault(group, []).append(name)
|
||||
|
||||
for group, members in groups.items():
|
||||
if group in result:
|
||||
continue
|
||||
members = sorted(members, key=_port_order)
|
||||
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)})",
|
||||
"last_flapped": -1.0,
|
||||
"speed": sum(max(float(interfaces[m].get("speed") or 0), 0.0) for m in members),
|
||||
"mtu": -1,
|
||||
"mac_address": "",
|
||||
"lag_members": members,
|
||||
}
|
||||
if lag_modes and group in lag_modes:
|
||||
lag["lag_mode"] = lag_modes[group]
|
||||
result[group] = lag
|
||||
return result
|
||||
@@ -0,0 +1,278 @@
|
||||
# -*- 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
|
||||
listens on -- ``0.0.0.0:5432`` is, ``127.0.0.1:5432`` is not -- and that is read
|
||||
the same way on every Linux host. So the command and its parse live here once,
|
||||
and a driver only carries the command across.
|
||||
|
||||
**One round trip.** ``ss -lntup`` lists every listening TCP and bound UDP
|
||||
socket with the processes holding it; for each of those processes,
|
||||
``/proc/<pid>/cgroup`` says which systemd service or container it runs in. The
|
||||
report is framed, and a report whose end is missing raises: a list cut short
|
||||
must never read as sockets that closed.
|
||||
|
||||
**Root, and without it.** Only root sees every process behind a socket.
|
||||
The whole script therefore goes to the host as one ``sh -c`` argument -- a
|
||||
driver that prefixes ``sudo -n`` would otherwise run only its first command as
|
||||
root. When that call brings no report back (no sudo, a wrong password), the
|
||||
command runs again without privilege: the sockets are still worth having, and
|
||||
the reading says it is not ``attributed``.
|
||||
|
||||
**No ``-H``.** iproute2 before 4.10 has no option to leave out the header and
|
||||
fails on it, which would read as nothing listening. The parse skips the header
|
||||
instead.
|
||||
|
||||
**Without ``ss``, ``netstat``.** OpenWrt's busybox and old net-tools hosts have
|
||||
no ``ss``; ``netstat -lntup`` lists the same sockets with ``PID/Program``, and
|
||||
the cgroups are read for its PIDs alike. On OpenWrt the cgroup names the procd
|
||||
service (``/services/<name>/<instance>``), a jailed one too, whose PID is not the
|
||||
one procd reports. A host with neither raises :class:`ListeningSocketsUnavailable`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from shlex import quote
|
||||
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
from napalm_device_types.models import ListeningSocketDict, ListeningSocketsDict
|
||||
from napalm_device_types.terminal import strip_terminal_codes
|
||||
|
||||
_BEGIN = "SOCK_BEGIN"
|
||||
_END = "SOCK_END"
|
||||
_NO_SS = "no-ss"
|
||||
_RC_RE = re.compile(r"^__SS_RC=(\d+)$")
|
||||
|
||||
#: One line, POSIX ``sh``, read-only. The frame markers are printed in two
|
||||
#: halves so that a transport which echoes the command does not show them early.
|
||||
#: ``ss`` is in ``/usr/sbin`` on some systems, outside a login user's ``PATH``.
|
||||
#: ``netstat`` names a socket's process as ``PID/Program``, ``ss`` as ``pid=PID``.
|
||||
LISTENING_SOCKETS_COMMAND = (
|
||||
"PATH=$PATH:/usr/sbin:/sbin; "
|
||||
"printf '%s%s\\n' SOCK_ BEGIN; "
|
||||
"if command -v ss >/dev/null 2>&1; then "
|
||||
"t=ss; s=$(ss -lntup 2>&1); r=$?; "
|
||||
"pids=$(printf '%s\\n' \"$s\" | grep -o 'pid=[0-9]*' | cut -d= -f2); "
|
||||
"elif command -v netstat >/dev/null 2>&1; then "
|
||||
"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\"; "
|
||||
"echo '[cgroups]'; "
|
||||
"for p in $(printf '%s\\n' \"$pids\" | sort -u); do "
|
||||
"sed \"s|^|$p |\" /proc/$p/cgroup 2>/dev/null; done; "
|
||||
"else echo '[no-ss]'; fi; "
|
||||
"printf '%s%s\\n' SOCK_ END"
|
||||
)
|
||||
|
||||
_PROTOCOLS = frozenset({"tcp", "udp"})
|
||||
#: netstat names the IPv6 sockets of net-tools ``tcp6``/``udp6``; busybox does not.
|
||||
_NETSTAT_PROTOCOLS = {"tcp": "tcp", "tcp6": "tcp", "udp": "udp", "udp6": "udp"}
|
||||
#: ``1604/dropbear``; net-tools prints ``700/sshd: /usr/sbin``.
|
||||
_PROGRAM_RE = re.compile(r"^(\d+)/(\S*)")
|
||||
#: ``users:(("nginx",pid=901,fd=6),("nginx",pid=900,fd=6))``
|
||||
_USER_RE = re.compile(r'\("((?:[^"\\]|\\.)*)",pid=(\d+),fd=\d+\)')
|
||||
#: The service a cgroup path runs in: its deepest ``*.service`` component.
|
||||
_SERVICE_RE = re.compile(r"/([^/]+)\.service(?=/|$)")
|
||||
#: OpenWrt's procd: ``/services/<name>/<instance>``.
|
||||
_PROCD_RE = re.compile(r"^/services/([^/]+)(?:/|$)")
|
||||
#: A container's cgroup: ``docker-<id>.scope`` (systemd driver), ``/docker/<id>`` (cgroupfs).
|
||||
_CONTAINER_RE = re.compile(
|
||||
r"(?:docker|libpod)-([0-9a-f]{64})\.scope|/(?:docker|libpod)/([0-9a-f]{64})(?=/|$)"
|
||||
)
|
||||
|
||||
|
||||
class ListeningSocketsUnavailable(NotImplementedError):
|
||||
"""The host has neither ``ss`` nor ``netstat``; nothing to read, nothing to retry."""
|
||||
|
||||
|
||||
def _frame(output: 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 listening socket report in the output") from None
|
||||
return lines[start + 1 : end]
|
||||
|
||||
|
||||
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], [])
|
||||
else:
|
||||
current.append(line)
|
||||
return sections
|
||||
|
||||
|
||||
def _split_local(local: str) -> Optional[Tuple[str, Optional[str], int]]:
|
||||
"""``[fe80::1]%eth0:546`` (or ``[fe80::1%eth0]:546``) -> ``("fe80::1", "eth0", 546)``;
|
||||
None if no port."""
|
||||
host, sep, port = local.rpartition(":")
|
||||
if not sep or not port.isdigit():
|
||||
return None
|
||||
address, _, zone = host.partition("%")
|
||||
zone = zone.rstrip("]")
|
||||
if address.startswith("["):
|
||||
address = address[1:].rstrip("]")
|
||||
return address or "*", zone or None, int(port)
|
||||
|
||||
|
||||
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] = {}
|
||||
for line in lines:
|
||||
pid, _, entry = line.partition(" ")
|
||||
parts = entry.split(":", 2)
|
||||
if not pid.isdigit() or len(parts) != 3:
|
||||
continue
|
||||
hierarchy, controllers, path = parts
|
||||
if (hierarchy == "0" and controllers == "") or controllers == "name=systemd":
|
||||
paths[int(pid)] = path
|
||||
return paths
|
||||
|
||||
|
||||
def _unit(path: Optional[str]) -> Optional[str]:
|
||||
services = _SERVICE_RE.findall(path or "")
|
||||
if services:
|
||||
return str(services[-1])
|
||||
procd = _PROCD_RE.match(path or "")
|
||||
return str(procd.group(1)) if procd else None
|
||||
|
||||
|
||||
def _container(path: Optional[str]) -> Optional[str]:
|
||||
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],
|
||||
) -> ListeningSocketDict:
|
||||
address, interface, port = local
|
||||
path = paths.get(pid) if pid is not None else None
|
||||
return {
|
||||
"proto": proto,
|
||||
"address": address,
|
||||
"port": port,
|
||||
"interface": interface,
|
||||
"process": process,
|
||||
"pid": pid,
|
||||
"unit": _unit(path),
|
||||
"container_id": _container(path),
|
||||
}
|
||||
|
||||
|
||||
def _ss_socket(line: str, paths: Dict[int, str]) -> Optional[ListeningSocketDict]:
|
||||
parts = line.split()
|
||||
if len(parts) < 5 or parts[0] not in _PROTOCOLS:
|
||||
return None
|
||||
local = _split_local(parts[4])
|
||||
if local is None:
|
||||
return None
|
||||
users = _USER_RE.findall(line)
|
||||
process, pid = (users[0][0], int(users[0][1])) if users else (None, None)
|
||||
return _entry(parts[0], local, process, pid, paths)
|
||||
|
||||
|
||||
def _netstat_socket(line: str, paths: Dict[int, str]) -> Optional[ListeningSocketDict]:
|
||||
"""``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()
|
||||
proto = _NETSTAT_PROTOCOLS.get(parts[0]) if parts else None
|
||||
if proto is None or len(parts) < 6:
|
||||
return None
|
||||
local = _split_local(parts[3])
|
||||
if local is None:
|
||||
return None
|
||||
process, pid = None, None
|
||||
for token in parts[5:7]:
|
||||
program = _PROGRAM_RE.match(token)
|
||||
if program:
|
||||
pid, process = int(program.group(1)), program.group(2).rstrip(":") or None
|
||||
break
|
||||
return _entry(proto, local, process, pid, paths)
|
||||
|
||||
|
||||
_PARSERS = {"ss": _ss_socket, "netstat": _netstat_socket}
|
||||
|
||||
|
||||
def parse_listening_sockets(output: str) -> List[ListeningSocketDict]:
|
||||
"""Parse what :data:`LISTENING_SOCKETS_COMMAND` printed, sorted by protocol,
|
||||
port and address.
|
||||
|
||||
:raises ListeningSocketsUnavailable: when the host has neither ``ss`` nor ``netstat``.
|
||||
:raises ValueError: when the output carries no intact report, or the tool failed.
|
||||
"""
|
||||
sections = _sections(_frame(output))
|
||||
if _NO_SS in sections:
|
||||
raise ListeningSocketsUnavailable("the host has neither ss nor netstat")
|
||||
tool = "netstat" if "netstat" in sections else "ss"
|
||||
lines = sections.get(tool, [])
|
||||
statuses = [m.group(1) for m in map(_RC_RE.match, lines) if m]
|
||||
if not statuses or statuses[-1] != "0":
|
||||
detail = " ".join(line for line in lines if not _RC_RE.match(line))[:200]
|
||||
raise ValueError(f"{tool} did not list the sockets: {detail or 'no exit status'}")
|
||||
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 ""))
|
||||
|
||||
|
||||
class ListeningSocketsMixin:
|
||||
"""Adds :meth:`get_listening_sockets` to a driver that can run a command on a Linux host.
|
||||
|
||||
With ``ss``, or ``netstat`` where there is none (OpenWrt's busybox).
|
||||
|
||||
The template form (README, "Function classes"): the command and its parse
|
||||
are the same everywhere, so they are concrete here, and a driver supplies
|
||||
only :meth:`_run_listening_sockets_command` -- how a command reaches its
|
||||
host, and how it gains root there. Mixed in by the drivers that can, not by
|
||||
:class:`~napalm_device_types.os.OSDriver`: a Windows host is an OS driver
|
||||
too, and ``hasattr(driver, "get_listening_sockets")`` has to stay truthful.
|
||||
"""
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
|
||||
|
||||
def _run_listening_sockets_command(self, command: str, *, privileged: bool) -> str:
|
||||
"""Run *command* on the host and return what it printed; as root
|
||||
when *privileged*. The command is a single ``sh -c`` invocation."""
|
||||
...
|
||||
|
||||
def get_listening_sockets(self) -> ListeningSocketsDict:
|
||||
"""
|
||||
Returns every listening TCP and bound UDP socket, with the process,
|
||||
systemd service and container behind it.
|
||||
|
||||
* attributed (bool) - read as root, so every process is named
|
||||
* sockets (list) - see :class:`~napalm_device_types.models.ListeningSocketDict`
|
||||
|
||||
Example::
|
||||
|
||||
{
|
||||
"attributed": True,
|
||||
"sockets": [
|
||||
{"proto": "tcp", "address": "0.0.0.0", "port": 5432,
|
||||
"interface": None, "process": "postgres", "pid": 812,
|
||||
"unit": "postgresql@16-main", "container_id": None},
|
||||
],
|
||||
}
|
||||
|
||||
:raises ListeningSocketsUnavailable: if the host has neither ``ss`` nor ``netstat``.
|
||||
:raises ValueError: if neither reading carried an intact report.
|
||||
"""
|
||||
command = f"sh -c {quote(LISTENING_SOCKETS_COMMAND)}"
|
||||
try:
|
||||
output = self._run_listening_sockets_command(command, privileged=True)
|
||||
return {"attributed": True, "sockets": parse_listening_sockets(output)}
|
||||
except ValueError:
|
||||
pass
|
||||
output = self._run_listening_sockets_command(command, privileged=False)
|
||||
return {"attributed": False, "sockets": parse_listening_sockets(output)}
|
||||
@@ -24,7 +24,7 @@ class MacAclMixin:
|
||||
|
||||
Keys are SSID names. Each value contains:
|
||||
|
||||
* ssid (string) - SSID name (repeated for convenience)
|
||||
* name (string) - SSID name (repeated for convenience)
|
||||
* policy (string) - ACL mode:
|
||||
|
||||
* ``"allow"`` – whitelist: only listed MACs may associate
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Abstract base class for server baseboard management controllers (BMCs).
|
||||
|
||||
Usage::
|
||||
|
||||
from napalm_device_types import ManagementControllerDriver
|
||||
|
||||
class IdracDriver(ManagementControllerDriver):
|
||||
...
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from napalm_device_types.base import DeviceTypeDriver
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - for type checkers only
|
||||
from napalm_device_types.models import (
|
||||
EventLogEntryDict,
|
||||
FirmwareDict,
|
||||
HardwareInventoryDict,
|
||||
ManagementProtocolsDict,
|
||||
PowerAction,
|
||||
PowerStateDict,
|
||||
)
|
||||
|
||||
|
||||
class ManagementControllerDriver(DeviceTypeDriver):
|
||||
"""
|
||||
Abstract intermediate driver for the management controller of a server
|
||||
(e.g. Supermicro BMC, Dell iDRAC, HPE iLO, Lenovo XCC), reached over
|
||||
Redfish or IPMI rather than SSH.
|
||||
|
||||
A BMC is not the server it manages. ``reboot_host`` restarts the BMC
|
||||
itself (Redfish ``Manager.Reset``), never the managed server; the server is
|
||||
switched on, off and restarted through :meth:`set_power_state`.
|
||||
|
||||
The BMC's sensors (temperatures, fans, PSU power) come through NAPALM's
|
||||
``get_environment``, with ``cpu`` and ``memory`` empty: a BMC does not know
|
||||
the load of the operating system on its server. There is no
|
||||
``HealthMetricsMixin`` here -- that is SNMP's -- and
|
||||
``CommandChannelMixin`` stays unimplemented: a BMC offers no shell to run a
|
||||
command in.
|
||||
"""
|
||||
|
||||
#: Stable key netOrk exposes as ``device_class``. The order in which a
|
||||
#: driver lists its role bases is the ranking; see
|
||||
#: :func:`napalm_device_types.roles.primary_role_of`.
|
||||
ROLE: str = "management_controller"
|
||||
TYPE_LABEL: str = "Management Controller"
|
||||
USES_SSH: bool = False
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def get_power_state(self) -> PowerStateDict:
|
||||
"""
|
||||
Returns the managed server's power state.
|
||||
|
||||
* state (string) - ``"On"``, ``"Off"``, ``"PoweringOn"`` or ``"PoweringOff"``
|
||||
* actions (list) - the :data:`~napalm_device_types.models.PowerAction`
|
||||
values the BMC accepts right now
|
||||
|
||||
Example::
|
||||
|
||||
{"state": "On", "actions": ["GracefulShutdown", "ForceOff", "ForceRestart"]}
|
||||
"""
|
||||
...
|
||||
|
||||
def set_power_state(self, action: PowerAction) -> None:
|
||||
"""
|
||||
Switches, shuts down or restarts the managed server.
|
||||
|
||||
Returns once the BMC has accepted the request; the server takes its
|
||||
time. ``GracefulShutdown`` and ``GracefulRestart`` ask the operating
|
||||
system (ACPI) and may be ignored by it.
|
||||
|
||||
:param action: One of ``On``, ``GracefulShutdown``, ``ForceOff``,
|
||||
``GracefulRestart``, ``ForceRestart`` or ``PowerCycle``.
|
||||
:raises ValueError: If *action* is not one of them.
|
||||
:raises RuntimeError: If the BMC refuses it, e.g. ``On`` for a
|
||||
server that is already on.
|
||||
"""
|
||||
...
|
||||
|
||||
def get_hardware_inventory(self) -> HardwareInventoryDict:
|
||||
"""
|
||||
Returns the managed server's hardware: CPUs, DIMMs, drives, power
|
||||
supplies and NICs, with the server's serial number, its SMBIOS
|
||||
system UUID and the MAC address of each NIC -- what ties the BMC to
|
||||
the operating system running on the server.
|
||||
|
||||
Example::
|
||||
|
||||
{
|
||||
"manufacturer": "Dell Inc.",
|
||||
"model": "PowerEdge R650",
|
||||
"serial_number": "7XK2Q93",
|
||||
"system_uuid": "4c4c4544-0058-4b10-8032-b7c04f513933",
|
||||
"cpus": [{"socket": "CPU.Socket.1", "model": "Intel(R) Xeon(R) Silver 4314",
|
||||
"cores": 16, "threads": 32, "status": "OK"}],
|
||||
"memory": [...],
|
||||
"drives": [...],
|
||||
"power_supplies": [...],
|
||||
"nics": [{"name": "NIC.Integrated.1-1", "mac_address": "b0:7b:25:de:1a:40",
|
||||
"speed_mbps": 10000, "link_up": True}],
|
||||
}
|
||||
"""
|
||||
...
|
||||
|
||||
def get_event_log(self, limit: int = 500) -> List[EventLogEntryDict]:
|
||||
"""
|
||||
Returns the BMC's system event log (SEL, Lifecycle Log, IML),
|
||||
newest first.
|
||||
|
||||
:param limit: At most this many entries.
|
||||
"""
|
||||
...
|
||||
|
||||
def get_firmware_inventory(self) -> List[FirmwareDict]:
|
||||
"""
|
||||
Returns every firmware component the BMC reports: its own, the
|
||||
BIOS, storage controllers, NICs, power supplies.
|
||||
|
||||
Example::
|
||||
|
||||
[
|
||||
{"name": "Integrated Dell Remote Access Controller",
|
||||
"version": "7.00.00.172", "updateable": True},
|
||||
{"name": "BIOS", "version": "1.13.2", "updateable": True},
|
||||
]
|
||||
"""
|
||||
...
|
||||
|
||||
def get_management_protocols(self) -> ManagementProtocolsDict:
|
||||
"""
|
||||
Returns what the BMC itself listens on: IPMI, SSH, HTTP, HTTPS,
|
||||
SNMP, the KVM console and virtual media, each with whether it is
|
||||
enabled and its port.
|
||||
|
||||
Example::
|
||||
|
||||
{
|
||||
"ipmi": {"enabled": False, "port": 623},
|
||||
"ssh": {"enabled": True, "port": 22},
|
||||
"http": {"enabled": False, "port": 80},
|
||||
"https": {"enabled": True, "port": 443},
|
||||
"snmp": {"enabled": False, "port": 161},
|
||||
"kvm": {"enabled": True, "port": 5900},
|
||||
"virtual_media": {"enabled": True, "port": None},
|
||||
}
|
||||
"""
|
||||
...
|
||||
+434
-66
@@ -5,7 +5,7 @@ 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, Optional
|
||||
from typing import Dict, List, Literal, Optional, Tuple, get_args
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
|
||||
@@ -60,11 +60,64 @@ class ServiceDict(TypedDict):
|
||||
|
||||
|
||||
class UpdateDict(TypedDict):
|
||||
"""A software package that has a newer version available in the package repository."""
|
||||
"""A software package that has a newer version available in the package repository.
|
||||
|
||||
``origin`` and ``security`` are optional: a reader that cannot tell leaves
|
||||
them out, and netOrk treats a missing ``security`` as unknown.
|
||||
"""
|
||||
|
||||
name: str
|
||||
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]]
|
||||
#: True for a security update, False for a known other one, None when unknown.
|
||||
security: NotRequired[Optional[bool]]
|
||||
|
||||
|
||||
class InterruptedPackageDict(TypedDict):
|
||||
"""A package an interrupted package manager run left halfway."""
|
||||
|
||||
name: str
|
||||
#: dpkg's word for it: "half-installed", "unpacked", "half-configured",
|
||||
#: "triggers-awaited", "triggers-pending", or "reinstall-required".
|
||||
state: str
|
||||
|
||||
|
||||
class FilesystemUsageDict(TypedDict):
|
||||
"""How full one filesystem is, as ``df -Pk`` says, in bytes."""
|
||||
|
||||
#: Where it is mounted, e.g. "/" or "/boot".
|
||||
mountpoint: str
|
||||
total: int
|
||||
used: int
|
||||
#: What an ordinary user can still write: less than total minus used on a
|
||||
#: filesystem that keeps blocks for root (ext4 keeps 5 %).
|
||||
available: int
|
||||
|
||||
|
||||
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]
|
||||
#: Why, e.g. "kernel 6.8.0-142-generic installed, 6.8.0-139-generic running".
|
||||
reboot_reason: Optional[str]
|
||||
#: True when the host installs updates on its own (unattended-upgrades, dnf-automatic).
|
||||
auto_updates: Optional[bool]
|
||||
#: 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]]
|
||||
#: The packages that run left halfway; empty when only its journal says so.
|
||||
interrupted_packages: NotRequired[List[InterruptedPackageDict]]
|
||||
#: When the host's own automatic updates last ran successfully (Unix time).
|
||||
auto_updates_last_success: NotRequired[Optional[int]]
|
||||
#: How often they are meant to run, in seconds; 0 for every time the timer fires.
|
||||
auto_updates_interval: NotRequired[Optional[int]]
|
||||
#: How full ``/``, ``/boot`` and ``/var`` are, each filesystem once; None when
|
||||
#: it could not be read.
|
||||
filesystems: NotRequired[Optional[List[FilesystemUsageDict]]]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -298,6 +351,112 @@ class NATTranslationDict(TypedDict):
|
||||
age: float
|
||||
|
||||
|
||||
class KernelFactsDict(TypedDict):
|
||||
"""What the running kernel has built and loaded (``KernelFactsMixin.get_kernel_facts``).
|
||||
|
||||
``None`` means *could not be read*; an empty list means *read, and there is
|
||||
nothing*. The difference is what lets a consumer say "this module cannot be
|
||||
loaded on this kernel" rather than "we did not look".
|
||||
|
||||
Module names are normalised by :func:`napalm_device_types.kernel.module_name`:
|
||||
no path, no ``.ko`` suffix, ``-`` folded to ``_``.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class ListeningSocketDict(TypedDict):
|
||||
"""A TCP socket that listens, or a UDP socket that is bound, on the host.
|
||||
|
||||
One entry per socket as ``ss`` lists it. ``address`` is printed the way ss
|
||||
prints it, without brackets or zone: ``0.0.0.0`` and ``::`` are every
|
||||
address of their family, ``*`` every address of both. Whether that is
|
||||
reachable from outside the host is the consumer's call.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class ListeningSocketsDict(TypedDict):
|
||||
"""What ``ListeningSocketsMixin.get_listening_sockets`` read.
|
||||
|
||||
``attributed`` is False when the reading ran without root: ``ss`` then names
|
||||
only the login user's own processes, so a socket without a process means
|
||||
"not told", not "the kernel's".
|
||||
"""
|
||||
|
||||
attributed: bool
|
||||
sockets: List[ListeningSocketDict]
|
||||
|
||||
|
||||
class VirtualIpDict(TypedDict):
|
||||
"""An address the host's HA configuration lets float between machines.
|
||||
|
||||
Declared, not observed: whether the host holds the address right now is in
|
||||
its interface addresses, not here -- unless the mechanism says so itself.
|
||||
CARP does (``holds``): a firewall's interface list need not show the
|
||||
address at all, but CARP's state names the MASTER. Only these fields leave
|
||||
the host -- never a password, an etcd endpoint or a notify script.
|
||||
"""
|
||||
|
||||
mechanism: str # "vip-manager" | "keepalived" | "carp"
|
||||
#: the vrrp_instance name, the vip-manager unit without ".service", or the
|
||||
#: 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]
|
||||
#: 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
|
||||
#: 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]]
|
||||
|
||||
|
||||
class VirtualIpsDict(TypedDict):
|
||||
"""What a driver's ``get_virtual_ips`` read, per mechanism.
|
||||
|
||||
A list is what was looked at -- ``[]`` a host without that mechanism. None is
|
||||
"did not look": a configuration it could not read, an include it could not
|
||||
follow, a host without systemd, a mechanism the driver does not read (a
|
||||
firewall's reading has ``vip_manager`` and ``keepalived`` None). A consumer
|
||||
keeps what it knew for a None, and for a missing ``carp``.
|
||||
"""
|
||||
|
||||
vip_manager: Optional[List[VirtualIpDict]]
|
||||
keepalived: Optional[List[VirtualIpDict]]
|
||||
#: 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]]]
|
||||
|
||||
|
||||
class PortForwardDict(TypedDict):
|
||||
"""A port the WAN side can reach, forwarded to a host inside.
|
||||
|
||||
Shared by firewalls and home gateways (``NatVpnMixin.get_port_forwards``).
|
||||
"""
|
||||
|
||||
name: str
|
||||
protocol: str # "TCP" or "UDP"
|
||||
external_port: int
|
||||
internal_ip: str
|
||||
internal_port: int
|
||||
enabled: bool
|
||||
remote_host: NotRequired[str] # restrict forward to a specific remote source
|
||||
|
||||
|
||||
class SecurityZoneDict(TypedDict):
|
||||
interfaces: List[str]
|
||||
policy: str
|
||||
@@ -441,6 +600,48 @@ class DhcpSubnetDiffDict(TypedDict):
|
||||
update: List[DhcpSubnetUpdateDict]
|
||||
|
||||
|
||||
class DhcpHaPeerDict(TypedDict):
|
||||
"""One server of a DHCP HA relationship, as the server's configuration names it."""
|
||||
|
||||
name: str
|
||||
#: The peer's role as the server words it ("primary", "standby", ...) -- data,
|
||||
#: not a vocabulary every server shares.
|
||||
role: str
|
||||
#: Where the peer's HA listener answers, when the server addresses it by URL.
|
||||
url: NotRequired[str]
|
||||
|
||||
|
||||
class DhcpHaDict(TypedDict):
|
||||
"""What a DHCP server's own HA configuration declares.
|
||||
|
||||
Configured, not observed: whether the pair is in hot standby right now, or
|
||||
one side has taken over, is the server's state and not in here.
|
||||
"""
|
||||
|
||||
#: The name of the service this configuration belongs to, as ``get_services``
|
||||
#: reports it -- the caller matches it to that service.
|
||||
service: NotRequired[str]
|
||||
enabled: bool
|
||||
#: Which of ``peers`` is this server.
|
||||
this_server: NotRequired[str]
|
||||
peers: List[DhcpHaPeerDict]
|
||||
|
||||
|
||||
class ConfigSyncDict(TypedDict):
|
||||
"""Where a host pushes its configuration, and whose.
|
||||
|
||||
``peer`` None: the host pushes to no one (the receiving side of a pair, or a
|
||||
host without the feature switched on).
|
||||
"""
|
||||
|
||||
peer: Optional[str]
|
||||
#: The services whose configuration it pushes, by the name ``get_services``
|
||||
#: reports.
|
||||
services: List[str]
|
||||
#: Sync items the driver has no service name for, as the host names them.
|
||||
unmapped: List[str]
|
||||
|
||||
|
||||
class VPNTunnelDict(TypedDict):
|
||||
type: str
|
||||
local_endpoint: str
|
||||
@@ -470,16 +671,6 @@ class WANStatusDict(TypedDict):
|
||||
link_status: NotRequired[str] # physical line state, e.g. "Up" / "Down"
|
||||
|
||||
|
||||
class PortForwardDict(TypedDict):
|
||||
name: str
|
||||
protocol: str # "TCP" or "UDP"
|
||||
external_port: int
|
||||
internal_ip: str
|
||||
internal_port: int
|
||||
enabled: bool
|
||||
remote_host: NotRequired[str] # restrict forward to a specific remote source
|
||||
|
||||
|
||||
class HostDict(TypedDict):
|
||||
mac: str
|
||||
ip: str
|
||||
@@ -740,62 +931,15 @@ class ApplyUpdatesResultDict(TypedDict):
|
||||
error: NotRequired[str] # error message if success is False
|
||||
|
||||
|
||||
class DockerContainerDict(TypedDict):
|
||||
"""A Docker container entry as returned by ``docker ps -a``."""
|
||||
class ContainerEngineDict(TypedDict):
|
||||
"""One container engine a host offers (``container_engines()``).
|
||||
|
||||
id: str # short container ID
|
||||
name: str # container name(s)
|
||||
image: str # image reference
|
||||
image_version: str # OCI org.opencontainers.image.version label; empty if absent
|
||||
command: str # entrypoint / command
|
||||
created: str # creation timestamp string
|
||||
status: str # human-readable status (e.g. "Up 3 hours")
|
||||
ports: str # port mapping string
|
||||
state: str # "running", "exited", "paused", etc.
|
||||
Only how to reach it: which engine, and which API it speaks. What runs on
|
||||
it is read and modelled above the driver, in netOrk (netork#765).
|
||||
"""
|
||||
|
||||
|
||||
class DockerImageDict(TypedDict):
|
||||
"""A local Docker image entry as returned by ``docker images``."""
|
||||
|
||||
id: str # short image ID
|
||||
repository: str # image repository
|
||||
tag: str # image tag
|
||||
size: str # human-readable size string (e.g. "187MB")
|
||||
created: str # creation timestamp string
|
||||
version: str # OCI org.opencontainers.image.version label; empty if absent
|
||||
|
||||
|
||||
class DockerVolumeDict(TypedDict):
|
||||
"""A Docker volume entry as returned by ``docker volume ls``."""
|
||||
|
||||
name: str # volume name
|
||||
driver: str # volume driver (e.g. "local")
|
||||
mountpoint: str # host filesystem path
|
||||
scope: str # "local" or "global"
|
||||
|
||||
|
||||
class DockerNetworkDict(TypedDict):
|
||||
"""A Docker network entry as returned by ``docker network ls``."""
|
||||
|
||||
id: str # short network ID
|
||||
name: str # network name
|
||||
driver: str # network driver (e.g. "bridge", "host", "overlay")
|
||||
scope: str # network scope
|
||||
ipv6: str # "true" if IPv6 is enabled, "false" otherwise
|
||||
internal: str # "true" if the network is internal, "false" otherwise
|
||||
|
||||
|
||||
class DockerInfoDict(TypedDict):
|
||||
"""Return value of ``get_docker_info()``."""
|
||||
|
||||
available: bool # False if Docker is absent/inaccessible
|
||||
permission_denied: NotRequired[bool] # True when socket access is denied
|
||||
version: NotRequired[str] # Docker Engine version string
|
||||
containers: NotRequired[List[DockerContainerDict]]
|
||||
images: NotRequired[List[DockerImageDict]]
|
||||
volumes: NotRequired[List[DockerVolumeDict]]
|
||||
networks: NotRequired[List[DockerNetworkDict]]
|
||||
outdated_images: NotRequired[List[str]] # image names with a newer remote digest
|
||||
engine: str # "docker"
|
||||
api: str # "docker-engine": the Docker Engine API over ``system dial-stdio``
|
||||
|
||||
|
||||
class DeviceActionResultDict(TypedDict):
|
||||
@@ -849,6 +993,86 @@ class HealthMetricsDict(TypedDict):
|
||||
interfaces: NotRequired[Dict[str, HealthMetricsIfaceDict]]
|
||||
|
||||
|
||||
class FixStepOutcomeDict(TypedDict):
|
||||
"""What one step of a fix routine reports (see ``napalm_device_types.fixes``)."""
|
||||
|
||||
ok: bool
|
||||
output: str
|
||||
|
||||
|
||||
class FixStepDict(TypedDict):
|
||||
"""One step of a fix routine, as ``FixResultDict`` lists it."""
|
||||
|
||||
step: str
|
||||
ok: bool
|
||||
output: str
|
||||
|
||||
|
||||
class FixResultDict(TypedDict):
|
||||
"""The result of a fix routine: every step that ran, up to the first failure."""
|
||||
|
||||
success: bool
|
||||
steps: List[FixStepDict]
|
||||
output: str # the steps' output, one "[step] output" line each
|
||||
|
||||
|
||||
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
|
||||
contact: NotRequired[str] # sysContact
|
||||
location: NotRequired[str] # sysLocation
|
||||
listen: NotRequired[str] # the address the agent binds to; absent: every address
|
||||
|
||||
|
||||
class SnmpValueSpec(TypedDict, total=False):
|
||||
"""Where an SNMP agent publishes one value, inside a ``SnmpHealthProfile``.
|
||||
|
||||
Either ``oids``, scalars asked in order (the first non-empty answer wins), or
|
||||
``table``, a column walked and folded to one value by ``fold``. ``pattern`` is a
|
||||
regular expression whose first group is the number, for an agent that answers
|
||||
with text.
|
||||
"""
|
||||
|
||||
oids: List[str]
|
||||
table: str
|
||||
fold: Literal["first", "average", "max"]
|
||||
pattern: str
|
||||
|
||||
|
||||
class SnmpMemorySpec(TypedDict, total=False):
|
||||
"""Memory: ``total`` with ``used`` or ``free``, or ``percent`` alone."""
|
||||
|
||||
total: SnmpValueSpec
|
||||
used: SnmpValueSpec
|
||||
free: SnmpValueSpec
|
||||
percent: SnmpValueSpec
|
||||
unit: Literal["KiB", "bytes"] # of total, used and free; KiB, as UCD-MIB counts, by default
|
||||
|
||||
|
||||
class SnmpInterfacesSpec(TypedDict, total=False):
|
||||
"""How the IF-MIB counters of a device are to be read."""
|
||||
|
||||
skip: str # a pattern of interface names whose counters are noise, not signal
|
||||
tx_errors_are_drops: bool # the device counts dropped packets as TX errors (OPNsense)
|
||||
|
||||
|
||||
class SnmpHealthProfile(TypedDict, total=False):
|
||||
"""Where a device's SNMP agent publishes its health values.
|
||||
|
||||
A driver declares it as ``SNMP_HEALTH_PROFILE`` when its device does not answer
|
||||
the standard UCD-MIB and IF-MIB; the application polls and computes. What a
|
||||
profile does not name is read the standard way. Uptime is in TimeTicks, CPU in
|
||||
percent busy.
|
||||
"""
|
||||
|
||||
uptime: SnmpValueSpec
|
||||
cpu: SnmpValueSpec
|
||||
memory: SnmpMemorySpec
|
||||
interfaces: SnmpInterfacesSpec
|
||||
|
||||
|
||||
class NICConfigDict(TypedDict):
|
||||
"""Network interface configuration for VM provisioning."""
|
||||
|
||||
@@ -907,6 +1131,24 @@ class StorageTargetDict(TypedDict):
|
||||
available_gb: float # Free capacity in gigabytes
|
||||
|
||||
|
||||
class VMCpuTypeDict(TypedDict):
|
||||
"""A virtual CPU model a new VM may be given (``create_vm_from_cloud_init``'s
|
||||
``cpu_type`` argument), judged against the specific node the VM will be
|
||||
created on.
|
||||
|
||||
``features`` uses the flag names of Linux's ``/proc/cpuinfo`` (``avx``,
|
||||
``avx2``, ``aes`` ...), so a caller can ask "does this model give the guest
|
||||
AVX?" without knowing the hypervisor's model names. A model that passes the
|
||||
host CPU through lists that CPU's own flags.
|
||||
"""
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ping sweep (shared across device types)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -928,3 +1170,129 @@ class PingSweepResultDict(TypedDict):
|
||||
scanned: int # destinations actually probed
|
||||
alive_count: int # entries with alive=True
|
||||
truncated: bool # True if targets were dropped at the sweep cap
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Management controller (BMC: Supermicro, iDRAC, iLO, XCC)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: What ``set_power_state`` does to the managed server; Redfish's ResetType names.
|
||||
PowerAction = Literal[
|
||||
"On",
|
||||
"GracefulShutdown",
|
||||
"ForceOff",
|
||||
"GracefulRestart",
|
||||
"ForceRestart",
|
||||
"PowerCycle",
|
||||
]
|
||||
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
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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
|
||||
part_number: str
|
||||
serial_number: str
|
||||
status: str
|
||||
|
||||
|
||||
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
|
||||
status: str
|
||||
|
||||
|
||||
class BmcPsuDict(TypedDict):
|
||||
slot: str
|
||||
model: str
|
||||
serial_number: str
|
||||
capacity_watts: Optional[int]
|
||||
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]
|
||||
|
||||
|
||||
class HardwareInventoryDict(TypedDict):
|
||||
"""The managed server's hardware, as its BMC sees it."""
|
||||
|
||||
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]
|
||||
|
||||
|
||||
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"
|
||||
message: str
|
||||
|
||||
|
||||
class FirmwareDict(TypedDict):
|
||||
"""One firmware component: the BMC's own, BIOS, a controller, a NIC."""
|
||||
|
||||
name: str # "BMC", "BIOS", "PERC H755 Front"
|
||||
version: str
|
||||
updateable: Optional[bool]
|
||||
|
||||
|
||||
class ProtocolStateDict(TypedDict):
|
||||
enabled: bool
|
||||
port: Optional[int] # None for a protocol without a port of its own
|
||||
|
||||
|
||||
class ManagementProtocolsDict(TypedDict):
|
||||
"""What the BMC itself listens on."""
|
||||
|
||||
ipmi: ProtocolStateDict
|
||||
ssh: ProtocolStateDict
|
||||
http: ProtocolStateDict
|
||||
https: ProtocolStateDict
|
||||
snmp: ProtocolStateDict
|
||||
kvm: ProtocolStateDict
|
||||
virtual_media: ProtocolStateDict
|
||||
|
||||
|
||||
class SystemIdentityDict(TypedDict):
|
||||
"""A host's SMBIOS identity, what ties it to its BMC (:mod:`napalm_device_types.system_identity`).
|
||||
|
||||
Each value is empty when unknown or a vendor placeholder."""
|
||||
|
||||
system_uuid: str # lowercase
|
||||
product_serial: str
|
||||
board_serial: str
|
||||
@@ -1,8 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Address translation and VPN tunnels.
|
||||
|
||||
A home gateway does a subset of what a firewall does, and these two readers
|
||||
are where the sets overlap exactly.
|
||||
A home gateway does a subset of what a firewall does, and these readers are
|
||||
where the sets overlap exactly.
|
||||
|
||||
Declared under ``if TYPE_CHECKING``: these are contracts, not placeholders.
|
||||
Nothing exists at runtime until a concrete driver implements it, so mixing
|
||||
@@ -13,7 +13,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, TYPE_CHECKING
|
||||
|
||||
from napalm_device_types.models import NATTranslationDict, VPNTunnelDict
|
||||
from napalm_device_types.models import NATTranslationDict, PortForwardDict, VPNTunnelDict
|
||||
|
||||
|
||||
class NatVpnMixin:
|
||||
@@ -47,6 +47,46 @@ class NatVpnMixin:
|
||||
"""
|
||||
...
|
||||
|
||||
def get_port_forwards(self) -> List[PortForwardDict]:
|
||||
"""
|
||||
Returns the port forwards that let traffic in from the WAN.
|
||||
|
||||
A port forward here means destination NAT on an interface facing
|
||||
the internet: whoever reaches the external port is let through to
|
||||
``internal_ip``. A redirect between internal networks is
|
||||
destination NAT as well, but it is **not** a port forward and must
|
||||
be left out -- callers read every entry as "this host is reachable
|
||||
from outside". So are rules that only exempt traffic from
|
||||
redirection.
|
||||
|
||||
Each entry contains:
|
||||
|
||||
* name (string) - the rule's description/name
|
||||
* protocol (string) - ``"TCP"`` or ``"UDP"``; a rule for both is
|
||||
two entries. ``"ANY"`` forwards every protocol
|
||||
* external_port (int) - the WAN-side port; the first of a range,
|
||||
``0`` for every port (a whole host forwarded)
|
||||
* internal_ip (string) - the host the traffic is forwarded to
|
||||
* internal_port (int) - the port on that host
|
||||
* enabled (bool) - whether the rule is currently active
|
||||
* remote_host (string, optional) - restricts the forward to a specific
|
||||
remote source address; empty/absent means "any"
|
||||
|
||||
Example::
|
||||
|
||||
[
|
||||
{
|
||||
"name": "Webserver HTTPS",
|
||||
"protocol": "TCP",
|
||||
"external_port": 443,
|
||||
"internal_ip": "192.168.1.10",
|
||||
"internal_port": 443,
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
"""
|
||||
...
|
||||
|
||||
def get_vpn_tunnels(self) -> Dict[str, VPNTunnelDict]:
|
||||
"""
|
||||
Returns the status of VPN tunnels.
|
||||
|
||||
@@ -19,7 +19,6 @@ from napalm_device_types.health_metrics import HealthMetricsMixin
|
||||
from napalm_device_types.models import (
|
||||
CronJobDict,
|
||||
DeviceActionResultDict,
|
||||
DockerInfoDict,
|
||||
ProcessDict,
|
||||
SNMPConfigDict,
|
||||
UserDict,
|
||||
@@ -37,7 +36,8 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM
|
||||
|
||||
#: Stable key netOrk exposes as ``device_class``. The order in which a
|
||||
#: driver lists its role bases is the ranking; see
|
||||
#: :func:`napalm_device_types.roles.primary_role_of`.
|
||||
#: :func:`napalm_device_types.roles.primary_role_of`. A driver for another
|
||||
#: OS sets its own (``ROLE = "windows"``), which replaces this one.
|
||||
ROLE: str = "linux"
|
||||
TYPE_LABEL: str = "OS"
|
||||
|
||||
@@ -63,7 +63,7 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM
|
||||
# Users
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_users(self) -> List[UserDict]:
|
||||
def get_os_users(self) -> List[UserDict]:
|
||||
"""
|
||||
Returns a list of local OS user accounts.
|
||||
|
||||
@@ -193,97 +193,6 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM
|
||||
"""
|
||||
...
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Docker
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_docker_info(self) -> DockerInfoDict:
|
||||
"""
|
||||
Returns information about the local Docker environment.
|
||||
|
||||
If Docker is not installed or the current user lacks access to the
|
||||
Docker socket, returns ``{"available": False}``. When the user has
|
||||
no socket permission, ``permission_denied`` is additionally set to
|
||||
``True``.
|
||||
|
||||
When Docker is available the dict contains:
|
||||
|
||||
* available (bool) - always ``True``
|
||||
* version (string) - Docker Engine version string
|
||||
* containers (list) - all containers (running and stopped), each with:
|
||||
|
||||
* id (string) - short container ID
|
||||
* name (string) - container name(s)
|
||||
* image (string) - image reference
|
||||
* image_version (string) - OCI ``org.opencontainers.image.version`` label; empty if absent
|
||||
* command (string) - entrypoint / command string
|
||||
* created (string) - creation timestamp string
|
||||
* status (string) - human-readable status (e.g. ``"Up 3 hours"``)
|
||||
* ports (string) - port mapping string
|
||||
* state (string) - ``"running"``, ``"exited"``, ``"paused"``, etc.
|
||||
|
||||
* images (list) - local images, each with:
|
||||
|
||||
* id (string) - short image ID
|
||||
* repository (string) - image repository
|
||||
* tag (string) - image tag
|
||||
* size (string) - human-readable size string (e.g. ``"187MB"``)
|
||||
* created (string) - creation timestamp string
|
||||
* version (string) - OCI ``org.opencontainers.image.version`` label; empty if absent
|
||||
|
||||
* volumes (list) - Docker volumes, each with:
|
||||
|
||||
* name (string) - volume name
|
||||
* driver (string) - volume driver
|
||||
* mountpoint (string) - host filesystem path
|
||||
* scope (string) - ``"local"`` or ``"global"``
|
||||
|
||||
* networks (list) - Docker networks, each with:
|
||||
|
||||
* id (string) - short network ID
|
||||
* name (string) - network name
|
||||
* driver (string) - network driver (e.g. ``"bridge"``, ``"host"``, ``"overlay"``)
|
||||
* scope (string) - network scope
|
||||
* ipv6 (string) - ``"true"`` if IPv6 is enabled
|
||||
* internal (string) - ``"true"`` if the network is internal
|
||||
|
||||
* outdated_images (list of strings) - image names where the local digest
|
||||
differs from the latest remote digest; empty list if all images are
|
||||
current or update checks could not be performed.
|
||||
|
||||
Example::
|
||||
|
||||
# Docker not installed:
|
||||
{"available": False}
|
||||
|
||||
# Docker installed, no socket permission:
|
||||
{"available": False, "permission_denied": True}
|
||||
|
||||
# Docker available:
|
||||
{
|
||||
"available": True,
|
||||
"version": "Docker version 27.3.1, build ce12230",
|
||||
"containers": [
|
||||
{
|
||||
"id": "a1b2c3d4e5f6",
|
||||
"name": "my-app",
|
||||
"image": "nginx:latest",
|
||||
"image_version": "1.27.0",
|
||||
"command": "nginx -g 'daemon off;'",
|
||||
"created": "2026-05-28 10:00:00 +0000 UTC",
|
||||
"status": "Up 3 days",
|
||||
"ports": "0.0.0.0:80->80/tcp",
|
||||
"state": "running",
|
||||
},
|
||||
],
|
||||
"images": [...],
|
||||
"volumes": [],
|
||||
"networks": [...],
|
||||
"outdated_images": ["nginx:latest"],
|
||||
}
|
||||
"""
|
||||
...
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Generic device actions
|
||||
# ------------------------------------------------------------------
|
||||
@@ -292,6 +201,11 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM
|
||||
"""
|
||||
Executes a named administrative action on the device.
|
||||
|
||||
**Deprecated** (docs/STRUCTURE.md): the actions become fix templates
|
||||
in :mod:`napalm_device_types.fixes`, whose values the caller supplies --
|
||||
``configure_snmp_agent``, ``configure_package_proxy``,
|
||||
``grant_container_engine_access``, ``ensure_package``. Goes in 5.0.
|
||||
|
||||
This method is an extensibility point for driver-specific one-off
|
||||
operations that do not fit any other NAPALM API method. Each driver
|
||||
documents the action names it supports.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# -*- 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
|
||||
pending update is a security update. apt says so in the suite a candidate comes
|
||||
from (``noble-security``, ``stable-security``), dnf in its update advisories.
|
||||
Reading that is the same for every driver whose host runs apt or dnf, so the
|
||||
parsers live here once and a driver only carries the command across.
|
||||
|
||||
apt: the suites a candidate comes from are its ``origin``; any suite ending in
|
||||
``-security`` makes it a security update. A security fix that a later
|
||||
``-updates`` build superseded shows only ``-updates`` and counts as not
|
||||
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
|
||||
|
||||
#: Read-only, no root needed, in a fixed language so the parse holds, and
|
||||
#: through a pipe: without a terminal apt draws no progress and no terminal
|
||||
#: codes, one of which (``ESC >``) a screen-scraping transport took for a shell
|
||||
#: prompt and stopped reading at. Its exit status is printed inside the group,
|
||||
#: so it is apt's, not cat's.
|
||||
APT_UPGRADABLE_COMMAND = "{ LC_ALL=C apt list --upgradable 2>/dev/null; echo __APT_RC=$?; } | cat"
|
||||
|
||||
#: Read-only. Lists the packages that a pending security advisory covers. A
|
||||
#: failed dnf prints nothing, the same as no advisories, so its exit status is
|
||||
#: printed inside the group, as apt's is.
|
||||
DNF_SECURITY_COMMAND = (
|
||||
"{ LC_ALL=C dnf updateinfo list --security --quiet 2>/dev/null; echo __DNF_RC=$?; } | cat"
|
||||
)
|
||||
|
||||
# openssl/noble-updates,noble-security 3.0.13-0ubuntu3.6 amd64 [upgradable from: 3.0.13-0ubuntu3.5]
|
||||
_APT_LINE = re.compile(r"^(\S+)/(\S+)\s+(\S+)\s+\S+\s+\[upgradable from:\s+(\S+)\]")
|
||||
_SECURITY_SUITE = "-security"
|
||||
_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]:
|
||||
"""Lines as apt printed them: a terminal wraps long ones, and the
|
||||
continuation starts with a space."""
|
||||
lines: List[str] = []
|
||||
for line in output.splitlines():
|
||||
if line.startswith(" ") and lines:
|
||||
lines[-1] += line[1:].rstrip()
|
||||
else:
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def _listing(output: str, status: re.Pattern[str], what: str) -> str:
|
||||
"""The listing without its exit status, or ``ValueError`` when the command
|
||||
failed or the output was cut short -- "could not read" must never look like
|
||||
"nothing pending"."""
|
||||
text = strip_terminal_codes(output)
|
||||
statuses = status.findall(text)
|
||||
if not statuses:
|
||||
raise ValueError(f"{what} reported no exit status; the output was cut short")
|
||||
if statuses[-1] != "0":
|
||||
raise ValueError(f"{what} failed with exit status {statuses[-1]}")
|
||||
return status.sub("", text)
|
||||
|
||||
|
||||
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)
|
||||
is one entry; it counts as a security update if any of its lines does.
|
||||
|
||||
:raises ValueError: when apt failed or its exit status never arrived.
|
||||
"""
|
||||
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:
|
||||
continue
|
||||
name, listed, new_version, current_version = match.groups()
|
||||
suites = list(dict.fromkeys(listed.split(","))) # apt may list a suite twice
|
||||
security = any(suite.endswith(_SECURITY_SUITE) for suite in suites)
|
||||
seen = by_name.get(name)
|
||||
if seen is not None:
|
||||
seen["security"] = bool(seen.get("security")) or security
|
||||
continue
|
||||
by_name[name] = {
|
||||
"name": name,
|
||||
"current_version": current_version,
|
||||
"new_version": new_version,
|
||||
"origin": ",".join(suites),
|
||||
"security": security,
|
||||
}
|
||||
return list(by_name.values())
|
||||
|
||||
|
||||
def nevra_name(nevra: str) -> str:
|
||||
"""The package name of an RPM ``name-[epoch:]version-release.arch``."""
|
||||
without_arch = nevra.rsplit(".", 1)[0]
|
||||
return without_arch.rsplit("-", 2)[0]
|
||||
|
||||
|
||||
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.
|
||||
|
||||
:raises ValueError: when dnf failed or its exit status never arrived -- every
|
||||
update would otherwise read as not security.
|
||||
"""
|
||||
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."):
|
||||
names.add(nevra_name(parts[-1]))
|
||||
return names
|
||||
@@ -68,7 +68,6 @@ class PackageManagementMixin:
|
||||
:param name: Package name as known to the package manager.
|
||||
:param version: Exact version to install. An empty string (default)
|
||||
installs the latest available version.
|
||||
:raises NotImplementedError: If the driver does not support package management.
|
||||
:raises ValueError: If the package name is unknown or the version does
|
||||
not exist in any configured feed.
|
||||
:raises RuntimeError: If the installation fails on the device side
|
||||
@@ -88,7 +87,6 @@ class PackageManagementMixin:
|
||||
The method blocks until the removal is complete.
|
||||
|
||||
:param name: Package name to remove.
|
||||
:raises NotImplementedError: If the driver does not support package management.
|
||||
:raises ValueError: If the package is not currently installed.
|
||||
:raises RuntimeError: If the removal fails on the device side
|
||||
(e.g. other packages depend on it).
|
||||
@@ -105,7 +103,6 @@ class PackageManagementMixin:
|
||||
dictionary. The structure is package-specific.
|
||||
|
||||
:param name: Package name.
|
||||
:raises NotImplementedError: If the driver does not support package management.
|
||||
:raises ValueError: If the package is not installed.
|
||||
|
||||
Example::
|
||||
@@ -140,7 +137,6 @@ class PackageManagementMixin:
|
||||
|
||||
:param name: Package name.
|
||||
:param config: New configuration as a nested dictionary.
|
||||
:raises NotImplementedError: If the driver does not support package management.
|
||||
:raises ValueError: If the package is not installed or the configuration
|
||||
contains invalid values.
|
||||
:raises RuntimeError: If the device rejects the configuration.
|
||||
|
||||
@@ -150,6 +150,8 @@ class PingSweepMixin:
|
||||
return {"ip": destination, "alive": False, "rtt_ms": None, "error": error}
|
||||
|
||||
success = reply.get("success") or {}
|
||||
if not isinstance(success, dict):
|
||||
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
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
What provisioning a VM from a cloud image needs to know about the guest.
|
||||
|
||||
The same for every hypervisor: which agent a guest runs so a QEMU-based
|
||||
hypervisor can read its IP, the network-config cloud-init and FreeBSD's
|
||||
nuageinit both read, and how a packed cloud image is unpacked. How a
|
||||
hypervisor attaches, boots and talks to the VM stays in its driver.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
#: ``{guest_os: (packages, runcmd)}``: what cloud-init installs and runs so
|
||||
#: the hypervisor can read the guest's IP address back.
|
||||
GuestAgents = Mapping[str, tuple[tuple[str, ...], tuple[str, ...]]]
|
||||
|
||||
#: The QEMU guest agent per guest OS, for hypervisors built on QEMU. Package
|
||||
#: names and service commands were verified on FreeBSD 15.1 and OpenBSD 7.9
|
||||
#: cloud images (NetOrk/netork#793).
|
||||
QEMU_GUEST_AGENTS: GuestAgents = {
|
||||
"linux": (("qemu-guest-agent",), ("systemctl enable --now qemu-guest-agent",)),
|
||||
"freebsd": (
|
||||
("qemu-guest-agent",),
|
||||
("sysrc qemu_guest_agent_enable=YES", "service qemu-guest-agent start"),
|
||||
),
|
||||
"openbsd": (("qemu-ga",), ("rcctl enable qemu_ga", "rcctl start qemu_ga")),
|
||||
}
|
||||
|
||||
#: Suffix of a packed image -> the command that writes it unpacked to stdout.
|
||||
_DECOMPRESSORS = {
|
||||
"xz": "xz -dc",
|
||||
"gz": "gzip -dc",
|
||||
"bz2": "bzip2 -dc",
|
||||
"zst": "zstd -dc",
|
||||
}
|
||||
|
||||
|
||||
def network_config(nics: list[tuple[str, bool]]) -> dict[str, Any] | None:
|
||||
"""Network-config v2: DHCP on every ``(mac, dhcp)`` NIC that asks for it.
|
||||
|
||||
Version 2 because FreeBSD's nuageinit reads no other: given the v1 that
|
||||
Proxmox generates, it fails and skips the rest of its first stage,
|
||||
runcmd included. ``None`` when no NIC wants DHCP, so the guest is left
|
||||
alone rather than told to configure nothing.
|
||||
"""
|
||||
ethernets = {
|
||||
f"nic{i}": {"match": {"macaddress": mac.lower()}, "dhcp4": True}
|
||||
for i, (mac, dhcp) in enumerate(nics)
|
||||
if dhcp
|
||||
}
|
||||
return {"version": 2, "ethernets": ethernets} if ethernets else None
|
||||
|
||||
|
||||
def split_compression(filename: str) -> tuple[str, str | None]:
|
||||
"""``(unpacked filename, command that unpacks to stdout)`` for *filename*.
|
||||
|
||||
The command is None for an image that is not packed, which is then
|
||||
imported as it is.
|
||||
"""
|
||||
stem, dot, suffix = filename.rpartition(".")
|
||||
command = _DECOMPRESSORS.get(suffix.lower()) if dot and stem else None
|
||||
return (stem, command) if command else (filename, None)
|
||||
Whitespace-only changes.
@@ -6,8 +6,9 @@ wireless access point in a single consumer device (e.g. AVM FritzBox,
|
||||
ISP-supplied DSL/cable routers). This base class merges the relevant
|
||||
subsets of :class:`~napalm_device_types.firewall.FirewallDriver` and
|
||||
:class:`~napalm_device_types.access_point.AccessPointDriver` plus
|
||||
gateway-specific operations (WAN status, port forwarding, connected
|
||||
hosts).
|
||||
gateway-specific operations (WAN status, connected hosts). Port
|
||||
forwarding is shared with firewalls, in
|
||||
:class:`~napalm_device_types.nat_vpn.NatVpnMixin`.
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -25,7 +26,6 @@ from napalm_device_types.health_metrics import HealthMetricsMixin
|
||||
from napalm_device_types.dhcp import DhcpServerMixin
|
||||
from napalm_device_types.models import (
|
||||
HostDict,
|
||||
PortForwardDict,
|
||||
RadioStatusDict,
|
||||
SSIDDict,
|
||||
WANStatusDict,
|
||||
@@ -85,36 +85,6 @@ class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin,
|
||||
"""
|
||||
...
|
||||
|
||||
def get_port_forwards(self) -> List[PortForwardDict]:
|
||||
"""
|
||||
Returns the configured port forwarding (port mapping) rules.
|
||||
|
||||
Each entry contains:
|
||||
|
||||
* name (string) - the rule's description/name
|
||||
* protocol (string) - ``"TCP"`` or ``"UDP"``
|
||||
* external_port (int) - the WAN-side port
|
||||
* internal_ip (string) - the LAN host the traffic is forwarded to
|
||||
* internal_port (int) - the LAN-side port
|
||||
* enabled (bool) - whether the rule is currently active
|
||||
* remote_host (string, optional) - restricts the forward to a specific
|
||||
remote source address; empty/absent means "any"
|
||||
|
||||
Example::
|
||||
|
||||
[
|
||||
{
|
||||
"name": "Webserver HTTPS",
|
||||
"protocol": "TCP",
|
||||
"external_port": 443,
|
||||
"internal_ip": "192.168.1.10",
|
||||
"internal_port": 443,
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
"""
|
||||
...
|
||||
|
||||
def get_hosts(self) -> List[HostDict]:
|
||||
"""
|
||||
Returns the list of hosts known to the gateway (LAN clients).
|
||||
|
||||
@@ -40,9 +40,13 @@ 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
|
||||
author wrote in the class definition.
|
||||
author wrote in the class definition. A class that sets ``ROLE`` itself
|
||||
replaces the role it inherits rather than adding one: a Windows driver on
|
||||
:class:`~napalm_device_types.os.OSDriver` sets ``ROLE = "windows"`` and is
|
||||
not also ``"linux"``.
|
||||
"""
|
||||
return [cls for cls in driver_cls.__mro__ if _is_role_base(cls)]
|
||||
roles = [cls for cls in driver_cls.__mro__ if _is_role_base(cls)]
|
||||
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]:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"""Init-system services: listing them and starting/stopping them.
|
||||
|
||||
Deliberately *not* the same thing as a NAS's exported shares, which live on
|
||||
``StorageServiceCapability`` as ``get_storage_services``. Those two used to
|
||||
``StorageDriver`` as ``get_storage_services``. Those two used to
|
||||
share the name ``get_services`` with incompatible return types, which is why
|
||||
a QNAP -- storage and OS at once -- could not satisfy both.
|
||||
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
# -*- 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 --
|
||||
``hmac-sha1``, the NIST curves. Taking them out is the same on every host that
|
||||
runs OpenSSH, so the reading, the plan and the write live here once, and a
|
||||
driver only mixes in :class:`OpenSshServerMixin` and supplies ``run_command``.
|
||||
|
||||
**What the server offers is read, not assumed.** ``sshd -T`` prints the lists
|
||||
sshd actually uses, after its defaults, ``sshd_config``, every ``Include`` and
|
||||
first-match-wins. The new list is that one minus what was asked, so nothing the
|
||||
host already restricted comes back, and nothing it allows is dropped by
|
||||
accident.
|
||||
|
||||
**A drop-in, not an edit.** Where ``sshd_config`` includes
|
||||
``/etc/ssh/sshd_config.d/*.conf`` -- Debian and Ubuntu do, at the top -- the
|
||||
lists go to :data:`DROPIN_PATH`, which sorts first and so wins. sshd_config
|
||||
stays as the distribution shipped it, and removing the file undoes the change.
|
||||
Only without such an ``Include`` is sshd_config itself rewritten, after a copy.
|
||||
|
||||
**Nobody is locked out, by three checks.**
|
||||
|
||||
1. The plan never removes the last algorithm of a class, and -- given the
|
||||
lists of the client that manages the host -- never the last one that client
|
||||
speaks. Each one kept back is reported, with why. For host key algorithms
|
||||
only those the host has a key for count (``ssh-keygen -l`` on each
|
||||
``HostKey``); when the keys cannot be read, the host key list is not touched.
|
||||
2. ``sshd -t`` must accept the result, and ``sshd -T`` must show the new lists.
|
||||
A file read before ours can set the same directive; ours is then valid and
|
||||
ignored, and that is reported rather than called a success.
|
||||
3. After the reload, *verify* -- the caller's -- opens a new session. The
|
||||
running one survives a reload, so if the new one cannot get in, this one
|
||||
puts the old state back and reloads again.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from shlex import quote
|
||||
from typing import Callable, Dict, Iterable, List, Mapping, Optional, Sequence, Set, Tuple, 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]] = {
|
||||
"kex": ("kexalgorithms", "KexAlgorithms"),
|
||||
"hostkey": ("hostkeyalgorithms", "HostKeyAlgorithms"),
|
||||
"cipher": ("ciphers", "Ciphers"),
|
||||
"mac": ("macs", "MACs"),
|
||||
}
|
||||
|
||||
MAIN_CONFIG = "/etc/ssh/sshd_config"
|
||||
DROPIN_DIR = "/etc/ssh/sshd_config.d"
|
||||
#: ``00-`` so it sorts before every other drop-in: sshd keeps the first value it reads.
|
||||
DROPIN_PATH = f"{DROPIN_DIR}/00-netork-ssh-hardening.conf"
|
||||
#: What marks a line this module disabled in sshd_config.
|
||||
DISABLED_PREFIX = "# netork: "
|
||||
|
||||
_HEADER = (
|
||||
"# Written by netOrk: the SSH algorithms a security assessment flagged,\n"
|
||||
"# taken out of what this server offers. Delete this file to undo it.\n"
|
||||
)
|
||||
|
||||
#: 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, ...]] = {
|
||||
"ED25519": ("ssh-ed25519",),
|
||||
"RSA": ("rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"),
|
||||
"DSA": ("ssh-dss",),
|
||||
"ED25519-SK": ("sk-ssh-ed25519@openssh.com",),
|
||||
"ECDSA-SK": ("sk-ecdsa-sha2-nistp256@openssh.com",),
|
||||
}
|
||||
_ECDSA_CURVES = ("256", "384", "521")
|
||||
|
||||
_RELOAD = (
|
||||
"if systemctl is-active --quiet ssh 2>/dev/null; then systemctl reload ssh;"
|
||||
" elif systemctl is-active --quiet sshd 2>/dev/null; then systemctl reload sshd;"
|
||||
" else for f in /run/sshd.pid /var/run/sshd.pid; do"
|
||||
' [ -r "$f" ] && kill -HUP "$(cat "$f")" && exit 0; done; exit 0; fi'
|
||||
)
|
||||
|
||||
|
||||
def _directive_key(line: str) -> Optional[str]:
|
||||
"""The algorithm class a global sshd_config line sets, if it sets one."""
|
||||
parts = line.split(None, 1)
|
||||
if not parts:
|
||||
return None
|
||||
word = parts[0].lower()
|
||||
for kind, (keyword, _directive) in SSH_ALGORITHM_CLASSES.items():
|
||||
if word == keyword:
|
||||
return kind
|
||||
return None
|
||||
|
||||
|
||||
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]] = {}
|
||||
for raw in text.splitlines():
|
||||
parts = raw.strip().split(None, 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
kind = _directive_key(parts[0])
|
||||
if kind and kind not in found:
|
||||
found[kind] = [a for a in parts[1].split(",") if a]
|
||||
missing = [k for k in SSH_ALGORITHM_CLASSES if k not in found]
|
||||
if missing:
|
||||
raise ValueError(f"sshd -T printed no {', '.join(missing)} list")
|
||||
return found
|
||||
|
||||
|
||||
def parse_sshd_hostkeys(text: str) -> List[str]:
|
||||
"""The ``hostkey`` paths from ``sshd -T`` output, in order."""
|
||||
paths: List[str] = []
|
||||
for raw in text.splitlines():
|
||||
parts = raw.strip().split(None, 1)
|
||||
if len(parts) == 2 and parts[0].lower() == "hostkey":
|
||||
paths.append(parts[1].strip())
|
||||
return paths
|
||||
|
||||
|
||||
def host_key_algorithms(fingerprints: str) -> Optional[Set[str]]:
|
||||
"""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()
|
||||
for raw in fingerprints.splitlines():
|
||||
parts = raw.split()
|
||||
if not parts:
|
||||
continue
|
||||
kind = parts[-1].strip("()")
|
||||
if len(parts) < 3 or not parts[-1].startswith("("):
|
||||
return None
|
||||
if kind == "ECDSA" and parts[0] in _ECDSA_CURVES:
|
||||
usable.add(f"ecdsa-sha2-nistp{parts[0]}")
|
||||
elif kind in _KEY_TYPE_ALGORITHMS:
|
||||
usable.update(_KEY_TYPE_ALGORITHMS[kind])
|
||||
else:
|
||||
return None
|
||||
return usable or None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Refusal:
|
||||
"""One algorithm that was asked to go and stays, with why.
|
||||
|
||||
*reason* is ``"last"`` (the class would be empty -- for host keys: have no
|
||||
algorithm the host has a key for), ``"client"`` (the managing client would
|
||||
share none of the class with the server), ``"host_keys_unknown"`` (the host
|
||||
keys could not be read, so the host key list stays) or ``"not_offered"``
|
||||
(the server does not offer it, so there is nothing to remove).
|
||||
"""
|
||||
|
||||
kind: str
|
||||
algorithm: str
|
||||
reason: str
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
@property
|
||||
def changed_classes(self) -> List[str]:
|
||||
return [k for k in SSH_ALGORITHM_CLASSES if self.remove.get(k)]
|
||||
|
||||
|
||||
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,
|
||||
) -> SshHardeningPlan:
|
||||
"""What the lists become when *remove* is taken out of *effective*.
|
||||
|
||||
*client* is what the client managing this host speaks, per class. A class it
|
||||
has no list for is not guarded beyond "never empty".
|
||||
|
||||
*host_keys* are the host key algorithms the host has a key for (see
|
||||
:func:`host_key_algorithms`). Only those count as a host key algorithm that
|
||||
stays: sshd offers the others but cannot use them. Without it nothing is
|
||||
known, and no host key algorithm is removed.
|
||||
|
||||
When a removal would leave a class empty, or without an algorithm *client*
|
||||
speaks, the first of the asked-for algorithms in the server's order that
|
||||
fixes it stays, and is reported in ``refused``.
|
||||
|
||||
:raises ValueError: for a class that is not one of :data:`SSH_ALGORITHM_CLASSES`.
|
||||
"""
|
||||
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] = []
|
||||
usable_keys = set(host_keys) if host_keys is not None else None
|
||||
for kind in SSH_ALGORITHM_CLASSES:
|
||||
offered = list(effective.get(kind, []))
|
||||
asked = set(remove.get(kind, []))
|
||||
not_offered += [Refusal(kind, a, "not_offered") for a in sorted(asked - set(offered))]
|
||||
going = [a for a in offered if a in asked]
|
||||
if kind == "hostkey" and usable_keys is None:
|
||||
refused += [Refusal(kind, a, "host_keys_unknown") for a in going]
|
||||
going = []
|
||||
# A host key algorithm without a key is offered, but nobody gets in with it.
|
||||
usable = usable_keys if kind == "hostkey" and usable_keys is not None else set(offered)
|
||||
staying = [a for a in offered if a not in going and a in usable]
|
||||
speaks = set(client[kind]) if client and kind in client else None
|
||||
if going and speaks is not None and not speaks & set(staying):
|
||||
spared = next((a for a in going if a in speaks and a in usable), None)
|
||||
if spared:
|
||||
going.remove(spared)
|
||||
staying.append(spared)
|
||||
refused.append(Refusal(kind, spared, "client"))
|
||||
if going and not staying:
|
||||
spared = next((a for a in going if a in usable), None)
|
||||
if spared is None and not [a for a in offered if a not in going]:
|
||||
spared = going[0]
|
||||
if spared is not None:
|
||||
going.remove(spared)
|
||||
refused.append(Refusal(kind, spared, "last"))
|
||||
keep[kind] = [a for a in offered if a not in going]
|
||||
if going:
|
||||
removed[kind] = going
|
||||
return SshHardeningPlan(keep=keep, remove=removed, refused=refused, not_offered=not_offered)
|
||||
|
||||
|
||||
def render_dropin(keep: Mapping[str, Sequence[str]], kinds: Iterable[str]) -> str:
|
||||
"""The drop-in file stating the full list of each class in *kinds*."""
|
||||
lines = [
|
||||
f"{SSH_ALGORITHM_CLASSES[k][1]} {','.join(keep[k])}\n"
|
||||
for k in SSH_ALGORITHM_CLASSES
|
||||
if k in set(kinds)
|
||||
]
|
||||
return _HEADER + "".join(lines)
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
def _is_match(line: str) -> bool:
|
||||
parts = line.split(None, 1)
|
||||
return bool(parts) and parts[0].lower() == "match"
|
||||
|
||||
|
||||
def has_dropin_include(text: str) -> bool:
|
||||
"""Whether sshd_config's global section includes :data:`DROPIN_DIR`."""
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if _is_match(line):
|
||||
return False
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0].lower() == "include":
|
||||
if any(p.startswith(DROPIN_DIR + "/") for p in parts[1:]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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)
|
||||
ours = 0
|
||||
while ours < len(lines) and _directive_key(lines[ours].strip()):
|
||||
ours += 1
|
||||
return dropin_classes("".join(lines[:ours])), "".join(lines[ours:])
|
||||
|
||||
|
||||
def render_main_config(text: str, keep: Mapping[str, Sequence[str]], kinds: Iterable[str]) -> str:
|
||||
"""sshd_config with the lists of *kinds* first and their old global lines disabled.
|
||||
|
||||
First because sshd keeps the first value it reads. A ``Match`` block is left
|
||||
alone -- it is not the global setting. A class an earlier rewrite set stays
|
||||
set, and rewriting a file this already rewrote gives the same file.
|
||||
"""
|
||||
earlier, body = _split_our_block(text)
|
||||
wanted = set(kinds) | set(earlier)
|
||||
out: List[str] = []
|
||||
in_match = False
|
||||
for raw in body.splitlines():
|
||||
if _is_match(raw.strip()):
|
||||
in_match = True
|
||||
if not in_match and _directive_key(raw.strip()) in wanted:
|
||||
out.append(DISABLED_PREFIX + raw)
|
||||
else:
|
||||
out.append(raw)
|
||||
return render_dropin(keep, wanted) + "\n".join(out) + ("\n" if body.endswith("\n") else "")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SshHardeningResult:
|
||||
"""What :meth:`OpenSshServerMixin.harden_ssh_server` did.
|
||||
|
||||
*applied* is True only when the new lists are live and a new session got
|
||||
in. *rolled_back* says the host was changed and then put back. *backup* is
|
||||
where sshd_config was copied to, when it was the file changed.
|
||||
"""
|
||||
|
||||
plan: SshHardeningPlan
|
||||
applied: bool = False
|
||||
rolled_back: bool = False
|
||||
path: Optional[str] = None
|
||||
backup: Optional[str] = None
|
||||
message: str = ""
|
||||
|
||||
|
||||
class OpenSshServerMixin:
|
||||
"""Adds :meth:`harden_ssh_server` to a driver that can run commands as root on an OpenSSH host.
|
||||
|
||||
Mixed in by the drivers whose host runs a stock OpenSSH, not by a base class,
|
||||
so ``hasattr(driver, "harden_ssh_server")`` stays a truthful answer. A driver
|
||||
whose platform writes sshd_config itself (an appliance that regenerates it
|
||||
from its own database) must not inherit this, or must opt out with
|
||||
``harden_ssh_server = None``: its file would be overwritten on the next
|
||||
apply.
|
||||
"""
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
|
||||
|
||||
def run_command(
|
||||
self,
|
||||
command: str,
|
||||
*,
|
||||
privileged: bool = False,
|
||||
timeout: float = 60,
|
||||
stdin: Optional[bytes] = None,
|
||||
) -> CommandResult: ...
|
||||
|
||||
# ── the host ────────────────────────────────────────────────────────
|
||||
|
||||
def _ssh_run(self, command: str, stdin: Optional[bytes] = None) -> "CommandResult":
|
||||
return self.run_command(command, privileged=True, timeout=30, stdin=stdin)
|
||||
|
||||
def _ssh_read(self, path: str) -> Optional[str]:
|
||||
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:
|
||||
"""Replace *path* with *content*, as root, without root reading stdin.
|
||||
|
||||
A driver gains root with ``sudo -S`` and puts the password in front of
|
||||
stdin. A sudo that does not prompt -- NOPASSWD, a cached timestamp --
|
||||
leaves it there, and a ``cat`` under sudo writes it into the file. So the
|
||||
content goes, without privilege, to a file only the login user can read;
|
||||
root then installs that file, owned by root, and removes it.
|
||||
|
||||
The mode is that of *mode_of* (default: *path* itself), so a 0600
|
||||
sshd_config stays 0600 and so does its copy; 644 for a new file.
|
||||
"""
|
||||
staged = self.run_command(
|
||||
'umask 077 && t=$(mktemp /tmp/netork-ssh.XXXXXX) && cat > "$t" && printf %s "$t"',
|
||||
privileged=False,
|
||||
timeout=30,
|
||||
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()}")
|
||||
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' || stat -f %Lp -- "$r" 2>/dev/null) || m=644;'
|
||||
f' install -m "$m" -- {quote(tmp)} {quote(part)} && mv -f -- {quote(part)} {quote(path)};'
|
||||
f" s=$?; rm -f -- {quote(tmp)} {quote(part)}; exit $s"
|
||||
)
|
||||
if result.exit_code != 0:
|
||||
raise RuntimeError(f"could not write {path}: {(result.stderr or result.stdout).strip()}")
|
||||
|
||||
def _ssh_remove(self, path: str) -> None:
|
||||
self._ssh_run(f"rm -f -- {quote(path)}")
|
||||
|
||||
def _reload_ssh_server(self) -> None:
|
||||
"""Make the running sshd read its configuration again; sessions stay up."""
|
||||
result = self._ssh_run(_RELOAD)
|
||||
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]]:
|
||||
"""The algorithm lists sshd uses now (``sshd -T``), per class.
|
||||
|
||||
:raises RuntimeError: if sshd -T fails -- no OpenSSH, or no root.
|
||||
"""
|
||||
return parse_sshd_effective(self._sshd_effective_text())
|
||||
|
||||
def _sshd_effective_text(self) -> str:
|
||||
result = self._ssh_run("PATH=$PATH:/usr/sbin:/sbin sshd -T")
|
||||
if result.exit_code != 0:
|
||||
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]]:
|
||||
"""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.
|
||||
"""
|
||||
paths = parse_sshd_hostkeys(sshd_t)
|
||||
if not paths:
|
||||
return None
|
||||
result = self._ssh_run(
|
||||
f"for k in {' '.join(quote(p) for p in paths)}; do"
|
||||
' [ -e "$k" ] || continue; ssh-keygen -l -f "$k" || exit 1; done'
|
||||
)
|
||||
if result.exit_code != 0:
|
||||
return None
|
||||
return host_key_algorithms(result.stdout)
|
||||
|
||||
def _sshd_accepts(self) -> Optional[str]:
|
||||
"""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()
|
||||
|
||||
# ── the change ──────────────────────────────────────────────────────
|
||||
|
||||
def harden_ssh_server(
|
||||
self,
|
||||
remove: Mapping[str, Iterable[str]],
|
||||
*,
|
||||
client: Optional[Mapping[str, Iterable[str]]] = None,
|
||||
verify: Optional[Callable[[], bool]] = None,
|
||||
log: Optional[Callable[[str], None]] = None,
|
||||
) -> SshHardeningResult:
|
||||
"""Stop the server offering the algorithms in *remove* (class -> names).
|
||||
|
||||
*client* guards the managing client's algorithms (see
|
||||
:func:`plan_ssh_hardening`); *verify* opens a new session after the
|
||||
reload and returns whether it got in; *log* receives a line per step.
|
||||
|
||||
Never raises after the host was touched: a failure from there on puts
|
||||
the previous state back and says so in the result.
|
||||
|
||||
:raises RuntimeError: if the current state cannot be read.
|
||||
"""
|
||||
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
|
||||
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)
|
||||
whys = {
|
||||
"last": "it is the last one",
|
||||
"client": "netOrk could no longer connect",
|
||||
"host_keys_unknown": "the host keys could not be read",
|
||||
}
|
||||
for r in plan.refused:
|
||||
last_key = r.reason == "last" and r.kind == "hostkey"
|
||||
why = "it is the last one the host has a key for" if last_key else whys[r.reason]
|
||||
say(f"Keeping {r.algorithm}: {why}")
|
||||
kinds = plan.changed_classes
|
||||
result = SshHardeningResult(plan=plan)
|
||||
if not kinds:
|
||||
result.message = "Nothing to change: the server offers none of the selected algorithms."
|
||||
say(result.message)
|
||||
return result
|
||||
for kind in kinds:
|
||||
say(f"Removing {', '.join(plan.remove[kind])} from {SSH_ALGORITHM_CLASSES[kind][1]}")
|
||||
|
||||
main = self._ssh_read(MAIN_CONFIG)
|
||||
if main is None:
|
||||
raise RuntimeError(f"cannot read {MAIN_CONFIG}")
|
||||
restore = self._write_change(plan, main, result, say)
|
||||
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]
|
||||
) -> Callable[[], None]:
|
||||
"""Write the new lists; return what puts the previous state back."""
|
||||
if has_dropin_include(main):
|
||||
previous = self._ssh_read(DROPIN_PATH)
|
||||
kinds = set(plan.changed_classes) | set(dropin_classes(previous or ""))
|
||||
self._ssh_run(f"mkdir -p -- {quote(DROPIN_DIR)}")
|
||||
self._ssh_write(DROPIN_PATH, render_dropin(plan.keep, kinds))
|
||||
result.path = DROPIN_PATH
|
||||
say(f"Wrote {DROPIN_PATH}")
|
||||
|
||||
def restore() -> None:
|
||||
if previous is None:
|
||||
self._ssh_remove(DROPIN_PATH)
|
||||
else:
|
||||
self._ssh_write(DROPIN_PATH, previous)
|
||||
|
||||
return restore
|
||||
backup = f"{MAIN_CONFIG}.netork-{time.strftime('%Y%m%d-%H%M%S')}.bak"
|
||||
self._ssh_write(backup, main, mode_of=MAIN_CONFIG)
|
||||
self._ssh_write(MAIN_CONFIG, render_main_config(main, plan.keep, plan.changed_classes))
|
||||
result.path, result.backup = MAIN_CONFIG, backup
|
||||
say(f"{MAIN_CONFIG} has no Include of {DROPIN_DIR}; rewrote it (copy at {backup})")
|
||||
|
||||
def restore_main() -> None:
|
||||
self._ssh_write(MAIN_CONFIG, main)
|
||||
|
||||
return restore_main
|
||||
|
||||
def _apply_or_restore(
|
||||
self,
|
||||
plan: SshHardeningPlan,
|
||||
kinds: List[str],
|
||||
result: SshHardeningResult,
|
||||
restore: Callable[[], None],
|
||||
verify: Optional[Callable[[], bool]],
|
||||
say: Callable[[str], None],
|
||||
) -> SshHardeningResult:
|
||||
def back(message: str, reload: bool) -> SshHardeningResult:
|
||||
say(message)
|
||||
say("Putting the previous configuration back")
|
||||
try:
|
||||
restore()
|
||||
if reload:
|
||||
self._reload_ssh_server()
|
||||
except Exception as exc: # noqa: BLE001 - report it, the caller must look
|
||||
result.message = f"{message}; putting the previous configuration back failed: {exc}"
|
||||
say(result.message)
|
||||
return result
|
||||
result.rolled_back = True
|
||||
result.message = message
|
||||
return result
|
||||
|
||||
try:
|
||||
say("Checking the configuration (sshd -t)")
|
||||
complaint = self._sshd_accepts()
|
||||
if complaint:
|
||||
return back(f"sshd rejected the new configuration: {complaint}", reload=False)
|
||||
now = self.get_ssh_server_algorithms()
|
||||
ignored = [SSH_ALGORITHM_CLASSES[k][1] for k in kinds if now[k] != plan.keep[k]]
|
||||
if ignored:
|
||||
return back(
|
||||
f"sshd does not use the new {', '.join(ignored)}: a setting read before"
|
||||
f" {result.path} decides them",
|
||||
reload=False,
|
||||
)
|
||||
say("Reloading sshd")
|
||||
self._reload_ssh_server()
|
||||
if verify is not None:
|
||||
say("Opening a new SSH session to check netOrk still gets in")
|
||||
if not verify():
|
||||
return back("A new SSH session could not log in after the reload", reload=True)
|
||||
except Exception as exc: # noqa: BLE001 - whatever went wrong, the host goes back
|
||||
return back(f"The change failed: {exc}", reload=True)
|
||||
result.applied = True
|
||||
result.message = "The server no longer offers the selected algorithms."
|
||||
say(result.message)
|
||||
return result
|
||||
@@ -0,0 +1,115 @@
|
||||
# -*- 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
|
||||
system UUID; the host reads the same values from its firmware tables. Both sides
|
||||
read them the same way on every Linux host, so the command, its parse and the
|
||||
placeholder filter live here once, and a driver only carries the command across.
|
||||
|
||||
**Placeholders are no identity.** Board vendors leave defaults in the DMI tables
|
||||
that are the same on thousands of machines: a Supermicro board's product serial
|
||||
is ``0123456789``, others read ``To be filled by O.E.M.`` or ``Default string``,
|
||||
and a common UUID on cheap boards is ``03000200-0400-0500-0006-000700080009``.
|
||||
Matching on those would join unrelated machines, so they read as empty.
|
||||
|
||||
**Root.** ``product_uuid`` and ``product_serial`` are readable by root only; without
|
||||
it they come back empty, which is "not known", not a mismatch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from napalm_device_types.models import SystemIdentityDict
|
||||
from napalm_device_types.terminal import strip_terminal_codes
|
||||
|
||||
_BEGIN = "IDBEGIN"
|
||||
_END = "IDEND"
|
||||
_DMI = "/sys/class/dmi/id"
|
||||
|
||||
#: Reads the three files in one round trip, framed so a banner or prompt around the
|
||||
#: 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)\""
|
||||
)
|
||||
|
||||
#: 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",
|
||||
})
|
||||
#: A value of one repeated character ("0", "00000000", "FFFF-...") or of zeros and Fs.
|
||||
_REPEATED = re.compile(r"^(.)\1*$")
|
||||
|
||||
|
||||
def is_placeholder_identity(value: str | None) -> bool:
|
||||
"""Whether *value* is a vendor default rather than this machine's identity."""
|
||||
text = (value or "").strip().lower()
|
||||
if text in _PLACEHOLDERS:
|
||||
return True
|
||||
compact = text.replace("-", "").replace(" ", "")
|
||||
return bool(_REPEATED.match(compact))
|
||||
|
||||
|
||||
def _clean(value: str) -> str:
|
||||
value = value.strip()
|
||||
return "" if is_placeholder_identity(value) else value
|
||||
|
||||
|
||||
def parse_system_identity(output: str) -> SystemIdentityDict:
|
||||
"""The identity from :data:`SYSTEM_IDENTITY_COMMAND`'s output, placeholders empty.
|
||||
|
||||
:raises ValueError: when the output carries no intact frame.
|
||||
"""
|
||||
lines = strip_terminal_codes(output).splitlines()
|
||||
try:
|
||||
start = lines.index(_BEGIN) + 1
|
||||
end = lines.index(_END, start)
|
||||
except ValueError:
|
||||
raise ValueError("system identity: no intact IDBEGIN/IDEND frame") from None
|
||||
fields = (lines[start:end] + ["", "", ""])[:3]
|
||||
uuid, product_serial, board_serial = (_clean(f) for f in fields)
|
||||
return {
|
||||
"system_uuid": uuid.lower(),
|
||||
"product_serial": product_serial,
|
||||
"board_serial": board_serial,
|
||||
}
|
||||
|
||||
|
||||
class SystemIdentityMixin:
|
||||
"""Adds :meth:`get_system_identity` to a driver that can run a command on a Linux host.
|
||||
|
||||
The template form (README, "Function classes"): command and parse are the same
|
||||
everywhere and concrete here; a driver supplies only
|
||||
:meth:`_run_system_identity_command` -- how a command reaches its host, and how it
|
||||
gains root there. Mixed in by the drivers that can, so
|
||||
``hasattr(driver, "get_system_identity")`` stays a truthful answer.
|
||||
"""
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
|
||||
|
||||
def _run_system_identity_command(self, command: str, *, privileged: bool) -> str:
|
||||
"""Run *command* on the host and return what it printed; as root when
|
||||
*privileged* (``product_uuid`` and ``product_serial`` are root's)."""
|
||||
...
|
||||
|
||||
def get_system_identity(self) -> SystemIdentityDict:
|
||||
"""
|
||||
Returns the host's SMBIOS identity, each value empty when unknown or a vendor
|
||||
placeholder:
|
||||
|
||||
* system_uuid (str) - lowercase
|
||||
* product_serial (str)
|
||||
* board_serial (str)
|
||||
|
||||
:raises ValueError: if the output carried no intact reading.
|
||||
"""
|
||||
output = self._run_system_identity_command(SYSTEM_IDENTITY_COMMAND, privileged=True)
|
||||
return parse_system_identity(output)
|
||||
@@ -0,0 +1,329 @@
|
||||
# -*- 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
|
||||
the same on every host that runs it. So the command, its parse, the check of a
|
||||
unit name and the reading of an action's exit status live here once, and a
|
||||
driver only carries a command across: SSH, an API's exec endpoint, whatever it
|
||||
has.
|
||||
|
||||
**Listing.** One command prints the installed unit files and, for every loaded
|
||||
service unit, what ``systemctl show`` knows about it -- state, boot state and
|
||||
main PID together, instead of asking ``systemctl is-enabled`` and ``systemctl
|
||||
show`` once per unit (two hundred round trips on an ordinary Linux host). The
|
||||
report is framed, and a report whose end is missing raises: a list cut short
|
||||
must never read as services that went away.
|
||||
|
||||
**What counts as enabled.** A unit file state of ``enabled`` or
|
||||
``enabled-runtime``. ``static`` does not: such a unit starts only when
|
||||
something else pulls it in, and calling it enabled made every one of them look
|
||||
like a service of the host. The state is read from ``UnitFileState``, never
|
||||
from a column of ``list-unit-files``, whose second column has been followed by
|
||||
a preset column since systemd 245.
|
||||
|
||||
**Starting and stopping.** ``systemctl`` runs bounded by ``timeout`` and never
|
||||
asks for a password, and its exit status is printed after it. The marker also
|
||||
keeps the output from ever being empty, which a transport that retries on an
|
||||
empty answer would otherwise take as a reason to run the action twice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from shlex import quote
|
||||
from typing import Any, Dict, List, Set, Tuple, TYPE_CHECKING
|
||||
|
||||
from napalm_device_types.models import ServiceDict
|
||||
from napalm_device_types.services import ServiceControlMixin
|
||||
from napalm_device_types.terminal import strip_terminal_codes
|
||||
|
||||
_BEGIN = "SVC_BEGIN"
|
||||
_END = "SVC_END"
|
||||
_NO_SYSTEMD = "no-systemd"
|
||||
_SUFFIX = ".service"
|
||||
|
||||
#: What ``systemctl show`` prints per unit. It prints them in its own order.
|
||||
_PROPERTIES = "Id,Names,LoadState,ActiveState,SubState,UnitFileState,MainPID"
|
||||
|
||||
#: Picks the units whose file state is ``generated`` out of ``systemctl show``'s
|
||||
#: output, whatever order it prints the properties in.
|
||||
_GENERATED_AWK = (
|
||||
'awk -F= \'NF<2{id="";g=0;next} $1=="Id"{id=$2} '
|
||||
'$1=="UnitFileState"{g=($2=="generated")} id!=""&&g{print id;id="";g=0}\''
|
||||
)
|
||||
|
||||
#: One line, POSIX ``sh``, read-only. The frame markers are printed in two
|
||||
#: halves so that a transport which echoes the command does not show them early.
|
||||
#: ``xargs -0`` passes escaped names such as ``foo\x2dbar.service`` unchanged.
|
||||
#: A generated unit -- the wrapper systemd makes for a SysV script -- has no unit
|
||||
#: file whose state says whether it starts at boot; ``systemctl is-enabled``
|
||||
#: asks the script's rc links instead, for those few units only.
|
||||
SYSTEMD_SERVICES_COMMAND = (
|
||||
"printf '%s%s\\n' SVC_ BEGIN; "
|
||||
"[ -d /run/systemd/system ] || echo '[no-systemd]'; "
|
||||
"echo '[files]'; systemctl list-unit-files --type=service --no-legend --no-pager 2>/dev/null; "
|
||||
"echo '[units]'; s=$(systemctl list-units --type=service --all --no-legend --no-pager --plain "
|
||||
"2>/dev/null | awk '{print $1}' | tr '\\n' '\\0' | xargs -0 -r systemctl show --no-pager "
|
||||
f"-p {_PROPERTIES} -- 2>/dev/null); printf '%s\\n' \"$s\"; "
|
||||
f"echo '[generated]'; printf '%s\\n' \"$s\" | {_GENERATED_AWK} | while read -r u; do "
|
||||
'printf \'%s %s\\n\' "$u" "$(systemctl is-enabled -- "$u" 2>/dev/null)"; done; '
|
||||
"printf '%s%s\\n' SVC_ END"
|
||||
)
|
||||
|
||||
#: The lifecycle actions :meth:`SystemdServicesMixin.manage_service` accepts.
|
||||
SERVICE_ACTIONS = ("start", "stop", "restart", "enable", "disable")
|
||||
|
||||
#: Seconds an action may run on the host before ``timeout`` stops waiting for
|
||||
#: it. systemd itself carries on with the job.
|
||||
ACTION_TIMEOUT = 45
|
||||
|
||||
#: What a transport should allow for one command: the action's own bound plus
|
||||
#: the round trip around it.
|
||||
_TRANSPORT_TIMEOUT = ACTION_TIMEOUT + 15
|
||||
|
||||
_TIMED_OUT = 124 # timeout(1)'s exit status when the time ran out
|
||||
_RC_MARKER = "__SVC_RC="
|
||||
_RC_RE = re.compile(rf"^{_RC_MARKER}(\d+)\s*$", re.MULTILINE)
|
||||
|
||||
#: The characters systemd allows in a unit name, with ``\xHH`` for any other byte.
|
||||
_UNIT_RE = re.compile(r"(?:[A-Za-z0-9_.:@-]|\\x[0-9A-Fa-f]{2})+")
|
||||
_MAX_UNIT_LENGTH = 255
|
||||
|
||||
_ENABLED = frozenset({"enabled", "enabled-runtime"})
|
||||
#: Unit file states of a service that is installed but need not be loaded.
|
||||
_INSTALLED = frozenset({"enabled", "enabled-runtime", "disabled", "indirect"})
|
||||
|
||||
|
||||
class SystemdUnavailable(NotImplementedError):
|
||||
"""The host does not run systemd; a driver may fall back to another init system."""
|
||||
|
||||
|
||||
def unit_name(name: str) -> str:
|
||||
"""*name* as a service unit's name without ``.service``, or ``ValueError``.
|
||||
|
||||
Accepts template instances (``wg-quick@wg0``), dots (``snapd.apparmor``),
|
||||
colons and systemd's ``\\xHH`` escapes. Refuses a bare template
|
||||
(``getty@``), a leading ``-`` that a command would read as an option, and
|
||||
anything a shell would read.
|
||||
"""
|
||||
base = name[: -len(_SUFFIX)] if name.endswith(_SUFFIX) else name
|
||||
if (
|
||||
not _UNIT_RE.fullmatch(base)
|
||||
or base.startswith("-")
|
||||
or base.endswith("@")
|
||||
or len(base) + len(_SUFFIX) > _MAX_UNIT_LENGTH
|
||||
):
|
||||
raise ValueError(f"Invalid service name: {name!r}")
|
||||
return base
|
||||
|
||||
|
||||
def service_action_command(name: str, action: str) -> str:
|
||||
"""The shell command that applies *action* to the service *name*.
|
||||
|
||||
:raises ValueError: for an unknown action or an invalid name.
|
||||
"""
|
||||
if action not in SERVICE_ACTIONS:
|
||||
raise ValueError(f"Invalid action {action!r}; use one of {', '.join(SERVICE_ACTIONS)}")
|
||||
unit = quote(unit_name(name) + _SUFFIX)
|
||||
return (
|
||||
f"timeout {ACTION_TIMEOUT} systemctl --no-ask-password {action} -- {unit} 2>&1; "
|
||||
f"echo {_RC_MARKER}$?"
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
is not reported as done, and output without a status is no success.
|
||||
"""
|
||||
output = strip_terminal_codes(output)
|
||||
statuses = _RC_RE.findall(output)
|
||||
text = _RC_RE.sub("", output).strip()
|
||||
if not statuses:
|
||||
return {"success": False, "output": text or "No exit status came back from the host."}
|
||||
status = int(statuses[-1])
|
||||
if status == 0:
|
||||
return {"success": True, "output": text}
|
||||
if status == _TIMED_OUT:
|
||||
note = f"Still running after {ACTION_TIMEOUT} s; systemd carries on with the job."
|
||||
return {"success": False, "output": f"{text}\n{note}".strip()}
|
||||
return {"success": False, "output": text or f"systemctl exited with status {status}."}
|
||||
|
||||
|
||||
def _frame(output: 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 systemd service report in the output") from None
|
||||
return lines[start + 1 : end]
|
||||
|
||||
|
||||
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], [])
|
||||
else:
|
||||
current.append(line)
|
||||
return sections
|
||||
|
||||
|
||||
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] = {}
|
||||
for line in lines:
|
||||
key, sep, value = line.partition("=")
|
||||
if not sep or key in current:
|
||||
if current:
|
||||
blocks.append(current)
|
||||
current = {}
|
||||
if sep:
|
||||
current[key] = value
|
||||
if current:
|
||||
blocks.append(current)
|
||||
return blocks
|
||||
|
||||
|
||||
def _base(unit: str) -> str:
|
||||
return unit[: -len(_SUFFIX)]
|
||||
|
||||
|
||||
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]]:
|
||||
"""The loaded services, and every name they go by (aliases included)."""
|
||||
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":
|
||||
continue
|
||||
names.update(block.get("Names", unit).split())
|
||||
running = block.get("ActiveState") == "active" and block.get("SubState") == "running"
|
||||
services[_base(unit)] = {
|
||||
"name": _base(unit),
|
||||
"running": running,
|
||||
"enabled": block.get("UnitFileState") in _ENABLED,
|
||||
"pid": _main_pid(block) if running else 0,
|
||||
}
|
||||
return services, names
|
||||
|
||||
|
||||
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] = {}
|
||||
for line in lines:
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
unit, state = parts[0], parts[1]
|
||||
if (
|
||||
not unit.endswith(_SUFFIX)
|
||||
or unit.endswith("@" + _SUFFIX)
|
||||
or unit in known
|
||||
or state not in _INSTALLED
|
||||
):
|
||||
continue
|
||||
services[_base(unit)] = {
|
||||
"name": _base(unit),
|
||||
"running": False,
|
||||
"enabled": state in _ENABLED,
|
||||
"pid": 0,
|
||||
}
|
||||
return services
|
||||
|
||||
|
||||
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()
|
||||
if len(parts) == 2 and parts[0].endswith(_SUFFIX) and _base(parts[0]) in services:
|
||||
services[_base(parts[0])]["enabled"] = parts[1] in _ENABLED
|
||||
|
||||
|
||||
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
|
||||
installed one that is not loaded.
|
||||
|
||||
:raises SystemdUnavailable: when the host does not run systemd.
|
||||
:raises ValueError: when the output carries no intact report.
|
||||
"""
|
||||
sections = _sections(_frame(output))
|
||||
if _NO_SYSTEMD in sections:
|
||||
raise SystemdUnavailable("the host does not run systemd")
|
||||
loaded, known = _loaded(_unit_blocks(sections.get("units", [])))
|
||||
_apply_generated(loaded, sections.get("generated", []))
|
||||
merged = {**_installed(sections.get("files", []), known), **loaded}
|
||||
return [merged[name] for name in sorted(merged)]
|
||||
|
||||
|
||||
class SystemdServicesMixin(ServiceControlMixin):
|
||||
"""Implements :class:`ServiceControlMixin` for a driver whose host runs systemd.
|
||||
|
||||
The template form (README, "Function classes"): the command, the parse,
|
||||
the check of the name and the reading of the exit status are the same
|
||||
everywhere, so they are concrete here, and a driver supplies only
|
||||
:meth:`_run_service_command` -- how a command reaches its host, and how it
|
||||
gains root there when it needs to.
|
||||
"""
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
|
||||
|
||||
def _run_service_command(self, command: str, *, privileged: bool, timeout: int) -> str:
|
||||
"""Run *command* with ``sh`` on the host and return what it printed.
|
||||
|
||||
*privileged* commands change the system and need root; *timeout*
|
||||
is how long the transport should wait for the output, in seconds.
|
||||
"""
|
||||
...
|
||||
|
||||
def get_services(self) -> List[ServiceDict]:
|
||||
"""
|
||||
Returns the services systemd knows, in one round trip.
|
||||
|
||||
* name (string) - the unit name without ``.service``
|
||||
* running (bool) - active and running
|
||||
* enabled (bool) - the unit file is enabled
|
||||
* pid (int) - the main process; 0 when not running
|
||||
|
||||
:raises SystemdUnavailable: if the host does not run systemd.
|
||||
:raises ValueError: if the host's output carried no intact report.
|
||||
"""
|
||||
output = self._run_service_command(
|
||||
SYSTEMD_SERVICES_COMMAND, privileged=False, timeout=_TRANSPORT_TIMEOUT
|
||||
)
|
||||
return parse_systemd_services(output)
|
||||
|
||||
def manage_service(self, name: str, action: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Applies *action* (start, stop, restart, enable, disable) to the service *name*.
|
||||
|
||||
:returns: ``{"success": bool, "output": str}``
|
||||
:raises ValueError: for an unknown action or an invalid name, before
|
||||
anything is sent.
|
||||
"""
|
||||
command = service_action_command(name, action)
|
||||
output = self._run_service_command(command, privileged=True, timeout=_TRANSPORT_TIMEOUT)
|
||||
return parse_action_result(output)
|
||||
@@ -0,0 +1,23 @@
|
||||
# -*- 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
|
||||
then colour their output and draw progress: systemctl colours its errors, apt
|
||||
switches the keypad mode with ``ESC =`` / ``ESC >``. Every parser in this package
|
||||
reads the text without them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
#: CSI sequences (colours, cursor), OSC sequences (window titles) and the
|
||||
#: two-character escapes (``ESC =``, ``ESC >``, ``ESC (B``).
|
||||
_TERMINAL_CODES = re.compile(
|
||||
r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|\([0-9A-Za-z]|[=>78DEHMNOc])"
|
||||
)
|
||||
|
||||
|
||||
def strip_terminal_codes(text: str) -> str:
|
||||
"""*text* without terminal escape sequences."""
|
||||
return _TERMINAL_CODES.sub("", text)
|
||||
@@ -13,7 +13,7 @@ this class in can never shadow a working implementation from a sibling base.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, TYPE_CHECKING
|
||||
from typing import Any, Dict, List, TYPE_CHECKING
|
||||
|
||||
from napalm_device_types.models import ApplyUpdatesResultDict, UpdateDict
|
||||
|
||||
@@ -30,6 +30,14 @@ class UpdateMixin:
|
||||
* name (string) - package name
|
||||
* current_version (string) - currently installed version
|
||||
* new_version (string) - version available in the repository
|
||||
* origin (string, optional) - where it comes from, e.g. apt's suites
|
||||
* security (bool or None, optional) - a security update; leave it
|
||||
out or None when the source does not say
|
||||
|
||||
**An empty list means nothing is pending.** A reader that cannot
|
||||
read -- no package index yet, an API that did not answer -- raises
|
||||
instead: netOrk keeps "pending since" per package, and an empty
|
||||
list for "don't know" would reset every one of those clocks.
|
||||
|
||||
Example::
|
||||
|
||||
@@ -43,6 +51,16 @@ class UpdateMixin:
|
||||
"""
|
||||
...
|
||||
|
||||
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``,
|
||||
``dnf makecache``, ``opkg update``, a firmware check). Installs nothing.
|
||||
|
||||
:returns: ``{"success": bool, "output": str}``
|
||||
"""
|
||||
...
|
||||
|
||||
def apply_updates(self, packages: List[str]) -> ApplyUpdatesResultDict:
|
||||
"""
|
||||
Upgrades the given packages to the newest available version.
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
# -*- 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
|
||||
*right now*. The address list of a standby shows no trace of it; what every
|
||||
member has is the configuration that declares it. That is read the same way on
|
||||
every Linux host, so the command and its parse live here once, and a driver
|
||||
only carries the command across.
|
||||
|
||||
**Declared, not held.** The reading says which addresses the configuration
|
||||
lets onto this host. Whether the host holds one at the moment is in its
|
||||
interface addresses, which the caller already has.
|
||||
|
||||
**Filtered on the host.** The same files hold secrets -- vip-manager's etcd
|
||||
password, keepalived's ``auth_pass``, notify scripts with their arguments. The
|
||||
command therefore never prints a configuration: an awk program reads the unit's
|
||||
properties and files and prints allowlisted fields only. A secret cannot reach
|
||||
the caller's memory, its log or an exception text, because it never leaves the
|
||||
host.
|
||||
|
||||
**What is read.**
|
||||
|
||||
- *vip-manager*: every ``vip-manager.service`` and ``vip-manager@*.service`` unit
|
||||
systemd knows. The address comes from the first of: a command-line flag
|
||||
(``--ip``; 1.x's ``-ip=${VIP_IP}`` resolved through the unit's environment), the
|
||||
environment (``VIP_IP``, an ``EnvironmentFile`` over ``Environment``), the
|
||||
``--config`` YAML (5.x's ``ip``). Mask, interface and trigger key alike.
|
||||
- *keepalived*: ``keepalived.service``, its ``-f``/``--use-file`` or
|
||||
``/etc/keepalived/keepalived.conf``. The ``virtual_ipaddress`` and
|
||||
``virtual_ipaddress_excluded`` entries of every ``vrrp_instance``, with the
|
||||
instance's ``interface`` and ``virtual_router_id``. ``include`` is followed
|
||||
four levels deep, a relative pattern from the including file's directory. A
|
||||
pattern that is not a plain path glob is not followed: it would otherwise be
|
||||
handed to a root shell.
|
||||
|
||||
**None is "did not look".** A file that exists but cannot be read, an include
|
||||
that cannot be followed, a host without systemd (no ``systemctl``, or systemd not
|
||||
running as PID 1, as in a container): that mechanism is None, and the
|
||||
caller keeps what it knew. ``[]`` is a host that has none.
|
||||
|
||||
**Root, and without it.** The configuration may be root's alone. The whole
|
||||
script goes to the host as one ``sh -c`` argument; when the privileged call
|
||||
brings no report back, it runs again without privilege and reads what it can.
|
||||
|
||||
**An exec channel, not a terminal.** With its awk program the command is about
|
||||
4 kB, past the line an interactive shell reliably takes. A driver sends it the
|
||||
way it runs any command that needs no PTY -- napalm-linux's ``run_command``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from shlex import quote
|
||||
from typing import Callable, Dict, List, Optional, Set, Tuple, TYPE_CHECKING
|
||||
|
||||
from napalm_device_types.models import VirtualIpDict, VirtualIpsDict
|
||||
from napalm_device_types.terminal import strip_terminal_codes
|
||||
|
||||
_BEGIN = "VIPS_BEGIN"
|
||||
_END = "VIPS_END"
|
||||
_NO_SYSTEMD = "no-systemd"
|
||||
#: Exists only while systemd runs as PID 1; a container may carry systemctl without.
|
||||
_SYSTEMD_RUN = "/run/systemd/system"
|
||||
#: A record that says a mechanism was not fully read.
|
||||
_BLIND = frozenset({"unreadable", "too-deep"})
|
||||
_KEEPALIVED_CONF = "/etc/keepalived/keepalived.conf"
|
||||
_ENV_KEYS = "VIP_(IP|MASK|NETMASK|IFACE|INTERFACE|KEY|TRIGGER_KEY)"
|
||||
_CFG_KEYS = "(ip|netmask|mask|interface|iface|trigger-key|key)"
|
||||
|
||||
#: One line of awk, fed a unit's ``systemctl show`` output with ``-v m=<mechanism>``.
|
||||
#: It prints records, never a line of configuration: ``active <state>``,
|
||||
#: ``arg <flag> <value>``, ``env <VIP_*> <value>``, ``cfg <key> <value>``,
|
||||
#: ``instance <name>`` / ``interface`` / ``virtual_router_id`` / ``vip <addr> [dev <if>]``
|
||||
#: / ``end``, and ``unreadable``/``too-deep``/``missing <path>``. No ``>`` but in
|
||||
#: ``2>/dev/null``: comparisons are written the other way round. ``\047`` is a
|
||||
#: single quote, so the program itself needs none. A statement ends with its line
|
||||
#: unless the next one opens with ``{``: keepalived takes ``vrrp_instance VI_2``
|
||||
#: and its brace on two lines.
|
||||
_AWK = " ".join(
|
||||
(
|
||||
r'function q(p) { return "\047" p "\047" }',
|
||||
r'function ok(f) { if (f == "" || f ~ /\047/) { print "unreadable " f; return 0 }'
|
||||
r' if (system("test -r " q(f)) == 0) return 1;'
|
||||
r' if (system("test -e " q(f)) == 0) print "unreadable " f; else print "missing " f;'
|
||||
r" return 0 }",
|
||||
r"function rf(f, c, l, r, k) { if (!ok(f)) return;"
|
||||
r" while (0 < (r = (getline l < f))) {"
|
||||
rf' if (c == "cfg" && l ~ /^[ \t]*{_CFG_KEYS}[ \t]*:/) {{ sub(/^[ \t]*/, "", l); k = l;'
|
||||
r' sub(/[ \t]*:.*/, "", k); sub(/^[^:]*:[ \t]*/, "", l); print "cfg " k " " l }'
|
||||
rf' else if (c == "env" && l ~ /^[ \t]*(export[ \t]+)?{_ENV_KEYS}[ \t]*=/) {{'
|
||||
r' sub(/^[ \t]*(export[ \t]+)?/, "", l); k = l; sub(/[ \t]*=.*/, "", k);'
|
||||
r' sub(/^[^=]*=[ \t]*/, "", l); print "env " k " " l } }'
|
||||
r' if (r < 0) print "unreadable " f; close(f) }',
|
||||
r'function push(n, a) { st[++sd] = n; if (n == "vrrp_instance") print "instance " a }',
|
||||
r'function pop() { if (0 < sd) { if (st[sd] == "vrrp_instance") print "end"; sd-- } }',
|
||||
r"function stmt(w, nw, f, d, x, i) { if (!nw) return;"
|
||||
r' if (w[1] == "include") { if (1 < nw) inc(w[2], f, d); return }'
|
||||
r' if (sd == 1 && st[1] == "vrrp_instance") {'
|
||||
r' if (1 < nw && (w[1] == "interface" || w[1] == "virtual_router_id")) print w[1] " " w[2];'
|
||||
r" return }"
|
||||
r' if (sd == 2 && st[1] == "vrrp_instance" && st[2] ~ /^virtual_ipaddress(_excluded)?$/) {'
|
||||
r' x = "vip " w[1]; for (i = 2; i < nw; i++) if (w[i] == "dev") x = x " dev " w[i + 1];'
|
||||
r" print x } }",
|
||||
r"function rk(f, d, l, r, n, t, k, w, nw) {"
|
||||
r' if (4 < d || (f in seen)) { print "too-deep " f; return }'
|
||||
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' 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);"
|
||||
r' if (r < 0) print "unreadable " f; close(f); delete seen[f] }',
|
||||
r"function inc(p, f, d, c, g, dir, fs, n, i) {"
|
||||
r' if (p !~ /^\//) { dir = f; sub(/[^\/]*$/, "", dir); p = dir p }'
|
||||
r' if (p !~ /^[A-Za-z0-9_.\/*?-]+$/) { print "unreadable " p; return }'
|
||||
r' c = "for g in " p "; do [ -e \"$g\" ] && echo \"$g\"; done"; n = 0;'
|
||||
r" while (0 < (c | getline g)) fs[++n] = g; close(c);"
|
||||
r" for (i = 1; i <= n; i++) rk(fs[i], d + 1) }",
|
||||
r'/^ActiveState=/ { print "active " substr($0, 13); next }',
|
||||
r'/^ExecStart=/ { s = $0; i = index(s, "argv[]="); if (!i) next; s = substr(s, i + 7);'
|
||||
r' j = index(s, " ;"); if (j) s = substr(s, 1, j - 1); n = split(s, t, " ");'
|
||||
r' for (k = 2; k <= n; k++) { f = t[k]; if (f !~ /^-/) continue; v = ""; x = index(f, "=");'
|
||||
r" if (x) { v = substr(f, x + 1); f = substr(f, 1, x - 1) }"
|
||||
r' else if (k < n && t[k + 1] !~ /^-/) v = t[++k]; sub(/^--?/, "", f);'
|
||||
r" if (f ~ /^(config|ip|netmask|mask|interface|iface|trigger-key|key|f|use-file)$/) {"
|
||||
r' print "arg " f " " v; a[f] = v } } next }',
|
||||
r'/^EnvironmentFiles=/ { s = substr($0, 18); sub(/ \(ignore_errors=[a-z]*\)$/, "", s);'
|
||||
r' if (s != "") ef[++ne] = s; next }',
|
||||
r'/^Environment=/ { n = split(substr($0, 13), t, " "); for (k = 1; k <= n; k++) {'
|
||||
rf' x = index(t[k], "="); if (x && substr(t[k], 1, x - 1) ~ /^{_ENV_KEYS}$/)'
|
||||
r' print "env " substr(t[k], 1, x - 1) " " substr(t[k], x + 1) } next }',
|
||||
rf'END {{ if (m == "keepalived") {{ p = "{_KEEPALIVED_CONF}";'
|
||||
r' if ("use-file" in a) p = a["use-file"]; if ("f" in a) p = a["f"]; rk(p, 0) }'
|
||||
r' else { for (k = 1; k <= ne; k++) rf(ef[k], "env");'
|
||||
r' if ("config" in a) rf(a["config"], "cfg") } }',
|
||||
)
|
||||
)
|
||||
|
||||
#: One line, POSIX ``sh``, read-only. The frame markers are printed in two halves
|
||||
#: so that a transport which echoes the command does not show them early.
|
||||
VIRTUAL_IPS_COMMAND = (
|
||||
"PATH=$PATH:/usr/sbin:/sbin; "
|
||||
"printf '%s%s\\n' VIPS_ BEGIN; "
|
||||
f"if command -v systemctl >/dev/null 2>&1 && [ -d {_SYSTEMD_RUN} ]; then echo '[systemd]'; "
|
||||
"for u in $(systemctl list-units --all --plain --no-legend --type=service "
|
||||
"vip-manager.service 'vip-manager@*' keepalived.service 2>/dev/null | awk '{print $1}'); do "
|
||||
"case $u in keepalived*) m=keepalived;; *) m=vip-manager;; esac; "
|
||||
'echo "[$m $u]"; '
|
||||
'systemctl show -p ActiveState -p ExecStart -p EnvironmentFiles -p Environment "$u" '
|
||||
f"2>/dev/null | awk -v m=$m {quote(_AWK)}; done; "
|
||||
"else echo '[no-systemd]'; fi; "
|
||||
"printf '%s%s\\n' VIPS_ END"
|
||||
)
|
||||
|
||||
_COMMENT_RE = re.compile(r"\s+#.*$")
|
||||
_VARIABLE_RE = re.compile(r"^\$\{?(\w+)\}?$")
|
||||
_Record = Tuple[str, str]
|
||||
|
||||
|
||||
def _frame(output: 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 virtual IP report in the output") from None
|
||||
return lines[start + 1 : end]
|
||||
|
||||
|
||||
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]]] = []
|
||||
for line in lines:
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
mechanism, _, unit = line[1:-1].partition(" ")
|
||||
sections.append((mechanism, unit, []))
|
||||
elif line and sections:
|
||||
kind, _, rest = line.partition(" ")
|
||||
sections[-1][2].append((kind, rest.strip()))
|
||||
return sections
|
||||
|
||||
|
||||
def _clean(value: str) -> str:
|
||||
"""Without a trailing ``# comment`` and the quotes around it."""
|
||||
value = _COMMENT_RE.sub("", value.strip())
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
||||
value = value[1:-1]
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _address(text: str) -> Optional[Tuple[str, Optional[int]]]:
|
||||
"""``"10.0.0.10/24"`` -> ``("10.0.0.10", 24)``; None for what is no address."""
|
||||
host, slash, prefix = text.partition("/")
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return None
|
||||
if not slash:
|
||||
return str(address), None
|
||||
if not prefix.isdigit() or int(prefix) > address.max_prefixlen:
|
||||
return None
|
||||
return str(address), int(prefix)
|
||||
|
||||
|
||||
def _prefix(mask: Optional[str]) -> Optional[int]:
|
||||
"""``"24"`` or ``"255.255.255.0"`` -> 24."""
|
||||
if not mask:
|
||||
return None
|
||||
if mask.isdigit():
|
||||
return int(mask) if int(mask) <= 128 else None
|
||||
try:
|
||||
return ipaddress.IPv4Network(f"0.0.0.0/{mask}").prefixlen
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _running(records: List[_Record]) -> Optional[bool]:
|
||||
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": {}}
|
||||
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]:
|
||||
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]:
|
||||
for kind, names in (("arg", args), ("env", envs), ("cfg", cfgs)):
|
||||
for name in names:
|
||||
if name in values[kind]:
|
||||
found = resolve(values[kind][name])
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
ip = pick(("ip",), ("VIP_IP",), ("ip",))
|
||||
parsed = _address(ip) if ip else None
|
||||
if parsed is None:
|
||||
return []
|
||||
address, prefix = parsed
|
||||
mask = pick(("netmask", "mask"), ("VIP_NETMASK", "VIP_MASK"), ("netmask", "mask"))
|
||||
return [
|
||||
{
|
||||
"mechanism": "vip-manager",
|
||||
"instance": unit[: -len(".service")] if unit.endswith(".service") else unit,
|
||||
"address": address,
|
||||
"prefix_length": _prefix(mask) if mask else prefix,
|
||||
"interface": pick(
|
||||
("interface", "iface"), ("VIP_INTERFACE", "VIP_IFACE"), ("interface", "iface")
|
||||
),
|
||||
"group_key": pick(
|
||||
("trigger-key", "key"), ("VIP_TRIGGER_KEY", "VIP_KEY"), ("trigger-key", "key")
|
||||
),
|
||||
"running": _running(records),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Instance:
|
||||
name: str
|
||||
interface: Optional[str] = None
|
||||
vrid: str = ""
|
||||
vips: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _keepalived(unit: str, records: List[_Record]) -> List[VirtualIpDict]:
|
||||
running = _running(records)
|
||||
instances: List[_Instance] = []
|
||||
current: Optional[_Instance] = None
|
||||
for kind, rest in records:
|
||||
if kind == "instance":
|
||||
current = _Instance(rest or unit)
|
||||
instances.append(current)
|
||||
elif current is None:
|
||||
continue
|
||||
elif kind == "interface":
|
||||
current.interface = rest or None
|
||||
elif kind == "virtual_router_id":
|
||||
current.vrid = rest
|
||||
elif kind == "vip":
|
||||
current.vips.append(rest)
|
||||
elif kind == "end":
|
||||
current = None
|
||||
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] = []
|
||||
for vip in instance.vips:
|
||||
tokens = vip.split()
|
||||
parsed = _address(tokens[0]) if tokens else None
|
||||
if parsed is None:
|
||||
continue
|
||||
address, prefix = parsed
|
||||
dev = tokens[tokens.index("dev") + 1] if "dev" in tokens[1:-1] else None
|
||||
entries.append(
|
||||
{
|
||||
"mechanism": "keepalived",
|
||||
"instance": instance.name,
|
||||
"address": address,
|
||||
"prefix_length": prefix if prefix is not None else _host_prefix(address),
|
||||
"interface": dev or instance.interface,
|
||||
"group_key": f"vrid:{instance.vrid}" if instance.vrid.isdigit() else None,
|
||||
"running": running,
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _host_prefix(address: str) -> int:
|
||||
"""keepalived's default for an entry without one: the single address."""
|
||||
return ipaddress.ip_address(address).max_prefixlen
|
||||
|
||||
|
||||
_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]:
|
||||
address = ipaddress.ip_address(entry["address"])
|
||||
return address.version, int(address), entry["instance"]
|
||||
|
||||
|
||||
def parse_virtual_ips(output: str) -> VirtualIpsDict:
|
||||
"""Parse what :data:`VIRTUAL_IPS_COMMAND` printed, each mechanism's entries
|
||||
sorted by address.
|
||||
|
||||
:raises ValueError: when the output carries no intact report.
|
||||
"""
|
||||
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()
|
||||
for mechanism, unit, records in sections:
|
||||
if mechanism not in _READERS:
|
||||
continue
|
||||
key, read = _READERS[mechanism]
|
||||
if any(kind in _BLIND for kind, _ in records):
|
||||
blind.add(key)
|
||||
else:
|
||||
found[key].extend(read(unit, records))
|
||||
def result(key: str) -> Optional[List[VirtualIpDict]]:
|
||||
return None if key in blind else sorted(found[key], key=_order)
|
||||
|
||||
return {"vip_manager": result("vip_manager"), "keepalived": result("keepalived")}
|
||||
|
||||
|
||||
class VirtualIpsMixin:
|
||||
"""Adds :meth:`get_virtual_ips` to a driver that can run a command on a Linux host.
|
||||
|
||||
The template form (README, "Function classes"): the command and its parse are
|
||||
the same everywhere, so they are concrete here, and a driver supplies only
|
||||
:meth:`_run_virtual_ips_command` -- how a command reaches its host, and how it
|
||||
gains root there. Mixed in by the drivers that can, not by
|
||||
:class:`~napalm_device_types.os.OSDriver`, so ``hasattr(driver,
|
||||
"get_virtual_ips")`` stays a truthful answer.
|
||||
"""
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
|
||||
|
||||
def _run_virtual_ips_command(self, command: str, *, privileged: bool) -> str:
|
||||
"""Run *command* on the host and return what it printed; as root
|
||||
when *privileged*. The command is a single ``sh -c`` invocation of
|
||||
about 4 kB: send it on an exec channel, not typed into a shell."""
|
||||
...
|
||||
|
||||
def get_virtual_ips(self) -> VirtualIpsDict:
|
||||
"""
|
||||
Returns the virtual IPs the host's vip-manager and keepalived configuration
|
||||
declares -- whether or not the host holds them at the moment.
|
||||
|
||||
* vip_manager (list or None) - see :class:`~napalm_device_types.models.VirtualIpDict`
|
||||
* keepalived (list or None)
|
||||
|
||||
None for a mechanism means it could not be read; ``[]`` that the host has none.
|
||||
|
||||
Example::
|
||||
|
||||
{
|
||||
"vip_manager": [
|
||||
{"mechanism": "vip-manager", "instance": "vip-manager",
|
||||
"address": "10.7.224.10", "prefix_length": 24, "interface": "ens7",
|
||||
"group_key": "/service/netork-db/leader", "running": True},
|
||||
],
|
||||
"keepalived": [],
|
||||
}
|
||||
|
||||
:raises ValueError: if neither reading carried an intact report.
|
||||
"""
|
||||
command = f"sh -c {quote(VIRTUAL_IPS_COMMAND)}"
|
||||
try:
|
||||
return parse_virtual_ips(self._run_virtual_ips_command(command, privileged=True))
|
||||
except ValueError:
|
||||
pass
|
||||
return parse_virtual_ips(self._run_virtual_ips_command(command, privileged=False))
|
||||
+6
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "napalm-device-types"
|
||||
version = "2.0.0"
|
||||
version = "4.13.0"
|
||||
description = "Abstract device-type base classes for NAPALM drivers"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -34,12 +34,14 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: System :: Networking",
|
||||
"Topic :: System :: Systems Administration",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"napalm>=4.0",
|
||||
"typing-extensions>=4.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -59,6 +61,9 @@ dev = [
|
||||
where = ["."]
|
||||
include = ["napalm_device_types*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
napalm_device_types = ["py.typed"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
strict = true
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""The command channel: how a driver runs a command and opens a byte stream.
|
||||
|
||||
netOrk's container runtime driver speaks the Docker Engine API over a stream
|
||||
from ``docker system dial-stdio`` (NetOrk/netork#765). The driver only provides
|
||||
the way there; these are the SSH pieces every SSH driver can reuse. The
|
||||
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 (
|
||||
CommandResult,
|
||||
ParamikoExecStream,
|
||||
open_stream_on_transport,
|
||||
run_on_transport,
|
||||
)
|
||||
|
||||
|
||||
class FakeChannel:
|
||||
"""Just enough of paramiko.Channel: scripted stdout/stderr chunks, an exit code."""
|
||||
|
||||
def __init__(self, stdout=(), stderr=(), exit_code=0, hang=False):
|
||||
self.out = list(stdout)
|
||||
self.err = list(stderr)
|
||||
self.exit_code = exit_code
|
||||
self.hang = hang
|
||||
self.sent = b""
|
||||
self.command = None
|
||||
self.write_closed = False
|
||||
self.closed = False
|
||||
self.timeout = None
|
||||
|
||||
def exec_command(self, command):
|
||||
self.command = command
|
||||
|
||||
def settimeout(self, timeout):
|
||||
self.timeout = timeout
|
||||
|
||||
def sendall(self, data):
|
||||
self.sent += data
|
||||
|
||||
def shutdown_write(self):
|
||||
self.write_closed = True
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def recv_ready(self):
|
||||
return bool(self.out)
|
||||
|
||||
def recv(self, n):
|
||||
if self.out:
|
||||
return self.out.pop(0)
|
||||
if self.hang:
|
||||
raise socket.timeout()
|
||||
return b""
|
||||
|
||||
def recv_stderr_ready(self):
|
||||
return bool(self.err)
|
||||
|
||||
def recv_stderr(self, n):
|
||||
return self.err.pop(0) if self.err else b""
|
||||
|
||||
def exit_status_ready(self):
|
||||
return not self.hang and not self.out and not self.err
|
||||
|
||||
def recv_exit_status(self):
|
||||
return self.exit_code
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
def __init__(self, channel):
|
||||
self.channel = channel
|
||||
|
||||
def open_session(self):
|
||||
return self.channel
|
||||
|
||||
|
||||
def test_run_collects_stdout_stderr_and_the_exit_code():
|
||||
ch = FakeChannel(stdout=[b"hel", b"lo\n"], stderr=[b"warn\n"], exit_code=3)
|
||||
|
||||
result = run_on_transport(FakeTransport(ch), "echo hello", timeout=5)
|
||||
|
||||
assert result == CommandResult(stdout="hello\n", stderr="warn\n", exit_code=3)
|
||||
assert ch.command == "echo hello"
|
||||
assert ch.closed
|
||||
|
||||
|
||||
def test_run_sends_stdin_and_then_closes_the_write_side():
|
||||
ch = FakeChannel(stdout=[b"ok"])
|
||||
|
||||
run_on_transport(FakeTransport(ch), "sudo -S true", stdin=b"pw\n", timeout=5)
|
||||
|
||||
assert ch.sent == b"pw\n"
|
||||
assert ch.write_closed
|
||||
|
||||
|
||||
def test_run_raises_when_the_command_does_not_finish_in_time():
|
||||
ch = FakeChannel(hang=True)
|
||||
|
||||
with pytest.raises(TimeoutError, match="did not finish"):
|
||||
run_on_transport(FakeTransport(ch), "sleep 999", timeout=0.05)
|
||||
assert ch.closed
|
||||
|
||||
|
||||
def test_a_stream_reads_until_eof_and_drains_stderr_on_the_way():
|
||||
ch = FakeChannel(stdout=[b"HTTP/1.1 200 OK\r\n"], stderr=[b"note\n"])
|
||||
stream = open_stream_on_transport(FakeTransport(ch), "docker system dial-stdio")
|
||||
|
||||
assert stream.read(4096, timeout=5) == b"HTTP/1.1 200 OK\r\n"
|
||||
assert stream.read(4096, timeout=5) == b""
|
||||
assert stream.stderr == "note\n"
|
||||
assert ch.command == "docker system dial-stdio"
|
||||
|
||||
|
||||
def test_a_stream_writes_and_closes_its_write_side_only_when_asked():
|
||||
"""dial-stdio answers HTTP 499 to a request whose writer closed early
|
||||
(netork#771), so write() never implies close_write()."""
|
||||
ch = FakeChannel()
|
||||
stream = open_stream_on_transport(FakeTransport(ch), "docker system dial-stdio")
|
||||
|
||||
stream.write(b"GET /_ping HTTP/1.1\r\n\r\n")
|
||||
assert not ch.write_closed
|
||||
|
||||
stream.close_write()
|
||||
assert ch.write_closed and ch.sent.startswith(b"GET /_ping")
|
||||
|
||||
|
||||
def test_a_stream_sends_its_stdin_prefix_first():
|
||||
"""How a sudo password reaches `sudo -S` before the stream's own bytes."""
|
||||
ch = FakeChannel()
|
||||
|
||||
open_stream_on_transport(FakeTransport(ch), "sudo -S -p '' cmd", stdin_prefix=b"pw\n")
|
||||
|
||||
assert ch.sent == b"pw\n"
|
||||
|
||||
|
||||
def test_a_stream_times_out_as_timeout_error():
|
||||
stream = ParamikoExecStream(FakeChannel(hang=True))
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
stream.read(10, timeout=0.01)
|
||||
|
||||
|
||||
def test_the_stream_keeps_only_a_bounded_tail_of_stderr():
|
||||
ch = FakeChannel(stderr=[b"x" * 100, b"y" * 100])
|
||||
stream = ParamikoExecStream(ch, stderr_limit=50)
|
||||
|
||||
assert stream.stderr == "y" * 50
|
||||
|
||||
|
||||
def test_exit_status_is_none_while_running_and_the_code_after():
|
||||
running = ParamikoExecStream(FakeChannel(hang=True))
|
||||
finished = ParamikoExecStream(FakeChannel(exit_code=1))
|
||||
|
||||
assert running.exit_status is None
|
||||
assert finished.exit_status == 1
|
||||
|
||||
|
||||
def test_close_closes_the_channel():
|
||||
ch = FakeChannel()
|
||||
|
||||
ParamikoExecStream(ch).close()
|
||||
|
||||
assert ch.closed
|
||||
|
||||
|
||||
def test_run_keeps_output_that_arrives_after_the_exit_status():
|
||||
"""sshd may send exit-status before the last stdout bytes."""
|
||||
|
||||
class EarlyExit(FakeChannel):
|
||||
def exit_status_ready(self):
|
||||
return True
|
||||
|
||||
def recv_ready(self):
|
||||
return False
|
||||
|
||||
channel = EarlyExit(stdout=[b"head ", b"tail"], stderr=[b"warn"])
|
||||
|
||||
result = run_on_transport(FakeTransport(channel), "cmd")
|
||||
|
||||
assert result == CommandResult("head tail", "warn", 0)
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Access to a container engine: which engines a host has, and a way to each.
|
||||
|
||||
This package abstracts the connection, the driver builds it, and netOrk does
|
||||
the talking (NetOrk/netork#765, decided 2026-10-07). So nothing here knows what
|
||||
a container is. It knows how to reach the engine's API (``dial-stdio``) and how
|
||||
to run the engine's CLI with arguments netOrk chooses, and the driver alone
|
||||
knows where that CLI lives.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types import OSDriver
|
||||
from napalm_device_types.channel import CommandResult
|
||||
from napalm_device_types.container_engine import (
|
||||
ContainerEngineConnection,
|
||||
ContainerEngineMixin,
|
||||
ContainerEngineUnavailable,
|
||||
)
|
||||
|
||||
|
||||
class FakeDriver(ContainerEngineMixin):
|
||||
"""A driver with a channel: records what it is asked to run."""
|
||||
|
||||
def __init__(self, present=("docker",), binary=None):
|
||||
self.present = set(present)
|
||||
self.binary = binary
|
||||
self.commands = []
|
||||
self.streams = []
|
||||
|
||||
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)
|
||||
|
||||
def open_stream(self, command, *, privileged=False):
|
||||
self.streams.append((command, privileged))
|
||||
return object()
|
||||
|
||||
def _binaries(self):
|
||||
return {self._container_engine_binary(e) for e in self.present}
|
||||
|
||||
def _container_engine_binary(self, engine):
|
||||
return self.binary or super()._container_engine_binary(engine)
|
||||
|
||||
|
||||
def test_a_host_with_docker_lists_it():
|
||||
assert FakeDriver().container_engines() == [{"engine": "docker", "api": "docker-engine"}]
|
||||
|
||||
|
||||
def test_a_host_without_the_cli_lists_nothing():
|
||||
assert FakeDriver(present=()).container_engines() == []
|
||||
|
||||
|
||||
def test_the_api_is_reached_through_dial_stdio():
|
||||
driver = FakeDriver()
|
||||
|
||||
driver.open_container_engine("docker").open_api()
|
||||
|
||||
assert driver.streams == [("docker system dial-stdio", False)]
|
||||
|
||||
|
||||
def test_the_driver_decides_where_the_binary_lives():
|
||||
"""QNAP keeps docker under Container Station's path; netOrk never sees it."""
|
||||
path = "/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
|
||||
driver = FakeDriver(binary=path)
|
||||
|
||||
assert driver.container_engines() == [{"engine": "docker", "api": "docker-engine"}]
|
||||
driver.open_container_engine("docker").open_api()
|
||||
|
||||
assert driver.streams == [(f"{path} system dial-stdio", False)]
|
||||
|
||||
|
||||
def test_cli_arguments_are_quoted_and_prefixed_by_the_binary():
|
||||
driver = FakeDriver()
|
||||
conn = driver.open_container_engine("docker")
|
||||
|
||||
conn.run_cli(["compose", "-f", "/srv/my stack/compose.yml", "config", "--format", "json"])
|
||||
|
||||
command, privileged, timeout, stdin = driver.commands[-1]
|
||||
assert command == "docker compose -f '/srv/my stack/compose.yml' config --format json"
|
||||
assert privileged is False and stdin is None
|
||||
|
||||
|
||||
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)
|
||||
|
||||
_command, _privileged, timeout, stdin = driver.commands[-1]
|
||||
assert (timeout, stdin) == (30, b"x")
|
||||
|
||||
|
||||
def test_a_streamed_cli_call_can_merge_stderr():
|
||||
"""compose writes its progress to stderr; a merged stream carries both."""
|
||||
driver = FakeDriver()
|
||||
conn = driver.open_container_engine("docker")
|
||||
|
||||
conn.stream_cli(["pull", "redis:7-alpine"])
|
||||
conn.stream_cli(["compose", "up", "-d", "db"], merge_stderr=True)
|
||||
|
||||
assert driver.streams == [
|
||||
("docker pull redis:7-alpine", False),
|
||||
("docker compose up -d db 2>&1", False),
|
||||
]
|
||||
|
||||
|
||||
def test_a_shell_metacharacter_in_an_argument_stays_an_argument():
|
||||
driver = FakeDriver()
|
||||
|
||||
driver.open_container_engine("docker").run_cli(["inspect", "x; rm -rf /"])
|
||||
|
||||
assert driver.commands[-1][0] == "docker inspect 'x; rm -rf /'"
|
||||
|
||||
|
||||
def test_an_unknown_engine_is_refused():
|
||||
with pytest.raises(ContainerEngineUnavailable):
|
||||
FakeDriver().open_container_engine("rkt")
|
||||
|
||||
|
||||
def test_the_connection_names_its_engine():
|
||||
conn = FakeDriver().open_container_engine("docker")
|
||||
|
||||
assert isinstance(conn, ContainerEngineConnection)
|
||||
assert conn.engine == "docker"
|
||||
|
||||
|
||||
def test_an_os_driver_does_not_get_container_engine_access_by_role():
|
||||
"""Mixed in by the driver, not by OSDriver: a Windows host is an OS driver
|
||||
too, and `hasattr(driver, "open_container_engine")` has to stay truthful."""
|
||||
assert not issubclass(OSDriver, ContainerEngineMixin)
|
||||
assert not hasattr(OSDriver, "open_container_engine")
|
||||
|
||||
|
||||
class TestPrivilegedCli:
|
||||
"""A refused engine call can be repeated as root (netork#773): the driver
|
||||
knows the binary and how it gains root, so the connection carries it."""
|
||||
|
||||
def test_run_cli_passes_privileged_to_the_channel(self):
|
||||
driver = FakeDriver()
|
||||
|
||||
driver.open_container_engine("docker").run_cli(["restart", "web"], privileged=True)
|
||||
|
||||
assert driver.commands == [("docker restart web", True, 120, None)]
|
||||
|
||||
def test_run_cli_is_unprivileged_by_default(self):
|
||||
driver = FakeDriver()
|
||||
|
||||
driver.open_container_engine("docker").run_cli(["ps"])
|
||||
|
||||
assert driver.commands[0][1] is False
|
||||
|
||||
def test_stream_cli_passes_privileged_too(self):
|
||||
driver = FakeDriver()
|
||||
|
||||
driver.open_container_engine("docker").stream_cli(["pull", "nginx"], privileged=True)
|
||||
|
||||
assert driver.streams == [("docker pull nginx", True)]
|
||||
|
||||
|
||||
class TestNoDockerModel:
|
||||
"""There is no container model here (NetOrk/netork#765): netOrk reads and
|
||||
handles containers over the Engine API, so the CLI-shaped types and the
|
||||
``get_docker_info()`` declaration that served napalm-linux' old reading are gone."""
|
||||
|
||||
def test_the_docker_types_are_gone(self):
|
||||
import napalm_device_types
|
||||
from napalm_device_types import models
|
||||
|
||||
for name in ("DockerInfoDict", "DockerContainerDict", "DockerImageDict"):
|
||||
assert not hasattr(models, name), name
|
||||
assert not hasattr(napalm_device_types, name), name
|
||||
|
||||
def test_an_os_driver_declares_no_docker_reading(self):
|
||||
from napalm_device_types import os as os_role
|
||||
|
||||
assert "get_docker_info" not in os_role.__dict__.get("OSDriver", object).__dict__
|
||||
@@ -206,3 +206,25 @@ class TestApplyReservationSet:
|
||||
assert driver.commits == 0
|
||||
assert driver.applied == []
|
||||
assert lines == ["[commit] no changes"]
|
||||
|
||||
|
||||
class TestKeysThatCannotNameOneReservation:
|
||||
def test_a_live_reservation_without_mac_is_never_matched(self):
|
||||
driver = _FakeDhcpServer([_reservation(uuid="b", mac="")])
|
||||
|
||||
diff = driver.diff_dhcp_reservations([_reservation()])
|
||||
|
||||
assert diff == {"add": [_reservation()], "update": []}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"live, desired",
|
||||
[
|
||||
([], [_reservation(mac="")]),
|
||||
([], [_reservation(mac="AA-BB-CC-DD-EE-02"), _reservation(mac="aabb.ccdd.ee02")]),
|
||||
([_reservation(uuid="a"), _reservation(uuid="b")], [_reservation()]),
|
||||
],
|
||||
ids=["empty desired", "desired twice", "two live share it"],
|
||||
)
|
||||
def test_raises(self, live, desired):
|
||||
with pytest.raises(ValueError):
|
||||
_FakeDhcpServer(live).diff_dhcp_reservations(desired)
|
||||
@@ -0,0 +1,70 @@
|
||||
# -*- 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
|
||||
pairs with and each peer's role, as the server words it. `get_config_sync` is
|
||||
where a host pushes its configuration and for which of its services. Both are
|
||||
declarations; what they mean for the pair -- hot standby, who to write to --
|
||||
is the caller's to work out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
import napalm_device_types as package
|
||||
from napalm_device_types import ConfigSyncMixin, DhcpServerMixin, FirewallDriver
|
||||
|
||||
|
||||
class TestDeclaredOnly:
|
||||
def test_get_dhcp_ha_is_absent_until_a_driver_implements_it(self):
|
||||
assert not hasattr(DhcpServerMixin, "get_dhcp_ha")
|
||||
|
||||
def test_get_config_sync_is_absent_until_a_driver_implements_it(self):
|
||||
assert not hasattr(ConfigSyncMixin, "get_config_sync")
|
||||
|
||||
def test_config_sync_mixin_owns_nothing_callable(self):
|
||||
own = [
|
||||
name
|
||||
for name, value in vars(ConfigSyncMixin).items()
|
||||
if not name.startswith("__") and inspect.isfunction(value)
|
||||
]
|
||||
assert own == []
|
||||
|
||||
def test_the_firewall_role_does_not_carry_config_sync(self):
|
||||
"""A driver mixes the capability in; the role does not promise it."""
|
||||
assert not issubclass(FirewallDriver, ConfigSyncMixin)
|
||||
|
||||
def test_a_driver_that_implements_them_is_found_by_hasattr(self):
|
||||
class Pair(ConfigSyncMixin, DhcpServerMixin):
|
||||
def get_dhcp_ha(self):
|
||||
return {"enabled": False, "peers": []}
|
||||
|
||||
def get_config_sync(self):
|
||||
return {"peer": None, "services": [], "unmapped": []}
|
||||
|
||||
assert callable(getattr(Pair(), "get_dhcp_ha", None))
|
||||
assert callable(getattr(Pair(), "get_config_sync", None))
|
||||
|
||||
|
||||
class TestTypes:
|
||||
def test_are_exported(self):
|
||||
for name in ("ConfigSyncMixin", "ConfigSyncDict", "DhcpHaDict", "DhcpHaPeerDict"):
|
||||
assert name in package.__all__ and hasattr(package, name), name
|
||||
|
||||
def test_a_peer_needs_only_its_name_and_role(self):
|
||||
"""A future server may not have a URL to report; Kea does."""
|
||||
from napalm_device_types import DhcpHaPeerDict
|
||||
|
||||
assert DhcpHaPeerDict.__required_keys__ == frozenset({"name", "role"})
|
||||
|
||||
def test_ha_needs_only_whether_it_is_on_and_the_peers(self):
|
||||
from napalm_device_types import DhcpHaDict
|
||||
|
||||
assert DhcpHaDict.__required_keys__ == frozenset({"enabled", "peers"})
|
||||
|
||||
def test_config_sync_reports_what_it_could_not_name(self):
|
||||
"""A sync item the driver has no service name for is still a fact."""
|
||||
from napalm_device_types import ConfigSyncDict
|
||||
|
||||
assert ConfigSyncDict.__required_keys__ == frozenset({"peer", "services", "unmapped"})
|
||||
@@ -226,3 +226,13 @@ def test_device_specific_methods_are_absent_until_implemented(method: str) -> No
|
||||
def test_calling_a_missing_device_method_fails_loudly() -> None:
|
||||
with pytest.raises(AttributeError):
|
||||
_BareDriver().get_dhcp_subnets()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"desired",
|
||||
[[_subnet(subnet="")], [_subnet(), _subnet(subnet=" 10.10.20.0/24 ")]],
|
||||
ids=["empty", "twice"],
|
||||
)
|
||||
def test_a_subnet_that_cannot_name_one_raises(desired):
|
||||
with pytest.raises(ValueError):
|
||||
_FakeDhcpServer([]).diff_dhcp_subnets(desired)
|
||||
@@ -8,6 +8,7 @@ from napalm_device_types import (
|
||||
DeviceTypeDriver,
|
||||
FingerprintRule,
|
||||
FirewallDriver,
|
||||
rules_max_weight,
|
||||
HypervisorDriver,
|
||||
OSDriver,
|
||||
PortSpec,
|
||||
@@ -90,6 +91,53 @@ def test_fingerprint_rule_negative():
|
||||
assert r.negative is True
|
||||
|
||||
|
||||
def test_fingerprint_rule_has_no_group_by_default():
|
||||
assert FingerprintRule("fritz!box").group is None
|
||||
|
||||
|
||||
def test_fingerprint_rule_group():
|
||||
r = FingerprintRule("idrac", weight=6.0, group="landing_page")
|
||||
assert r.group == "landing_page"
|
||||
|
||||
|
||||
# ── 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
|
||||
|
||||
|
||||
def test_max_weight_counts_one_rule_per_group():
|
||||
# Alternatives: a page is ATEN's or iDRAC's or iLO's, never all three. Summing
|
||||
# them made a driver that knows three BMC families lose to one that knows none.
|
||||
rules = [
|
||||
FingerprintRule("aten international", 6.0, group="landing_page"),
|
||||
FingerprintRule("idrac", 6.0, group="landing_page"),
|
||||
FingerprintRule("integrated lights-out", 4.0, group="landing_page"),
|
||||
]
|
||||
assert rules_max_weight(rules) == 6.0
|
||||
|
||||
|
||||
def test_max_weight_mixes_groups_and_single_rules():
|
||||
rules = [
|
||||
FingerprintRule("redfishversion", 10.0, group="redfish_answer"),
|
||||
FingerprintRule("@message.extendedinfo", 8.0, group="redfish_answer"),
|
||||
FingerprintRule("#serviceroot.", 5.0),
|
||||
FingerprintRule("aten international", 6.0, group="landing_page"),
|
||||
FingerprintRule("idrac", 6.0, group="landing_page"),
|
||||
]
|
||||
assert rules_max_weight(rules) == 21.0
|
||||
|
||||
|
||||
def test_max_weight_ignores_negative_rules():
|
||||
rules = [FingerprintRule("vcenter", 9.0), FingerprintRule("vmware esxi", 9.0, negative=True)]
|
||||
assert rules_max_weight(rules) == 9.0
|
||||
|
||||
|
||||
def test_max_weight_of_no_rules_is_zero():
|
||||
assert rules_max_weight([]) == 0.0
|
||||
|
||||
|
||||
def test_fingerprint_rule_is_immutable():
|
||||
r = FingerprintRule("test", weight=3.0)
|
||||
with pytest.raises(AttributeError):
|
||||
|
||||
@@ -169,3 +169,30 @@ class TestApplyFirewallRuleset:
|
||||
|
||||
assert driver.applied == []
|
||||
assert driver.committed is True
|
||||
|
||||
|
||||
class TestKeysThatCannotNameOneRule:
|
||||
"""An empty or shared description must never overwrite a rule by guess."""
|
||||
|
||||
def test_a_live_rule_without_description_is_never_matched(self):
|
||||
driver = _FakeFirewall([_rule(uuid="auto1", description="", action="block")])
|
||||
|
||||
diff = driver.diff_firewall_rules([_rule()])
|
||||
|
||||
assert diff == {"add": [_rule()], "update": []}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"live, desired",
|
||||
[
|
||||
([], [_rule(description="")]),
|
||||
([], [_rule(), _rule(action="block")]),
|
||||
([_rule(uuid="u1"), _rule(uuid="u2")], [_rule()]),
|
||||
],
|
||||
ids=["empty desired", "desired twice", "two live rules share it"],
|
||||
)
|
||||
def test_raises(self, live, desired):
|
||||
driver = _FakeFirewall(live)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
driver.diff_firewall_rules(desired)
|
||||
assert driver.applied == []
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Fix routines are templates (docs/STRUCTURE.md, NetOrk/netork#1105).
|
||||
|
||||
The template fixes the order of the steps, the driver knows how each step is done
|
||||
on its device, the caller supplies every value. None of them is mixed into a role
|
||||
base, so ``callable(getattr(driver, name, None))`` stays a truthful answer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types import (
|
||||
AccessPointDriver,
|
||||
EnsurePackageMixin,
|
||||
FirewallDriver,
|
||||
HypervisorDriver,
|
||||
ManagementControllerDriver,
|
||||
MediaDriver,
|
||||
OSDriver,
|
||||
PhoneDriver,
|
||||
ResidentialGatewayDriver,
|
||||
StorageDriver,
|
||||
SwitchDriver,
|
||||
PackageProxyMixin,
|
||||
SnmpAgentMixin,
|
||||
run_fix_steps,
|
||||
)
|
||||
|
||||
ROLE_BASES = [
|
||||
AccessPointDriver, FirewallDriver, HypervisorDriver, ManagementControllerDriver, MediaDriver,
|
||||
OSDriver, PhoneDriver, ResidentialGatewayDriver, StorageDriver, SwitchDriver,
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def _ok(output: str = ""):
|
||||
return lambda: {"ok": True, "output": output}
|
||||
|
||||
|
||||
def _fail(output: str):
|
||||
return lambda: {"ok": False, "output": output}
|
||||
|
||||
|
||||
class TestRunFixSteps:
|
||||
def test_every_step_runs_in_order_and_is_reported(self):
|
||||
result = run_fix_steps([("install", _ok("installed")), ("start", _ok("started"))])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["steps"] == [
|
||||
{"step": "install", "ok": True, "output": "installed"},
|
||||
{"step": "start", "ok": True, "output": "started"},
|
||||
]
|
||||
assert result["output"] == "[install] installed\n[start] started"
|
||||
|
||||
def test_the_first_failure_stops_the_rest(self):
|
||||
ran = []
|
||||
|
||||
def later():
|
||||
ran.append("later")
|
||||
return {"ok": True, "output": ""}
|
||||
|
||||
result = run_fix_steps([("install", _fail("no package")), ("start", later)])
|
||||
|
||||
assert result["success"] is False
|
||||
assert [s["step"] for s in result["steps"]] == ["install"]
|
||||
assert ran == []
|
||||
|
||||
def test_a_step_that_raises_is_a_failed_step(self):
|
||||
def broken():
|
||||
raise RuntimeError("connection lost")
|
||||
|
||||
result = run_fix_steps([("install", broken)])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["steps"] == [{"step": "install", "ok": False, "output": "connection lost"}]
|
||||
|
||||
|
||||
class _Agent(SnmpAgentMixin):
|
||||
def __init__(self, fail_at: str = ""):
|
||||
self.calls: list = []
|
||||
self.fail_at = fail_at
|
||||
|
||||
def _step(self, name, *args):
|
||||
self.calls.append((name, *args))
|
||||
return {"ok": name != self.fail_at, "output": name}
|
||||
|
||||
def _install_snmp_agent(self):
|
||||
return self._step("install")
|
||||
|
||||
def _write_snmp_agent_config(self, settings):
|
||||
return self._step("configure", settings)
|
||||
|
||||
def _allow_snmp_sources(self, settings):
|
||||
return self._step("firewall", settings)
|
||||
|
||||
def _start_snmp_agent(self):
|
||||
return self._step("start")
|
||||
|
||||
|
||||
class TestSnmpAgent:
|
||||
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()
|
||||
|
||||
result = agent.configure_snmp_agent(self.SETTINGS)
|
||||
|
||||
assert result["success"] is True
|
||||
assert agent.calls == [
|
||||
("install",),
|
||||
("configure", self.SETTINGS),
|
||||
("firewall", self.SETTINGS),
|
||||
("start",),
|
||||
]
|
||||
|
||||
def test_a_failed_install_configures_nothing(self):
|
||||
agent = _Agent(fail_at="install")
|
||||
|
||||
assert agent.configure_snmp_agent(self.SETTINGS)["success"] is False
|
||||
assert agent.calls == [("install",)]
|
||||
|
||||
|
||||
class _Packages(EnsurePackageMixin):
|
||||
def __init__(self, installed):
|
||||
self.installed = installed
|
||||
self.asked: list = []
|
||||
|
||||
def get_packages(self):
|
||||
return [{"name": n, "installed": True} for n in self.installed] + [
|
||||
{"name": "lldpd", "installed": False}
|
||||
]
|
||||
|
||||
def install_package(self, name, version=""):
|
||||
self.asked.append(name)
|
||||
|
||||
|
||||
class TestEnsurePackage:
|
||||
def test_installs_what_is_missing(self):
|
||||
driver = _Packages(installed=["curl"])
|
||||
|
||||
result = driver.ensure_package("lldpd")
|
||||
|
||||
assert result["success"] is True
|
||||
assert driver.asked == ["lldpd"]
|
||||
|
||||
def test_leaves_alone_what_is_there(self):
|
||||
driver = _Packages(installed=["lldpd"])
|
||||
|
||||
result = driver.ensure_package("lldpd")
|
||||
|
||||
assert result["success"] is True
|
||||
assert driver.asked == []
|
||||
assert "already installed" in result["output"]
|
||||
|
||||
def test_an_install_that_reports_failure_fails(self):
|
||||
driver = _Packages(installed=[])
|
||||
driver.install_package = lambda name, version="": {"success": False, "output": "E: no candidate"}
|
||||
|
||||
result = driver.ensure_package("lldpd")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "no candidate" in result["output"]
|
||||
|
||||
|
||||
@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"],
|
||||
)
|
||||
def test_no_role_base_offers_a_fix(base, method):
|
||||
assert getattr(base, method, None) is None
|
||||
|
||||
|
||||
def test_the_package_proxy_is_declared_only():
|
||||
assert not hasattr(PackageProxyMixin, "configure_package_proxy")
|
||||
@@ -0,0 +1,569 @@
|
||||
"""Host status: does the host need a reboot, and does it patch itself?
|
||||
|
||||
A patch run that installed a new kernel has not closed anything until the host
|
||||
boots it, so "reboot required" is part of being patched. Whether the host
|
||||
installs updates on its own (unattended-upgrades, dnf-automatic) is what netOrk
|
||||
shows next to the window it governs. Both are read the same way on every Linux
|
||||
host, so the command and its parse live here once (netOrk MVP 5).
|
||||
|
||||
The fixtures are the real states of six hosts on netOrk's test server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types import OSDriver
|
||||
from napalm_device_types.host_status import (
|
||||
BUSY_CHECK,
|
||||
HOST_STATUS_COMMAND,
|
||||
HostStatusMixin,
|
||||
kernel_reboot_pending,
|
||||
parse_host_status,
|
||||
)
|
||||
|
||||
|
||||
def _wire(
|
||||
*,
|
||||
reboot_file=False,
|
||||
running="6.8.0-142-generic",
|
||||
modules=("6.8.0-139-generic", "6.8.0-142-generic"),
|
||||
needs_restarting=None,
|
||||
periodic=None,
|
||||
stamp=None,
|
||||
timer="enabled",
|
||||
unattended=True,
|
||||
dnf_timers=("not-found", "not-found"),
|
||||
dnf_conf=(),
|
||||
dpkg=None,
|
||||
journal=(),
|
||||
busy=False,
|
||||
):
|
||||
lines = ["HSTAT_BEGIN"]
|
||||
if reboot_file:
|
||||
lines.append("[reboot-required]")
|
||||
if needs_restarting is not None:
|
||||
lines += ["[needs-restarting]", str(needs_restarting)]
|
||||
lines += ["[kernel]", running, "[modules]", *modules]
|
||||
if periodic is not None:
|
||||
lines += ["[apt-config]", *periodic]
|
||||
if unattended:
|
||||
lines.append("[unattended-upgrade]")
|
||||
lines += ["[upgrade-stamp]", *([str(stamp)] if stamp else [])]
|
||||
if dpkg is not None:
|
||||
if busy:
|
||||
lines.append("[dpkg-busy]")
|
||||
lines += ["[dpkg-journal]", *journal, "[dpkg-audit]", *dpkg]
|
||||
lines += [
|
||||
"[timers]",
|
||||
f"apt-daily-upgrade.timer {timer}",
|
||||
f"dnf-automatic.timer {dnf_timers[0]}",
|
||||
f"dnf-automatic-install.timer {dnf_timers[1]}",
|
||||
"[dnf-automatic]",
|
||||
*dnf_conf,
|
||||
"HSTAT_END",
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
UNATTENDED = ['APT::Periodic::Update-Package-Lists "1";', 'APT::Periodic::Unattended-Upgrade "1";']
|
||||
|
||||
|
||||
class TestRebootRequired:
|
||||
def test_the_reboot_required_file_says_so(self):
|
||||
status = parse_host_status(_wire(reboot_file=True))
|
||||
|
||||
assert status["reboot_required"] is True
|
||||
assert "reboot-required" in status["reboot_reason"]
|
||||
|
||||
def test_a_newer_installed_kernel_than_the_running_one(self):
|
||||
"""z2m-garden: running 6.8.0-139, 6.8.0-142 installed."""
|
||||
status = parse_host_status(
|
||||
_wire(
|
||||
running="6.8.0-139-generic",
|
||||
modules=("6.8.0-87-generic", "6.8.0-139-generic", "6.8.0-142-generic"),
|
||||
)
|
||||
)
|
||||
|
||||
assert status["reboot_required"] is True
|
||||
assert "6.8.0-142-generic" in status["reboot_reason"]
|
||||
|
||||
def test_the_newest_kernel_running_needs_none(self):
|
||||
"""vault-01: 6.8.0-142 running and newest; 6.8.0-94 sorts below it."""
|
||||
status = parse_host_status(
|
||||
_wire(running="6.8.0-142-generic", modules=("6.8.0-94-generic", "6.8.0-142-generic"))
|
||||
)
|
||||
|
||||
assert status["reboot_required"] is False
|
||||
assert status["reboot_reason"] is None
|
||||
|
||||
def test_needs_restarting_exit_1_means_reboot(self):
|
||||
assert parse_host_status(_wire(needs_restarting=1))["reboot_required"] is True
|
||||
|
||||
def test_needs_restarting_exit_0_does_not(self):
|
||||
assert parse_host_status(_wire(needs_restarting=0))["reboot_required"] is False
|
||||
|
||||
def test_no_kernel_information_is_unknown(self):
|
||||
"""A container has no /lib/modules of its own."""
|
||||
status = parse_host_status(_wire(modules=()))
|
||||
|
||||
assert status["reboot_required"] is None
|
||||
|
||||
|
||||
class TestKernelRebootPending:
|
||||
@pytest.mark.parametrize(
|
||||
("running", "installed", "newer"),
|
||||
[
|
||||
# Raspberry Pi: two flavours side by side; only the running one counts.
|
||||
(
|
||||
"6.18.33+rpt-rpi-v8",
|
||||
[
|
||||
"6.12.75+rpt-rpi-2712",
|
||||
"6.12.75+rpt-rpi-v8",
|
||||
"6.18.33+rpt-rpi-2712",
|
||||
"6.18.33+rpt-rpi-v8",
|
||||
],
|
||||
None,
|
||||
),
|
||||
# Debian (OMV): 7.1.8 installed while 7.1.3 runs.
|
||||
(
|
||||
"7.1.3+deb13-amd64",
|
||||
["6.12.57+deb13-amd64", "7.1.3+deb13-amd64", "7.1.8+deb13-amd64"],
|
||||
"7.1.8+deb13-amd64",
|
||||
),
|
||||
# Proxmox: 7.0.14-19 is newer than 7.0.2-6, numerically.
|
||||
("7.0.14-19-pve", ["7.0.14-19-pve", "7.0.2-6-pve"], None),
|
||||
# Arch: the running kernel's modules were replaced by the upgrade.
|
||||
("6.10.5-arch1-1", ["6.10.9-arch1-1"], "6.10.9-arch1-1"),
|
||||
# RHEL/Fedora: the release changes with every update, the arch does not.
|
||||
(
|
||||
"5.14.0-427.13.1.el9_4.x86_64",
|
||||
["5.14.0-427.13.1.el9_4.x86_64", "5.14.0-503.11.1.el9_5.x86_64"],
|
||||
"5.14.0-503.11.1.el9_5.x86_64",
|
||||
),
|
||||
("6.9.7-200.fc40.x86_64", ["6.9.7-200.fc40.x86_64"], None),
|
||||
],
|
||||
)
|
||||
def test_the_newer_kernel_of_the_running_flavour(self, running, installed, newer):
|
||||
assert kernel_reboot_pending(running, installed) == newer
|
||||
|
||||
|
||||
class TestAutoUpdates:
|
||||
def test_unattended_upgrades_switched_on(self):
|
||||
assert parse_host_status(_wire(periodic=UNATTENDED))["auto_updates"] is True
|
||||
|
||||
def test_apt_without_the_setting_does_not_patch_itself(self):
|
||||
"""Proxmox and Raspberry Pi OS: apt-config answers, the setting is absent."""
|
||||
assert parse_host_status(_wire(periodic=[]))["auto_updates"] is False
|
||||
|
||||
def test_switched_off_by_zero(self):
|
||||
off = ['APT::Periodic::Unattended-Upgrade "0";']
|
||||
|
||||
assert parse_host_status(_wire(periodic=off))["auto_updates"] is False
|
||||
|
||||
def test_a_disabled_timer_stops_it_even_when_configured(self):
|
||||
status = parse_host_status(_wire(periodic=UNATTENDED, timer="disabled"))
|
||||
|
||||
assert status["auto_updates"] is False
|
||||
|
||||
def test_removed_but_not_purged_does_not_patch_itself(self):
|
||||
"""``apt remove unattended-upgrades`` leaves its conffile, and its "1", behind."""
|
||||
status = parse_host_status(_wire(periodic=UNATTENDED, unattended=False))
|
||||
|
||||
assert status["auto_updates"] is False
|
||||
|
||||
def test_dnf_automatic_install_timer_always_installs(self):
|
||||
status = parse_host_status(_wire(dnf_timers=("not-found", "enabled")))
|
||||
|
||||
assert status["auto_updates"] is True
|
||||
|
||||
def test_dnf_automatic_with_apply_updates(self):
|
||||
conf = ["apply_updates = yes"]
|
||||
status = parse_host_status(_wire(dnf_timers=("enabled", "not-found"), dnf_conf=conf))
|
||||
|
||||
assert status["auto_updates"] is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"conf", [[], ["apply_updates = no"], ["apply_updates=yes", "apply_updates = False"]]
|
||||
)
|
||||
def test_dnf_automatic_by_default_only_downloads(self, conf):
|
||||
"""dnf-automatic.timer installs only with apply_updates; the default is no.
|
||||
dnf5's file comes last and wins."""
|
||||
status = parse_host_status(_wire(dnf_timers=("enabled", "not-found"), dnf_conf=conf))
|
||||
|
||||
assert status["auto_updates"] is False
|
||||
|
||||
def test_the_command_reads_apply_updates_of_dnf4_then_dnf5(self):
|
||||
conf = "/etc/dnf/automatic.conf /etc/dnf/dnf5-plugins/automatic.conf"
|
||||
assert conf in HOST_STATUS_COMMAND
|
||||
|
||||
@pytest.mark.parametrize("installed", [True, False])
|
||||
def test_the_command_asks_whether_unattended_upgrade_is_installed(self, tmp_path, installed):
|
||||
"""Run for real, with a stand-in apt-config that still says "1"."""
|
||||
if os.path.exists("/usr/bin/unattended-upgrade"):
|
||||
pytest.skip("this host has unattended-upgrade installed")
|
||||
apt_config = tmp_path / "apt-config"
|
||||
apt_config.write_text("#!/bin/sh\necho 'APT::Periodic::Unattended-Upgrade \"1\";'\n")
|
||||
stubs = [apt_config]
|
||||
if installed:
|
||||
stubs.append(tmp_path / "unattended-upgrade")
|
||||
stubs[-1].write_text("#!/bin/sh\nexit 0\n")
|
||||
for stub in stubs:
|
||||
stub.chmod(0o755)
|
||||
env = {**os.environ, "PATH": f"{tmp_path}:{os.environ.get('PATH', '/usr/bin:/bin')}"}
|
||||
out = subprocess.run(
|
||||
["sh", "-c", HOST_STATUS_COMMAND], capture_output=True, text=True, env=env
|
||||
).stdout
|
||||
|
||||
assert ("[unattended-upgrade]" in out) is installed
|
||||
|
||||
def test_neither_apt_nor_dnf_is_unknown(self):
|
||||
assert parse_host_status(_wire())["auto_updates"] is None
|
||||
|
||||
|
||||
class TestTheReport:
|
||||
def test_a_cut_short_report_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_host_status(_wire().replace("HSTAT_END\n", ""))
|
||||
|
||||
def test_the_frame_is_not_in_the_command_itself(self):
|
||||
assert "HSTAT_BEGIN" not in HOST_STATUS_COMMAND
|
||||
assert "HSTAT_END" not in HOST_STATUS_COMMAND
|
||||
|
||||
def test_it_runs_without_a_terminal(self):
|
||||
"""No colour codes from ls, nothing a screen scraper could take for a prompt."""
|
||||
assert HOST_STATUS_COMMAND.rstrip().endswith("| cat")
|
||||
|
||||
def test_terminal_codes_are_dropped(self):
|
||||
status = parse_host_status(
|
||||
"\x1b[0m" + _wire(reboot_file=True).replace("HSTAT_END", "\x1b>HSTAT_END")
|
||||
)
|
||||
|
||||
assert status["reboot_required"] is True
|
||||
|
||||
def test_it_changes_nothing(self):
|
||||
for word in ("rm ", "apt-get ", "dnf install", "systemctl start", "reboot"):
|
||||
assert word not in HOST_STATUS_COMMAND.replace("reboot-required", "")
|
||||
|
||||
|
||||
class _Driver(HostStatusMixin):
|
||||
def __init__(self, reply: str) -> None:
|
||||
self.reply = reply
|
||||
self.commands: list = []
|
||||
|
||||
def _run_host_status_command(self, command: str) -> str:
|
||||
self.commands.append(command)
|
||||
return self.reply
|
||||
|
||||
|
||||
class TestHostStatusMixin:
|
||||
def test_a_driver_supplies_only_the_transport(self):
|
||||
driver = _Driver(_wire(reboot_file=True, periodic=UNATTENDED))
|
||||
|
||||
status = driver.get_host_status()
|
||||
|
||||
assert driver.commands == [HOST_STATUS_COMMAND]
|
||||
assert (status["reboot_required"], status["auto_updates"]) == (True, True)
|
||||
|
||||
def test_not_every_os_driver_has_it(self):
|
||||
assert not hasattr(OSDriver, "get_host_status")
|
||||
|
||||
|
||||
class TestFreeBSD:
|
||||
"""freebsd-version -k names the installed kernel, -r the running one; a
|
||||
patch that replaced the kernel shows as the two differing until the host
|
||||
boots it. Recorded on a FreeBSD 15.1 host (NetOrk/netork#799)."""
|
||||
|
||||
#: What the command printed there before this section existed.
|
||||
BEFORE = (
|
||||
"HSTAT_BEGIN\n[kernel]\n15.1-RELEASE\n[modules]\n[timers]\n"
|
||||
"apt-daily-upgrade.timer \ndnf-automatic.timer \ndnf-automatic-install.timer \n"
|
||||
"HSTAT_END\n"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _report(installed: str, running: str) -> str:
|
||||
return (
|
||||
f"HSTAT_BEGIN\n[kernel]\n{running}\n[modules]\n"
|
||||
f"[freebsd-kernel]\n{installed}\n[freebsd-running]\n{running}\n"
|
||||
"[timers]\napt-daily-upgrade.timer \nHSTAT_END\n"
|
||||
)
|
||||
|
||||
def test_without_the_section_it_is_unknown(self):
|
||||
assert parse_host_status(self.BEFORE)["reboot_required"] is None
|
||||
|
||||
def test_the_running_kernel_is_the_installed_one(self):
|
||||
status = parse_host_status(self._report("15.1-RELEASE-p4", "15.1-RELEASE-p4"))
|
||||
assert status["reboot_required"] is False
|
||||
assert status["reboot_reason"] is None
|
||||
|
||||
def test_a_patched_kernel_waits_for_a_reboot(self):
|
||||
status = parse_host_status(self._report("15.1-RELEASE-p5", "15.1-RELEASE-p4"))
|
||||
assert status["reboot_required"] is True
|
||||
assert status["reboot_reason"] == (
|
||||
"kernel 15.1-RELEASE-p5 installed, 15.1-RELEASE-p4 running"
|
||||
)
|
||||
|
||||
def test_freebsd_does_not_patch_itself_as_far_as_netork_can_tell(self):
|
||||
status = parse_host_status(self._report("15.1-RELEASE", "15.1-RELEASE"))
|
||||
assert status["auto_updates"] is None
|
||||
|
||||
def test_the_command_asks_only_where_freebsd_version_exists(self):
|
||||
assert "command -v freebsd-version" in HOST_STATUS_COMMAND
|
||||
assert "freebsd-version -k" in HOST_STATUS_COMMAND
|
||||
assert "freebsd-version -r" in HOST_STATUS_COMMAND
|
||||
|
||||
|
||||
class TestInterruptedDpkg:
|
||||
"""dpkg left halfway: apt refuses to install anything until
|
||||
``dpkg --configure -a`` has run, and the daily apt timers report success
|
||||
all the same (NetOrk/netork#738).
|
||||
|
||||
The fixture is aris (Ubuntu 24.04): unattended-upgrades was cut off on
|
||||
2026-07-03 while update-initramfs built the initrd for 6.8.0-134, and the
|
||||
kernel package stayed half-configured for three months.
|
||||
"""
|
||||
|
||||
ARIS = ("iF linux-image-6.8.0-134-generic",)
|
||||
|
||||
def test_a_half_configured_kernel_is_an_interrupted_dpkg(self):
|
||||
status = parse_host_status(_wire(dpkg=self.ARIS))
|
||||
|
||||
assert status["package_manager_interrupted"] is True
|
||||
assert status["interrupted_packages"] == [
|
||||
{"name": "linux-image-6.8.0-134-generic", "state": "half-configured"}
|
||||
]
|
||||
|
||||
def test_a_clean_dpkg_is_not_interrupted(self):
|
||||
status = parse_host_status(_wire(dpkg=()))
|
||||
|
||||
assert status["package_manager_interrupted"] is False
|
||||
assert status["interrupted_packages"] == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("line", "state"),
|
||||
[
|
||||
("iH base-files", "half-installed"),
|
||||
("iU base-files", "unpacked"),
|
||||
("iF base-files", "half-configured"),
|
||||
("iW base-files", "triggers-awaited"),
|
||||
("it base-files", "triggers-pending"),
|
||||
("iUR base-files", "reinstall-required"),
|
||||
("hF base-files", "half-configured"),
|
||||
],
|
||||
)
|
||||
def test_every_state_an_interrupted_run_leaves(self, line, state):
|
||||
status = parse_host_status(_wire(dpkg=(line,)))
|
||||
|
||||
assert status["interrupted_packages"] == [{"name": "base-files", "state": state}]
|
||||
|
||||
def test_a_journal_left_behind_is_interrupted_on_its_own(self):
|
||||
"""What apt itself checks: numbered files in /var/lib/dpkg/updates."""
|
||||
status = parse_host_status(_wire(dpkg=(), journal=("0000", "0001")))
|
||||
|
||||
assert status["package_manager_interrupted"] is True
|
||||
assert status["interrupted_packages"] == []
|
||||
|
||||
def test_only_numbered_journal_files_count(self):
|
||||
"""apt ignores anything else there, such as dpkg's tmp.i."""
|
||||
status = parse_host_status(_wire(dpkg=(), journal=("tmp.i",)))
|
||||
|
||||
assert status["package_manager_interrupted"] is False
|
||||
|
||||
def test_a_running_package_manager_makes_it_unknown(self):
|
||||
"""Mid-upgrade, packages are unpacked and not yet configured, also
|
||||
between two of apt's dpkg calls. That is work in progress, not an
|
||||
interruption."""
|
||||
status = parse_host_status(_wire(dpkg=("iU base-files",), busy=True))
|
||||
|
||||
assert status["package_manager_interrupted"] is None
|
||||
assert status["interrupted_packages"] == []
|
||||
|
||||
def test_without_dpkg_it_is_unknown(self):
|
||||
status = parse_host_status(_wire())
|
||||
|
||||
assert status["package_manager_interrupted"] is None
|
||||
assert status["interrupted_packages"] == []
|
||||
|
||||
def test_the_command_asks_dpkg_only_where_it_exists(self):
|
||||
assert "command -v dpkg-query" in HOST_STATUS_COMMAND
|
||||
assert "/var/lib/dpkg/updates" in HOST_STATUS_COMMAND
|
||||
|
||||
def test_it_never_repairs(self):
|
||||
assert "--configure" not in HOST_STATUS_COMMAND
|
||||
|
||||
|
||||
class TestAutoUpdatesLastSuccess:
|
||||
"""apt.systemd.daily touches upgrade-stamp only after unattended-upgrade
|
||||
succeeded. update-success-stamp says nothing: netOrk's own daily
|
||||
``apt-get update`` touches it as well (NetOrk/netork#738)."""
|
||||
|
||||
def test_the_upgrade_stamp_is_the_last_successful_run(self):
|
||||
status = parse_host_status(_wire(periodic=UNATTENDED, stamp=1751525820))
|
||||
|
||||
assert status["auto_updates_last_success"] == 1751525820
|
||||
assert status["auto_updates_interval"] == 86400
|
||||
|
||||
def test_no_stamp_is_unknown(self):
|
||||
status = parse_host_status(_wire(periodic=UNATTENDED))
|
||||
|
||||
assert status["auto_updates_last_success"] is None
|
||||
|
||||
def test_without_apt_both_are_unknown(self):
|
||||
status = parse_host_status(_wire())
|
||||
|
||||
assert status["auto_updates_last_success"] is None
|
||||
assert status["auto_updates_interval"] is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "seconds"),
|
||||
[
|
||||
('"1"', 86400),
|
||||
('"7d"', 7 * 86400),
|
||||
('"12h"', 43200),
|
||||
('"30m"', 1800),
|
||||
('"90s"', 90),
|
||||
('"always"', 0),
|
||||
('"0"', None),
|
||||
('"weekly"', None),
|
||||
],
|
||||
)
|
||||
def test_the_interval_in_apts_units(self, value, seconds):
|
||||
periodic = [f"APT::Periodic::Unattended-Upgrade {value};"]
|
||||
|
||||
assert parse_host_status(_wire(periodic=periodic))["auto_updates_interval"] == seconds
|
||||
|
||||
def test_the_command_reads_the_upgrade_stamp_only(self):
|
||||
assert "/var/lib/apt/periodic/upgrade-stamp" in HOST_STATUS_COMMAND
|
||||
assert "update-success-stamp" not in HOST_STATUS_COMMAND
|
||||
|
||||
|
||||
class TestBusyCheck:
|
||||
"""Whether a package manager is working right now, run through ``sh`` with a
|
||||
stand-in ``ps``. On Ubuntu, unattended-upgrades keeps
|
||||
``unattended-upgrade-shutdown --wait-for-signal`` running all the time; its
|
||||
process name is cut to 15 characters, ``unattended-upgr``, the same as a real
|
||||
run's. Taken for a run, it made every such host's dpkg state unknown -- aris
|
||||
among them (NetOrk/netork#738)."""
|
||||
|
||||
UBUNTU_IDLE = [
|
||||
("systemd", "/sbin/init"),
|
||||
("unattended-upgr", "/usr/bin/python3 /usr/share/unattended-upgrades/unattended-upgrade-shutdown --wait-for-signal"),
|
||||
("sshd", "sshd: netork [priv]"),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _busy(tmp_path, processes) -> bool:
|
||||
comm = "\n".join(c for c, _ in processes)
|
||||
args = "\n".join(a for _, a in processes)
|
||||
ps = tmp_path / "ps"
|
||||
ps.write_text(
|
||||
"#!/bin/sh\n"
|
||||
f"case \"$*\" in *comm=*) printf '%s\\n' '{comm}';; *) printf '%s\\n' '{args}';; esac\n"
|
||||
)
|
||||
ps.chmod(0o755)
|
||||
env = {"PATH": f"{tmp_path}:/usr/bin:/bin"}
|
||||
out = subprocess.run(["sh", "-c", BUSY_CHECK], capture_output=True, text=True, env=env)
|
||||
return "[dpkg-busy]" in out.stdout
|
||||
|
||||
def test_the_shutdown_helper_of_an_idle_ubuntu_host_is_no_run(self, tmp_path):
|
||||
assert self._busy(tmp_path, self.UBUNTU_IDLE) is False
|
||||
|
||||
def test_unattended_upgrade_running(self, tmp_path):
|
||||
run = ("unattended-upgr", "/usr/bin/python3 /usr/bin/unattended-upgrade")
|
||||
|
||||
assert self._busy(tmp_path, [*self.UBUNTU_IDLE, run]) is True
|
||||
|
||||
@pytest.mark.parametrize("name", ["dpkg", "apt", "apt-get", "aptitude"])
|
||||
def test_dpkg_and_apt(self, tmp_path, name):
|
||||
assert self._busy(tmp_path, [*self.UBUNTU_IDLE, (name, f"/usr/bin/{name} upgrade")]) is True
|
||||
|
||||
def test_it_is_part_of_the_command(self):
|
||||
assert BUSY_CHECK in HOST_STATUS_COMMAND
|
||||
|
||||
def test_the_command_does_not_match_itself(self):
|
||||
"""Run over an exec channel, the command is the command line of the shell
|
||||
that runs it, and ``ps -o args`` lists it."""
|
||||
assert not re.search(r"(^|[ /])unattended-upgrade( |$)", HOST_STATUS_COMMAND)
|
||||
|
||||
|
||||
class TestFilesystems:
|
||||
"""How full the filesystems an upgrade writes to are: ``/``, ``/boot`` and
|
||||
``/var``, read with ``df -Pk`` and no root.
|
||||
|
||||
pve-dual's root filesystem stood at 94 % with about 400 MB free for a week;
|
||||
a kernel upgrade failed on it with "No space left on device" and left dpkg
|
||||
halfway, and nothing had said the disk was full (NetOrk/netork#862). The
|
||||
figures below are shaped after it (6.5 GiB, 400 MiB available): ext4 keeps
|
||||
5 % for root, so what is available is less than total minus used.
|
||||
"""
|
||||
|
||||
HEADER = "Filesystem 1024-blocks Used Available Capacity Mounted on"
|
||||
PVE_ROOT = "/dev/mapper/pve-root 6815744 6065357 409600 94% /"
|
||||
BOOT = "/dev/sda2 1046512 104860 941652 11% /boot"
|
||||
|
||||
@staticmethod
|
||||
def _report(*df_lines: str) -> str:
|
||||
return _wire().replace("[timers]", "\n".join(["[filesystems]", *df_lines, "[timers]"]))
|
||||
|
||||
def test_the_root_filesystem_in_bytes(self):
|
||||
status = parse_host_status(self._report(self.HEADER, self.PVE_ROOT))
|
||||
|
||||
assert status["filesystems"] == [
|
||||
{
|
||||
"mountpoint": "/",
|
||||
"total": 6815744 * 1024,
|
||||
"used": 6065357 * 1024,
|
||||
"available": 409600 * 1024,
|
||||
}
|
||||
]
|
||||
|
||||
def test_a_separate_boot_partition(self):
|
||||
status = parse_host_status(self._report(self.HEADER, self.PVE_ROOT, self.BOOT))
|
||||
|
||||
assert [fs["mountpoint"] for fs in status["filesystems"]] == ["/", "/boot"]
|
||||
|
||||
def test_a_directory_on_root_is_root_once(self):
|
||||
"""``df /boot /var`` names the filesystem they live on, here ``/`` three times."""
|
||||
status = parse_host_status(
|
||||
self._report(self.HEADER, self.PVE_ROOT, self.PVE_ROOT, self.PVE_ROOT)
|
||||
)
|
||||
|
||||
assert [fs["mountpoint"] for fs in status["filesystems"]] == ["/"]
|
||||
|
||||
def test_a_long_device_name_and_a_blank_in_the_mountpoint(self):
|
||||
line = "/dev/disk/by-id/a very long name 1000 600 400 60% /mnt/my disk"
|
||||
|
||||
(fs,) = parse_host_status(self._report(self.HEADER, line))["filesystems"]
|
||||
|
||||
assert (fs["mountpoint"], fs["available"]) == ("/mnt/my disk", 400 * 1024)
|
||||
|
||||
def test_a_filesystem_without_size_is_left_out(self):
|
||||
"""What a pseudo filesystem reports; it cannot run full."""
|
||||
status = parse_host_status(self._report(self.HEADER, "none 0 0 0 - /"))
|
||||
|
||||
assert status["filesystems"] is None
|
||||
|
||||
def test_without_the_section_it_is_unknown(self):
|
||||
"""An older command, or no df: None, not "nothing is full"."""
|
||||
assert parse_host_status(_wire())["filesystems"] is None
|
||||
|
||||
def test_df_that_printed_nothing_is_unknown(self):
|
||||
assert parse_host_status(self._report())["filesystems"] is None
|
||||
|
||||
def test_the_command_asks_for_the_three_without_root(self):
|
||||
assert "df -Pk / /boot /var" in HOST_STATUS_COMMAND
|
||||
assert "sudo" not in HOST_STATUS_COMMAND
|
||||
|
||||
def test_the_command_reads_them_on_a_real_shell(self):
|
||||
"""POSIX df the way a host's sh runs it: ``/`` is always there, whatever it is."""
|
||||
out = subprocess.run(
|
||||
["sh", "-c", HOST_STATUS_COMMAND], capture_output=True, text=True, timeout=30
|
||||
)
|
||||
|
||||
mountpoints = [fs["mountpoint"] for fs in parse_host_status(out.stdout)["filesystems"]]
|
||||
assert "/" in mountpoints
|
||||
@@ -0,0 +1,159 @@
|
||||
"""get_kernel_facts: what the running kernel has built and loaded.
|
||||
|
||||
A kernel CVE's preconditions ask whether a module is loaded or a build option
|
||||
set. Reading that is the same on every Linux host -- one read-only command and
|
||||
its parse -- so both live here once, and a driver only carries the command
|
||||
across (#268 in netOrk).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import gzip
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types import OSDriver
|
||||
from napalm_device_types.kernel import (
|
||||
KERNEL_FACTS_COMMAND,
|
||||
KernelFactsMixin,
|
||||
module_name,
|
||||
parse_kernel_facts,
|
||||
)
|
||||
|
||||
REPORT = """[release]
|
||||
6.1.0-25-amd64
|
||||
[loaded]
|
||||
tipc
|
||||
nf_tables
|
||||
[builtin]
|
||||
kernel/net/ipv4/tcp_cubic.ko
|
||||
kernel/drivers/char/tpm/tpm-tis.ko
|
||||
[available]
|
||||
kernel/net/tipc/tipc.ko.xz
|
||||
kernel/net/can/can-raw.ko.zst
|
||||
kernel/net/netfilter/nf_tables.ko
|
||||
[config]
|
||||
CONFIG_TIPC=m
|
||||
CONFIG_BPF_JIT=y
|
||||
CONFIG_DEFAULT_HOSTNAME="(none)"
|
||||
CONFIG_HZ=250
|
||||
"""
|
||||
|
||||
|
||||
def _wire(report: str, *, noise: str = "") -> str:
|
||||
"""The report as the command prints it: framed, gzipped, base64 in lines."""
|
||||
payload = base64.encodebytes(gzip.compress(report.encode())).decode()
|
||||
return f"{noise}KFACTS_BEGIN\n{payload}KFACTS_END\n"
|
||||
|
||||
|
||||
class TestParsing:
|
||||
def test_every_section_is_read(self):
|
||||
facts = parse_kernel_facts(_wire(REPORT))
|
||||
|
||||
assert facts["release"] == "6.1.0-25-amd64"
|
||||
assert facts["loaded"] == ["nf_tables", "tipc"]
|
||||
assert facts["builtin"] == ["tcp_cubic", "tpm_tis"]
|
||||
assert facts["available"] == ["can_raw", "nf_tables", "tipc"]
|
||||
assert facts["config"] == {
|
||||
"CONFIG_TIPC": "m",
|
||||
"CONFIG_BPF_JIT": "y",
|
||||
"CONFIG_DEFAULT_HOSTNAME": "(none)",
|
||||
"CONFIG_HZ": "250",
|
||||
}
|
||||
|
||||
def test_a_section_never_printed_is_none_not_empty(self):
|
||||
"""``None`` is "could not read"; an empty list would claim "read it,
|
||||
and there is nothing" -- and that is what turns a module into
|
||||
``not_met`` downstream."""
|
||||
facts = parse_kernel_facts(_wire("[release]\n6.1.0\n[loaded]\n"))
|
||||
|
||||
assert facts["loaded"] == []
|
||||
assert facts["builtin"] is None
|
||||
assert facts["available"] is None
|
||||
assert facts["config"] is None
|
||||
|
||||
def test_whatever_surrounds_the_frame_is_ignored(self):
|
||||
"""A screen-scraping transport may echo the command or a banner."""
|
||||
noise = "Last login: today\nprintf '%s%s\\n' KFACTS_ BEGIN; ...\n"
|
||||
|
||||
assert parse_kernel_facts(_wire(REPORT, noise=noise))["release"] == "6.1.0-25-amd64"
|
||||
|
||||
def test_output_without_the_frame_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_kernel_facts("sh: gzip: not found\n")
|
||||
|
||||
def test_an_empty_payload_raises(self):
|
||||
"""gzip or base64 missing on the host: the frame is there, the report is not."""
|
||||
with pytest.raises(ValueError):
|
||||
parse_kernel_facts("KFACTS_BEGIN\nKFACTS_END\n")
|
||||
empty = base64.b64encode(gzip.compress(b"")).decode()
|
||||
with pytest.raises(ValueError):
|
||||
parse_kernel_facts(f"KFACTS_BEGIN\n{empty}\nKFACTS_END\n")
|
||||
|
||||
def test_a_damaged_payload_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_kernel_facts("KFACTS_BEGIN\nnot base64 at all!\nKFACTS_END\n")
|
||||
|
||||
|
||||
class TestModuleNames:
|
||||
@pytest.mark.parametrize(
|
||||
"raw, name",
|
||||
[
|
||||
("tipc", "tipc"),
|
||||
("kernel/net/tipc/tipc.ko", "tipc"),
|
||||
("kernel/net/tipc/tipc.ko.zst", "tipc"),
|
||||
("kernel/net/can/can-raw.ko.xz", "can_raw"),
|
||||
("CAN-RAW", "can_raw"),
|
||||
(" nf_tables ", "nf_tables"),
|
||||
],
|
||||
)
|
||||
def test_dash_and_underscore_are_one_name(self, raw, name):
|
||||
"""The kernel treats ``-`` and ``_`` in module names as the same."""
|
||||
assert module_name(raw) == name
|
||||
|
||||
|
||||
class TestTheCommand:
|
||||
def test_the_frame_is_not_in_the_command_itself(self):
|
||||
"""An echoing transport prints the command back; the markers must only
|
||||
appear once the command has run."""
|
||||
assert "KFACTS_BEGIN" not in KERNEL_FACTS_COMMAND
|
||||
assert "KFACTS_END" not in KERNEL_FACTS_COMMAND
|
||||
|
||||
def test_it_writes_nothing(self):
|
||||
for verb in ("modprobe", "insmod", "rmmod", "sudo", " > ", ">>"):
|
||||
assert verb not in KERNEL_FACTS_COMMAND
|
||||
|
||||
@pytest.mark.skipif(os.uname().sysname != "Linux", reason="reads a Linux kernel")
|
||||
def test_it_runs_and_parses_on_this_host(self):
|
||||
out = subprocess.run(
|
||||
["sh", "-c", KERNEL_FACTS_COMMAND], capture_output=True, text=True, timeout=60
|
||||
).stdout
|
||||
|
||||
facts = parse_kernel_facts(out)
|
||||
|
||||
assert facts["release"] == os.uname().release
|
||||
assert facts["loaded"] is None or all(isinstance(m, str) for m in facts["loaded"])
|
||||
|
||||
|
||||
class TestTheTemplate:
|
||||
def test_a_driver_supplies_only_the_transport(self):
|
||||
class Driver(KernelFactsMixin):
|
||||
def _run_kernel_facts_command(self, command: str) -> str:
|
||||
self.sent = command
|
||||
return _wire(REPORT)
|
||||
|
||||
driver = Driver()
|
||||
facts = driver.get_kernel_facts()
|
||||
|
||||
assert driver.sent == KERNEL_FACTS_COMMAND
|
||||
assert facts["release"] == "6.1.0-25-amd64"
|
||||
|
||||
def test_not_every_os_driver_has_it(self):
|
||||
"""A Windows host is an OSDriver too, and has no Linux kernel to read:
|
||||
``hasattr`` has to stay a truthful answer, so the drivers that can mix
|
||||
this in themselves."""
|
||||
assert not issubclass(OSDriver, KernelFactsMixin)
|
||||
assert not hasattr(OSDriver, "get_kernel_facts")
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests for add_lag_interfaces — one logical row per trunk group."""
|
||||
|
||||
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:
|
||||
port = {
|
||||
"is_up": is_up,
|
||||
"is_enabled": is_enabled,
|
||||
"description": "",
|
||||
"last_flapped": -1.0,
|
||||
"speed": speed,
|
||||
"mtu": -1,
|
||||
"mac_address": "",
|
||||
}
|
||||
if trunk_group:
|
||||
port["trunk_group"] = trunk_group
|
||||
return port
|
||||
|
||||
|
||||
def test_adds_one_row_per_trunk_group():
|
||||
ifaces = {
|
||||
"1": _port(),
|
||||
"3": _port(trunk_group="Trk3"),
|
||||
"4": _port(trunk_group="Trk3"),
|
||||
"10": _port(trunk_group="Trk6"),
|
||||
"7": _port(trunk_group="Trk6"),
|
||||
}
|
||||
result = add_lag_interfaces(ifaces)
|
||||
|
||||
assert result["Trk3"]["lag_members"] == ["3", "4"]
|
||||
# Members in natural port order, not string order ("7" before "10").
|
||||
assert result["Trk6"]["lag_members"] == ["7", "10"]
|
||||
assert result["Trk6"]["description"] == "LAG (7, 10)"
|
||||
assert "Trk1" not in result
|
||||
|
||||
|
||||
def test_state_is_derived_from_members():
|
||||
ifaces = {
|
||||
"3": _port(is_up=False, speed=1000.0, trunk_group="Trk3"),
|
||||
"4": _port(is_up=True, speed=1000.0, trunk_group="Trk3"),
|
||||
"6": _port(is_up=False, is_enabled=False, trunk_group="Trk6"),
|
||||
}
|
||||
result = add_lag_interfaces(ifaces)
|
||||
|
||||
assert result["Trk3"]["is_up"] is True
|
||||
assert result["Trk3"]["is_enabled"] is True
|
||||
assert result["Trk3"]["speed"] == 2000.0
|
||||
assert result["Trk6"]["is_up"] is False
|
||||
assert result["Trk6"]["is_enabled"] is False
|
||||
|
||||
|
||||
def test_lag_mode_only_when_known():
|
||||
"""The UI reads a missing mode as "static trunk"; guessing would mislabel LACP."""
|
||||
ifaces = {"3": _port(trunk_group="Trk3"), "6": _port(trunk_group="Trk6")}
|
||||
result = add_lag_interfaces(ifaces, lag_modes={"Trk3": "lacp"})
|
||||
|
||||
assert result["Trk3"]["lag_mode"] == "lacp"
|
||||
assert "lag_mode" not in result["Trk6"]
|
||||
|
||||
|
||||
def test_keeps_a_lag_the_driver_already_reported():
|
||||
ifaces = {
|
||||
"3": _port(trunk_group="Trk3"),
|
||||
"Trk3": {**_port(), "description": "uplink", "lag_members": ["3"]},
|
||||
}
|
||||
result = add_lag_interfaces(ifaces)
|
||||
|
||||
assert result["Trk3"]["description"] == "uplink"
|
||||
|
||||
|
||||
def test_does_not_modify_its_input():
|
||||
ifaces = {"3": _port(trunk_group="Trk3")}
|
||||
add_lag_interfaces(ifaces)
|
||||
|
||||
assert list(ifaces) == ["3"]
|
||||
|
||||
|
||||
def test_an_unknown_member_speed_does_not_make_the_lag_negative():
|
||||
ifaces = {
|
||||
"3": _port(speed=-1.0, trunk_group="Trk3"),
|
||||
"4": _port(speed=1000.0, trunk_group="Trk3"),
|
||||
}
|
||||
|
||||
assert add_lag_interfaces(ifaces)["Trk3"]["speed"] == 1000.0
|
||||
@@ -0,0 +1,397 @@
|
||||
"""get_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
|
||||
listens on -- ``0.0.0.0:5432`` is, ``127.0.0.1:5432`` is not. Reading that is
|
||||
the same on every Linux host: ``ss`` for the sockets, ``/proc/<pid>/cgroup``
|
||||
for the systemd unit or container a process belongs to. So both live here once,
|
||||
and a driver only carries the command across (#658 in netOrk).
|
||||
|
||||
A host without ``ss`` -- OpenWrt's busybox, an old net-tools box -- is read
|
||||
with ``netstat -lntup`` instead, and on OpenWrt the cgroup names the procd
|
||||
service (#673 in netOrk).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types import OSDriver
|
||||
from napalm_device_types.listening import (
|
||||
LISTENING_SOCKETS_COMMAND,
|
||||
ListeningSocketsMixin,
|
||||
ListeningSocketsUnavailable,
|
||||
parse_listening_sockets,
|
||||
)
|
||||
|
||||
CID = "4f1c" + "0" * 60
|
||||
SS = """Netid State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
|
||||
udp UNCONN 0 0 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=612,fd=13))
|
||||
udp UNCONN 0 0 0.0.0.0%ens18:68 0.0.0.0:* users:(("systemd-network",pid=590,fd=22))
|
||||
tcp LISTEN 0 4096 0.0.0.0:5432 0.0.0.0:* users:(("postgres",pid=812,fd=6))
|
||||
tcp LISTEN 0 128 [::]:22 [::]:* users:(("sshd",pid=700,fd=4))
|
||||
tcp LISTEN 0 511 *:80 *:* users:(("nginx",pid=901,fd=6),("nginx",pid=900,fd=6))
|
||||
tcp LISTEN 0 4096 [::ffff:127.0.0.1]:8125 *:* users:(("statsd",pid=950,fd=3))
|
||||
tcp LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("docker-proxy",pid=1200,fd=4))
|
||||
tcp LISTEN 0 4096 0.0.0.0:9100 0.0.0.0:* users:(("node_exporter",pid=1300,fd=3))
|
||||
tcp LISTEN 0 64 0.0.0.0:2049 0.0.0.0:*
|
||||
"""
|
||||
CGROUPS = f"""612 0::/system.slice/systemd-resolved.service
|
||||
590 0::/system.slice/systemd-networkd.service
|
||||
812 0::/system.slice/system-postgresql.slice/postgresql@16-main.service
|
||||
700 0::/system.slice/ssh.service
|
||||
900 0::/system.slice/nginx.service
|
||||
901 0::/system.slice/nginx.service
|
||||
950 0::/user.slice/user-1000.slice/session-3.scope
|
||||
1200 0::/system.slice/docker.service
|
||||
1300 0::/system.slice/docker-{CID}.scope
|
||||
"""
|
||||
|
||||
|
||||
def _wire(ss: str = SS, cgroups: str = CGROUPS, *, rc: int = 0, noise: str = "") -> str:
|
||||
"""The report as the command prints it, framed."""
|
||||
return f"{noise}SOCK_BEGIN\n[ss]\n{ss}__SS_RC={rc}\n[cgroups]\n{cgroups}SOCK_END\n"
|
||||
|
||||
|
||||
def _by_port(sockets: list) -> dict:
|
||||
return {(s["proto"], s["port"], s["address"]): s for s in sockets}
|
||||
|
||||
|
||||
class TestParsing:
|
||||
def test_a_socket_comes_with_its_process_and_unit(self):
|
||||
sockets = _by_port(parse_listening_sockets(_wire()))
|
||||
|
||||
assert sockets[("tcp", 5432, "0.0.0.0")] == {
|
||||
"proto": "tcp",
|
||||
"address": "0.0.0.0",
|
||||
"port": 5432,
|
||||
"interface": None,
|
||||
"process": "postgres",
|
||||
"pid": 812,
|
||||
"unit": "postgresql@16-main",
|
||||
"container_id": None,
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"local, address, interface",
|
||||
[
|
||||
("[::]:22", "::", None),
|
||||
("*:80", "*", None),
|
||||
("127.0.0.53%lo:53", "127.0.0.53", "lo"),
|
||||
("0.0.0.0%ens18:68", "0.0.0.0", "ens18"),
|
||||
("[::ffff:127.0.0.1]:8125", "::ffff:127.0.0.1", None),
|
||||
("[fe80::1%eth0]:546", "fe80::1", "eth0"),
|
||||
("[fe80::1]%eth0:546", "fe80::1", "eth0"), # what iproute2 ss prints
|
||||
(":::22", "::", None), # iproute2 4.9 prints IPv6 without brackets
|
||||
],
|
||||
)
|
||||
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"
|
||||
|
||||
[socket] = parse_listening_sockets(_wire(line, "1 0::/system.slice/x.service\n"))
|
||||
|
||||
assert (socket["address"], socket["interface"]) == (address, interface)
|
||||
|
||||
def test_the_first_of_several_processes_names_the_socket(self):
|
||||
nginx = _by_port(parse_listening_sockets(_wire()))[("tcp", 80, "*")]
|
||||
|
||||
assert (nginx["process"], nginx["pid"], nginx["unit"]) == ("nginx", 901, "nginx")
|
||||
|
||||
def test_a_socket_without_a_process_is_kept(self):
|
||||
"""The kernel's nfsd, or every socket of another user without root."""
|
||||
nfs = _by_port(parse_listening_sockets(_wire()))[("tcp", 2049, "0.0.0.0")]
|
||||
|
||||
assert (nfs["process"], nfs["pid"], nfs["unit"], nfs["container_id"]) == (
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
def test_the_header_is_no_socket(self):
|
||||
sockets = parse_listening_sockets(_wire())
|
||||
|
||||
assert len(sockets) == 9
|
||||
assert all(s["proto"] in ("tcp", "udp") for s in sockets)
|
||||
|
||||
def test_sorted_by_protocol_port_and_address(self):
|
||||
keys = [(s["proto"], s["port"], s["address"]) for s in parse_listening_sockets(_wire())]
|
||||
|
||||
assert keys == sorted(keys)
|
||||
|
||||
def test_nothing_listening_is_an_empty_list(self):
|
||||
assert parse_listening_sockets(_wire(SS.splitlines(True)[0], "")) == []
|
||||
|
||||
|
||||
class TestCgroups:
|
||||
def _unit_and_container(self, cgroup_lines: str) -> tuple:
|
||||
line = 'tcp LISTEN 0 128 0.0.0.0:1 0.0.0.0:* users:(("p",pid=5,fd=3))\n'
|
||||
[socket] = parse_listening_sockets(_wire(line, cgroup_lines))
|
||||
return socket["unit"], socket["container_id"]
|
||||
|
||||
def test_a_container_on_the_host_network(self):
|
||||
assert self._unit_and_container(f"5 0::/system.slice/docker-{CID}.scope\n") == (
|
||||
None,
|
||||
CID,
|
||||
)
|
||||
|
||||
def test_a_container_under_the_cgroupfs_driver(self):
|
||||
assert self._unit_and_container(f"5 0::/docker/{CID}\n") == (None, CID)
|
||||
|
||||
def test_cgroup_v1_reads_the_systemd_hierarchy(self):
|
||||
lines = "5 12:pids:/user.slice\n5 1:name=systemd:/system.slice/ssh.service\n"
|
||||
|
||||
assert self._unit_and_container(lines) == ("ssh", None)
|
||||
|
||||
def test_an_escaped_template_instance(self):
|
||||
lines = "5 0::/system.slice/system-wg\\x2dquick.slice/wg-quick@wg0.service\n"
|
||||
|
||||
assert self._unit_and_container(lines) == ("wg-quick@wg0", None)
|
||||
|
||||
def test_a_procd_service_on_openwrt(self):
|
||||
"""procd puts every instance into /services/<name>/<instance>, a jailed
|
||||
one too -- its PID is not the one procd reports, its cgroup is."""
|
||||
assert self._unit_and_container("5 0::/services/dnsmasq/cfg01411c\n") == (
|
||||
"dnsmasq",
|
||||
None,
|
||||
)
|
||||
|
||||
def test_a_login_session_is_no_unit(self):
|
||||
assert self._unit_and_container("5 0::/user.slice/user-1000.slice/session-3.scope\n") == (
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
def test_a_process_gone_before_its_cgroup_was_read(self):
|
||||
assert self._unit_and_container("") == (None, None)
|
||||
|
||||
def test_the_docker_daemons_own_proxy_stays_raw(self):
|
||||
"""docker-proxy runs in docker.service. Telling it apart from a real
|
||||
listener is the consumer's business; the reading reports what is there."""
|
||||
proxy = _by_port(parse_listening_sockets(_wire()))[("tcp", 8080, "0.0.0.0")]
|
||||
|
||||
assert (proxy["process"], proxy["unit"]) == ("docker-proxy", "docker")
|
||||
|
||||
|
||||
class TestFailures:
|
||||
def test_whatever_surrounds_the_frame_is_ignored(self):
|
||||
noisy = _wire(noise="$ sh -c '...'\nWelcome to Ubuntu\n")
|
||||
|
||||
assert len(parse_listening_sockets(noisy)) == 9
|
||||
|
||||
def test_output_without_the_frame_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_listening_sockets("sudo: a password is required\n")
|
||||
|
||||
def test_a_report_cut_short_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_listening_sockets(_wire()[:-len("SOCK_END\n")])
|
||||
|
||||
def test_a_host_without_ss_or_netstat_is_unavailable(self):
|
||||
with pytest.raises(ListeningSocketsUnavailable):
|
||||
parse_listening_sockets("SOCK_BEGIN\n[no-ss]\nSOCK_END\n")
|
||||
|
||||
def test_ss_failing_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_listening_sockets(_wire("ss: invalid option -- 'p'\n", "", rc=1))
|
||||
|
||||
def test_unavailable_is_not_a_value_error(self):
|
||||
"""A caller retries a broken report without privilege; a host without
|
||||
ss has nothing to retry."""
|
||||
assert not issubclass(ListeningSocketsUnavailable, ValueError)
|
||||
|
||||
|
||||
# busybox netstat on OpenWrt 25.12, addresses replaced by documentation ones.
|
||||
BUSYBOX = """Active Internet connections (only servers)
|
||||
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
|
||||
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1604/dropbear
|
||||
tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN 1969/uhttpd
|
||||
tcp 0 0 192.0.2.15:53 0.0.0.0:* LISTEN 1495/dnsmasq
|
||||
tcp 0 0 127.0.0.1:53 0.0.0.0:* LISTEN 1495/dnsmasq
|
||||
tcp 0 0 :::22 :::* LISTEN 1604/dropbear
|
||||
tcp 0 0 fe80::1:53 :::* LISTEN 1495/dnsmasq
|
||||
tcp 0 0 ::1:53 :::* LISTEN 1495/dnsmasq
|
||||
udp 0 0 192.0.2.15:53 0.0.0.0:* 1495/dnsmasq
|
||||
udp 0 0 0.0.0.0:161 0.0.0.0:* 3173/snmpd
|
||||
udp 0 0 0.0.0.0:5353 0.0.0.0:* -
|
||||
"""
|
||||
PROCD = """1495 0::/services/dnsmasq/cfg01411c
|
||||
1604 0::/services/dropbear/instance1
|
||||
1969 0::/services/uhttpd/instance1
|
||||
3173 0::/services/snmpd/instance1
|
||||
"""
|
||||
# net-tools netstat on an old Debian: tcp6/udp6, and a program name with a space.
|
||||
NET_TOOLS = """Active Internet connections (only servers)
|
||||
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
|
||||
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 700/sshd: /usr/sbin
|
||||
tcp6 0 0 :::22 :::* LISTEN 700/sshd: /usr/sbin
|
||||
udp6 0 0 :::5353 :::* -
|
||||
"""
|
||||
|
||||
|
||||
def _netstat(out: str = BUSYBOX, cgroups: str = PROCD, *, rc: int = 0) -> str:
|
||||
return f"SOCK_BEGIN\n[netstat]\n{out}__SS_RC={rc}\n[cgroups]\n{cgroups}SOCK_END\n"
|
||||
|
||||
|
||||
class TestNetstat:
|
||||
"""A host without ss (#673 in netOrk): OpenWrt's busybox, old net-tools."""
|
||||
|
||||
def test_a_socket_comes_with_its_process_and_procd_service(self):
|
||||
sockets = _by_port(parse_listening_sockets(_netstat()))
|
||||
|
||||
assert sockets[("tcp", 22, "0.0.0.0")] == {
|
||||
"proto": "tcp",
|
||||
"address": "0.0.0.0",
|
||||
"port": 22,
|
||||
"interface": None,
|
||||
"process": "dropbear",
|
||||
"pid": 1604,
|
||||
"unit": "dropbear",
|
||||
"container_id": None,
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"proto, port, address",
|
||||
[("tcp", 22, "::"), ("tcp", 53, "fe80::1"), ("tcp", 53, "::1"), ("tcp", 53, "192.0.2.15")],
|
||||
)
|
||||
def test_every_address_form(self, proto, port, address):
|
||||
assert (proto, port, address) in _by_port(parse_listening_sockets(_netstat()))
|
||||
|
||||
def test_a_udp_socket_has_no_state_column(self):
|
||||
snmpd = _by_port(parse_listening_sockets(_netstat()))[("udp", 161, "0.0.0.0")]
|
||||
|
||||
assert (snmpd["process"], snmpd["pid"], snmpd["unit"]) == ("snmpd", 3173, "snmpd")
|
||||
|
||||
def test_a_socket_without_a_process_is_kept(self):
|
||||
mdns = _by_port(parse_listening_sockets(_netstat()))[("udp", 5353, "0.0.0.0")]
|
||||
|
||||
assert (mdns["process"], mdns["pid"], mdns["unit"]) == (None, None, None)
|
||||
|
||||
def test_the_headers_are_no_sockets(self):
|
||||
assert len(parse_listening_sockets(_netstat())) == 10
|
||||
|
||||
def test_net_tools_names_ipv6_and_programs_its_own_way(self):
|
||||
sockets = _by_port(parse_listening_sockets(_netstat(NET_TOOLS, "")))
|
||||
|
||||
assert sockets[("tcp", 22, "::")]["process"] == "sshd"
|
||||
assert sockets[("tcp", 22, "::")]["pid"] == 700
|
||||
assert ("udp", 5353, "::") in sockets
|
||||
|
||||
def test_netstat_failing_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_listening_sockets(_netstat("netstat: invalid option -- 'p'\n", "", rc=1))
|
||||
|
||||
|
||||
class TestTheNetstatFallback:
|
||||
"""The command itself, on a host where ss is missing and netstat is not."""
|
||||
|
||||
@pytest.mark.skipif(
|
||||
any(os.path.exists(f"{d}/ss") for d in ("/usr/sbin", "/sbin")),
|
||||
reason="ss sits on the PATH the command always adds",
|
||||
)
|
||||
def test_it_reads_netstat_when_there_is_no_ss(self, tmp_path):
|
||||
for tool in ("grep", "cut", "sort", "sed", "tr", "cat"):
|
||||
(tmp_path / tool).symlink_to(shutil.which(tool))
|
||||
stub = tmp_path / "netstat"
|
||||
stub.write_text(f"#!/bin/sh\ncat <<'EOF'\n{BUSYBOX}EOF\n")
|
||||
stub.chmod(0o755)
|
||||
|
||||
out = subprocess.run(
|
||||
["/bin/sh", "-c", LISTENING_SOCKETS_COMMAND],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={"PATH": str(tmp_path)},
|
||||
timeout=30,
|
||||
).stdout
|
||||
|
||||
assert "[netstat]" in out
|
||||
sockets = _by_port(parse_listening_sockets(out))
|
||||
assert sockets[("tcp", 443, "0.0.0.0")]["process"] == "uhttpd"
|
||||
assert len(sockets) == 10
|
||||
|
||||
|
||||
class TestTheCommand:
|
||||
def test_the_frame_is_not_in_the_command_itself(self):
|
||||
"""An echoing transport prints the command back; the markers must only
|
||||
appear once the command has run."""
|
||||
assert "SOCK_BEGIN" not in LISTENING_SOCKETS_COMMAND
|
||||
assert "SOCK_END" not in LISTENING_SOCKETS_COMMAND
|
||||
|
||||
def test_it_writes_and_changes_nothing(self):
|
||||
for verb in ("sudo", " > ", ">>", "kill", "rm ", "systemctl "):
|
||||
assert verb not in LISTENING_SOCKETS_COMMAND
|
||||
|
||||
def test_it_is_posix_sh(self):
|
||||
result = subprocess.run(
|
||||
["sh", "-n", "-c", LISTENING_SOCKETS_COMMAND], capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
@pytest.mark.skipif(shutil.which("ss") is None, reason="needs ss on this host")
|
||||
def test_it_runs_and_parses_on_this_host(self):
|
||||
out = subprocess.run(
|
||||
["sh", "-c", LISTENING_SOCKETS_COMMAND], capture_output=True, text=True, timeout=60
|
||||
).stdout
|
||||
|
||||
sockets = parse_listening_sockets(out)
|
||||
|
||||
assert all(s["proto"] in ("tcp", "udp") and 0 < s["port"] < 65536 for s in sockets)
|
||||
|
||||
|
||||
class _Driver(ListeningSocketsMixin):
|
||||
def __init__(self, privileged: str, unprivileged: str = "") -> None:
|
||||
self.answers = {True: privileged, False: unprivileged}
|
||||
self.calls: list = []
|
||||
|
||||
def _run_listening_sockets_command(self, command: str, *, privileged: bool) -> str:
|
||||
self.calls.append((command, privileged))
|
||||
return self.answers[privileged]
|
||||
|
||||
|
||||
class TestTheTemplate:
|
||||
def test_a_driver_supplies_only_the_transport(self):
|
||||
driver = _Driver(_wire())
|
||||
|
||||
reading = driver.get_listening_sockets()
|
||||
|
||||
assert reading["attributed"] is True
|
||||
assert len(reading["sockets"]) == 9
|
||||
[(command, privileged)] = driver.calls
|
||||
assert privileged is True
|
||||
|
||||
def test_the_command_reaches_root_as_one_shell(self):
|
||||
"""``sudo -n a; b`` runs only ``a`` as root: the whole script goes as
|
||||
one ``sh -c`` argument."""
|
||||
driver = _Driver(_wire())
|
||||
driver.get_listening_sockets()
|
||||
|
||||
[(command, _privileged)] = driver.calls
|
||||
assert command.startswith("sh -c '")
|
||||
assert subprocess.run(["sh", "-n", "-c", command]).returncode == 0
|
||||
|
||||
def test_without_root_it_reads_what_it_can(self):
|
||||
"""Without sudo, ss names only the user's own processes: the sockets
|
||||
are still worth having, marked as not attributed."""
|
||||
driver = _Driver("sudo: a password is required\n", _wire(cgroups=""))
|
||||
|
||||
reading = driver.get_listening_sockets()
|
||||
|
||||
assert reading["attributed"] is False
|
||||
assert len(reading["sockets"]) == 9
|
||||
assert [p for _c, p in driver.calls] == [True, False]
|
||||
|
||||
def test_a_host_without_either_is_not_asked_twice(self):
|
||||
driver = _Driver("SOCK_BEGIN\n[no-ss]\nSOCK_END\n")
|
||||
|
||||
with pytest.raises(ListeningSocketsUnavailable):
|
||||
driver.get_listening_sockets()
|
||||
assert len(driver.calls) == 1
|
||||
|
||||
def test_not_every_os_driver_has_it(self):
|
||||
"""A Windows host is an OSDriver too and has no ss: ``hasattr`` has to
|
||||
stay a truthful answer, so the drivers that can mix this in themselves."""
|
||||
assert not issubclass(OSDriver, ListeningSocketsMixin)
|
||||
assert not hasattr(OSDriver, "get_listening_sockets")
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Pending updates: which package, from where, and whether it closes a security hole.
|
||||
|
||||
A patch deadline ("security updates within 14 days") needs to know which pending
|
||||
update is a security update. apt says so in the suite a candidate comes from
|
||||
(``noble-security``, ``stable-security``); dnf says so in its update advisories.
|
||||
Reading that is the same for every driver whose host runs apt or dnf, so the
|
||||
parsers live here once (netOrk MVP 5, #556).
|
||||
|
||||
Fixture lines are from real hosts (Ubuntu 24.04, Debian 13 / OMV, Proxmox VE 9).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types.package_updates import (
|
||||
APT_UPGRADABLE_COMMAND,
|
||||
DNF_SECURITY_COMMAND,
|
||||
nevra_name,
|
||||
parse_apt_upgradable,
|
||||
parse_dnf_security,
|
||||
)
|
||||
|
||||
UBUNTU = """\
|
||||
docker-compose-plugin/noble 5.6.0-1~ubuntu.24.04~noble amd64 [upgradable from: 5.5.1-1~ubuntu.24.04~noble]
|
||||
openssl/noble-updates,noble-security 3.0.13-0ubuntu3.6 amd64 [upgradable from: 3.0.13-0ubuntu3.5]
|
||||
__APT_RC=0
|
||||
"""
|
||||
|
||||
DEBIAN = """\
|
||||
linux-image-amd64/stable-backports 7.1.13-1~bpo13+1 amd64 [upgradable from: 7.1.8-1~bpo13+1]
|
||||
libssl3t64/stable-security 3.5.1-1+deb13u2 amd64 [upgradable from: 3.5.1-1+deb13u1]
|
||||
__APT_RC=0
|
||||
"""
|
||||
|
||||
|
||||
def _by_name(updates):
|
||||
return {u["name"]: u for u in updates}
|
||||
|
||||
|
||||
class TestAptUpgradable:
|
||||
def test_each_line_is_a_package_with_both_versions(self):
|
||||
update = _by_name(parse_apt_upgradable(UBUNTU))["docker-compose-plugin"]
|
||||
|
||||
assert update["current_version"] == "5.5.1-1~ubuntu.24.04~noble"
|
||||
assert update["new_version"] == "5.6.0-1~ubuntu.24.04~noble"
|
||||
|
||||
def test_the_suites_are_its_origin(self):
|
||||
updates = _by_name(parse_apt_upgradable(UBUNTU))
|
||||
|
||||
assert updates["openssl"]["origin"] == "noble-updates,noble-security"
|
||||
assert updates["docker-compose-plugin"]["origin"] == "noble"
|
||||
|
||||
def test_a_suite_apt_lists_twice_is_named_once(self):
|
||||
line = (
|
||||
"fonts-opensymbol/noble-updates,noble-updates,noble-security,noble-security "
|
||||
"4:102.12+LibO24.2.7-0ubuntu0.24.04.7 all [upgradable from: 4:102.12+LibO24.2.7-0ubuntu0.24.04.6]\n"
|
||||
"__APT_RC=0\n"
|
||||
)
|
||||
|
||||
assert parse_apt_upgradable(line)[0]["origin"] == "noble-updates,noble-security"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("output", "name", "security"),
|
||||
[
|
||||
(UBUNTU, "openssl", True),
|
||||
(UBUNTU, "docker-compose-plugin", False),
|
||||
(DEBIAN, "libssl3t64", True),
|
||||
(DEBIAN, "linux-image-amd64", False),
|
||||
],
|
||||
)
|
||||
def test_a_security_suite_makes_it_a_security_update(self, output, name, security):
|
||||
assert _by_name(parse_apt_upgradable(output))[name]["security"] is security
|
||||
|
||||
def test_a_line_the_terminal_wrapped_is_joined(self):
|
||||
wrapped = (
|
||||
"openssl/noble-updates,noble-security 3.0.13-0ubuntu3.6 amd64 [upgradable fro\n"
|
||||
" m: 3.0.13-0ubuntu3.5]\n__APT_RC=0\n"
|
||||
)
|
||||
|
||||
assert _by_name(parse_apt_upgradable(wrapped))["openssl"]["current_version"] == (
|
||||
"3.0.13-0ubuntu3.5"
|
||||
)
|
||||
|
||||
def test_a_line_wrapped_at_a_blank_keeps_the_blank(self):
|
||||
wrapped = (
|
||||
"openssl/noble-updates 3.0.13-0ubuntu3.6\n"
|
||||
" amd64 [upgradable from: 3.0.13-0ubuntu3.5]\n__APT_RC=0\n"
|
||||
)
|
||||
|
||||
assert _by_name(parse_apt_upgradable(wrapped))["openssl"]["current_version"] == (
|
||||
"3.0.13-0ubuntu3.5"
|
||||
)
|
||||
|
||||
def test_one_package_for_several_architectures_is_one_entry(self):
|
||||
multiarch = (
|
||||
"libc6/noble-updates 2.39-0ubuntu8.5 amd64 [upgradable from: 2.39-0ubuntu8.4]\n"
|
||||
"libc6/noble-updates,noble-security 2.39-0ubuntu8.5 i386 [upgradable from: 2.39-0ubuntu8.4]\n"
|
||||
"__APT_RC=0\n"
|
||||
)
|
||||
|
||||
updates = parse_apt_upgradable(multiarch)
|
||||
|
||||
assert [u["name"] for u in updates] == ["libc6"]
|
||||
assert updates[0]["security"] is True
|
||||
|
||||
def test_noise_is_ignored(self):
|
||||
noisy = "Listing... Done\nWARNING: apt does not have a stable CLI interface.\n" + UBUNTU
|
||||
|
||||
assert len(parse_apt_upgradable(noisy)) == 2
|
||||
|
||||
def test_nothing_pending_is_an_empty_list(self):
|
||||
assert parse_apt_upgradable("__APT_RC=0\n") == []
|
||||
|
||||
def test_a_list_without_its_exit_status_raises(self):
|
||||
"""Cut short: a transport stopped reading early, so nothing can be concluded."""
|
||||
with pytest.raises(ValueError):
|
||||
parse_apt_upgradable(UBUNTU.replace("__APT_RC=0\n", ""))
|
||||
|
||||
def test_a_failed_apt_raises(self):
|
||||
with pytest.raises(ValueError, match="100"):
|
||||
parse_apt_upgradable("E: Could not get lock\n__APT_RC=100\n")
|
||||
|
||||
def test_terminal_codes_around_the_status_are_dropped(self):
|
||||
"""What a pseudo-terminal left on a Raspberry Pi OS host."""
|
||||
raw = "Listing... 0%\n\x1b[?1h\x1b=\n" + UBUNTU.replace("__APT_RC=0", "\x1b>__APT_RC=0")
|
||||
|
||||
assert len(parse_apt_upgradable(raw)) == 2
|
||||
|
||||
def test_the_command_reads_without_root_or_a_terminal(self):
|
||||
"""Through a pipe apt draws no progress and no terminal codes; one of
|
||||
those, ESC >, ended a screen-scraping read at a false prompt."""
|
||||
assert "LC_ALL=C apt list --upgradable" in APT_UPGRADABLE_COMMAND
|
||||
assert APT_UPGRADABLE_COMMAND.rstrip().endswith("| cat")
|
||||
assert "sudo" not in APT_UPGRADABLE_COMMAND
|
||||
|
||||
|
||||
class TestDnfSecurity:
|
||||
ADVISORIES = """\
|
||||
FEDORA-2024-1a2b3c4d5e Important/Sec. openssl-libs-1:3.1.4-2.fc40.x86_64
|
||||
FEDORA-2024-1a2b3c4d5e Important/Sec. openssl-1:3.1.4-2.fc40.x86_64
|
||||
RLSA-2024:1234 Moderate/Sec. kernel-core-5.14.0-427.13.1.el9_4.x86_64
|
||||
__DNF_RC=0
|
||||
"""
|
||||
|
||||
def test_the_names_of_packages_with_a_security_advisory(self):
|
||||
assert parse_dnf_security(self.ADVISORIES) == {"openssl-libs", "openssl", "kernel-core"}
|
||||
|
||||
def test_nothing_is_an_empty_set(self):
|
||||
assert parse_dnf_security("__DNF_RC=0\n") == set()
|
||||
|
||||
def test_a_failed_dnf_raises_instead_of_reading_as_no_advisories(self):
|
||||
"""dnf that could not reach its repositories prints nothing to stdout;
|
||||
every pending update would have become not security."""
|
||||
with pytest.raises(ValueError, match="1"):
|
||||
parse_dnf_security("__DNF_RC=1\n")
|
||||
|
||||
def test_output_cut_short_raises(self):
|
||||
with pytest.raises(ValueError, match="no exit status"):
|
||||
parse_dnf_security(self.ADVISORIES.replace("__DNF_RC=0\n", ""))
|
||||
|
||||
def test_the_command_reports_dnfs_exit_status_not_cats(self, tmp_path):
|
||||
"""A stand-in dnf that fails quietly, as one without its repositories does."""
|
||||
dnf = tmp_path / "dnf"
|
||||
dnf.write_text("#!/bin/sh\nexit 1\n")
|
||||
dnf.chmod(0o755)
|
||||
env = {"PATH": f"{tmp_path}:{os.environ.get('PATH', '/usr/bin:/bin')}"}
|
||||
out = subprocess.run(
|
||||
["sh", "-c", DNF_SECURITY_COMMAND], capture_output=True, text=True, env=env
|
||||
).stdout
|
||||
|
||||
assert out == "__DNF_RC=1\n"
|
||||
with pytest.raises(ValueError, match="exit status 1"):
|
||||
parse_dnf_security(out)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("nevra", "name"),
|
||||
[
|
||||
("openssl-libs-1:3.1.4-2.fc40.x86_64", "openssl-libs"),
|
||||
("kernel-core-5.14.0-427.13.1.el9_4.x86_64", "kernel-core"),
|
||||
("python3-dnf-4.14.0-9.el9.noarch", "python3-dnf"),
|
||||
],
|
||||
)
|
||||
def test_the_name_of_a_nevra(self, nevra, name):
|
||||
assert nevra_name(nevra) == name
|
||||
@@ -0,0 +1,9 @@
|
||||
"""The package says it is typed (PEP 561), so it must ship the marker."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import napalm_device_types
|
||||
|
||||
|
||||
def test_the_package_ships_py_typed():
|
||||
assert (Path(napalm_device_types.__file__).parent / "py.typed").is_file()
|
||||
@@ -219,3 +219,10 @@ def test_ping_sweep_stops_when_stop_requested():
|
||||
|
||||
assert result["scanned"] == 1
|
||||
assert len(driver.calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("success", [True, "yes", ["x"]])
|
||||
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"}
|
||||
@@ -0,0 +1,39 @@
|
||||
"""get_port_forwards: what the WAN side may reach inside, on any gateway.
|
||||
|
||||
The reader used to be declared on ``ResidentialGatewayDriver`` only, as if a
|
||||
port forward were a home-router feature. A firewall forwards ports just the
|
||||
same -- OPNsense calls it destination NAT -- and the two consumers that ask
|
||||
(is this host reachable from the internet, which CVEs are exposed) need the
|
||||
answer from both. The declaration therefore lives where the two roles overlap,
|
||||
next to the NAT translations reader.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
from napalm_device_types import FirewallDriver, ResidentialGatewayDriver
|
||||
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)
|
||||
|
||||
|
||||
def test_it_is_declared_once():
|
||||
assert "def get_port_forwards" not in inspect.getsource(ResidentialGatewayDriver)
|
||||
|
||||
|
||||
def test_absent_until_a_driver_implements_it():
|
||||
assert not hasattr(FirewallDriver, "get_port_forwards")
|
||||
assert not hasattr(ResidentialGatewayDriver, "get_port_forwards")
|
||||
|
||||
|
||||
def test_the_contract_says_what_counts():
|
||||
"""A redirect between two internal networks is destination NAT too, and
|
||||
would make an internal host look reachable from the internet."""
|
||||
source = inspect.getsource(NatVpnMixin)
|
||||
assert "from the WAN" in source
|
||||
assert "between internal networks" in source
|
||||
@@ -0,0 +1,102 @@
|
||||
"""What provisioning a VM from a cloud image needs to know about the guest,
|
||||
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,
|
||||
network_config,
|
||||
split_compression,
|
||||
)
|
||||
|
||||
|
||||
class TestGuestAgentDeclaration:
|
||||
"""netOrk's cloud-init installs the agent through which the hypervisor
|
||||
reads the new VM's IP. Which agent depends on the hypervisor and on the
|
||||
guest; the guests a driver lists are the ones it can provision."""
|
||||
|
||||
def test_the_base_provisions_linux_with_qemu_guest_agent(self):
|
||||
assert HypervisorDriver.GUEST_AGENTS == {
|
||||
"linux": (("qemu-guest-agent",), ("systemctl enable --now qemu-guest-agent",)),
|
||||
}
|
||||
|
||||
def test_the_declaration_is_data_netork_reads_off_the_class(self):
|
||||
assert not callable(vars(HypervisorDriver)["GUEST_AGENTS"])
|
||||
|
||||
def test_the_per_hypervisor_attributes_are_gone(self):
|
||||
assert not hasattr(HypervisorDriver, "GUEST_AGENT_PACKAGES")
|
||||
assert not hasattr(HypervisorDriver, "GUEST_AGENT_RUNCMD")
|
||||
|
||||
|
||||
class TestQemuGuestAgents:
|
||||
"""Verified on FreeBSD 15.1 and OpenBSD 7.9 cloud images (#793)."""
|
||||
|
||||
def test_linux_is_the_base_default(self):
|
||||
assert QEMU_GUEST_AGENTS["linux"] == HypervisorDriver.GUEST_AGENTS["linux"]
|
||||
|
||||
def test_freebsd(self):
|
||||
assert QEMU_GUEST_AGENTS["freebsd"] == (
|
||||
("qemu-guest-agent",),
|
||||
("sysrc qemu_guest_agent_enable=YES", "service qemu-guest-agent start"),
|
||||
)
|
||||
|
||||
def test_openbsd(self):
|
||||
assert QEMU_GUEST_AGENTS["openbsd"] == (
|
||||
("qemu-ga",),
|
||||
("rcctl enable qemu_ga", "rcctl start qemu_ga"),
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("guest_os", ["freebsd", "openbsd"])
|
||||
def test_no_bsd_guest_is_told_to_use_systemd(self, guest_os):
|
||||
_, runcmd = QEMU_GUEST_AGENTS[guest_os]
|
||||
assert not any("systemctl" in command for command in runcmd)
|
||||
|
||||
|
||||
class TestNetworkConfig:
|
||||
"""cloud-init's network-config v2. FreeBSD's nuageinit reads only this
|
||||
version; the v1 Proxmox generates makes it skip runcmd (#793)."""
|
||||
|
||||
def test_dhcp_nics_matched_by_mac(self):
|
||||
cfg = network_config([("00:50:56:aa:bb:cc", True), ("00:50:56:aa:bb:dd", False)])
|
||||
assert cfg == {
|
||||
"version": 2,
|
||||
"ethernets": {
|
||||
"nic0": {"match": {"macaddress": "00:50:56:aa:bb:cc"}, "dhcp4": True},
|
||||
},
|
||||
}
|
||||
|
||||
def test_no_dhcp_nic_means_no_network_config(self):
|
||||
assert network_config([("00:50:56:aa:bb:cc", False)]) is None
|
||||
|
||||
def test_macs_are_written_in_lower_case(self):
|
||||
"""Proxmox reports them upper-case; nuageinit compares them as given."""
|
||||
cfg = network_config([("BC:24:11:AA:BB:02", True)])
|
||||
assert cfg["ethernets"]["nic0"]["match"]["macaddress"] == "bc:24:11:aa:bb:02"
|
||||
|
||||
|
||||
class TestSplitCompression:
|
||||
"""Official FreeBSD images come packed; hypervisors import unpacked ones."""
|
||||
|
||||
def test_xz(self):
|
||||
assert split_compression("FreeBSD-15.1-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz") == (
|
||||
"FreeBSD-15.1-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2",
|
||||
"xz -dc",
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "command"),
|
||||
[("a.raw.gz", "gzip -dc"), ("a.raw.bz2", "bzip2 -dc"), ("a.qcow2.zst", "zstd -dc")],
|
||||
)
|
||||
def test_other_packers(self, name, command):
|
||||
assert split_compression(name) == (name.rsplit(".", 1)[0], command)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name", ["debian-13-genericcloud-amd64.qcow2", "ubuntu-24.04-server-cloudimg-amd64.img"]
|
||||
)
|
||||
def test_an_unpacked_image_is_left_as_it_is(self, name):
|
||||
assert split_compression(name) == (name, None)
|
||||
|
||||
def test_the_suffix_is_matched_case_insensitively(self):
|
||||
assert split_compression("IMAGE.QCOW2.XZ") == ("IMAGE.QCOW2", "xz -dc")
|
||||
@@ -22,6 +22,7 @@ from napalm_device_types import (
|
||||
DeviceTypeDriver,
|
||||
FirewallDriver,
|
||||
HypervisorDriver,
|
||||
ManagementControllerDriver,
|
||||
MediaDriver,
|
||||
OSDriver,
|
||||
PhoneDriver,
|
||||
@@ -35,6 +36,7 @@ ROLE_BASES = [
|
||||
AccessPointDriver,
|
||||
FirewallDriver,
|
||||
HypervisorDriver,
|
||||
ManagementControllerDriver,
|
||||
MediaDriver,
|
||||
OSDriver,
|
||||
PhoneDriver,
|
||||
@@ -84,6 +86,12 @@ class TestDeclaredMethodsDoNotExistAtRuntime:
|
||||
(ResidentialGatewayDriver, "get_wan_status"),
|
||||
(PhoneDriver, "get_sip_accounts"),
|
||||
(MediaDriver, "get_playback_state"),
|
||||
(ManagementControllerDriver, "get_power_state"),
|
||||
(ManagementControllerDriver, "set_power_state"),
|
||||
(ManagementControllerDriver, "get_hardware_inventory"),
|
||||
(ManagementControllerDriver, "get_event_log"),
|
||||
(ManagementControllerDriver, "get_firmware_inventory"),
|
||||
(ManagementControllerDriver, "get_management_protocols"),
|
||||
],
|
||||
)
|
||||
def test_absent_until_a_driver_implements_it(self, base, method):
|
||||
@@ -133,9 +141,76 @@ class TestRoleIntrospection:
|
||||
assert primary_role_of(Nas) == "storage"
|
||||
assert primary_role_of(Host) == "linux"
|
||||
|
||||
def test_a_driver_that_sets_its_own_role_replaces_the_inherited_one(self):
|
||||
"""A Windows host is an OS driver too, but not a "linux" one."""
|
||||
|
||||
class Windows(OSDriver):
|
||||
ROLE = "windows"
|
||||
|
||||
class Nas(StorageDriver, Windows):
|
||||
pass
|
||||
|
||||
assert primary_role_of(Windows) == "windows"
|
||||
assert [r.__name__ for r in roles_of(Windows)] == ["Windows"]
|
||||
assert [r.__name__ for r in roles_of(Nas)] == ["StorageDriver", "Windows"]
|
||||
|
||||
def test_driver_without_a_role_has_none(self):
|
||||
class Bare(DeviceTypeDriver):
|
||||
pass
|
||||
|
||||
assert roles_of(Bare) == []
|
||||
assert primary_role_of(Bare) is None
|
||||
|
||||
|
||||
class TestManagementController:
|
||||
"""A BMC is reached over Redfish or IPMI, and it is not the server it manages."""
|
||||
|
||||
def test_role_key_and_transport(self):
|
||||
assert ManagementControllerDriver.ROLE == "management_controller"
|
||||
assert ManagementControllerDriver.USES_SSH is False
|
||||
|
||||
def test_power_actions_are_redfish_reset_types(self):
|
||||
from napalm_device_types import POWER_ACTIONS
|
||||
|
||||
assert POWER_ACTIONS == (
|
||||
"On",
|
||||
"GracefulShutdown",
|
||||
"ForceOff",
|
||||
"GracefulRestart",
|
||||
"ForceRestart",
|
||||
"PowerCycle",
|
||||
)
|
||||
|
||||
def test_carries_neither_snmp_health_metrics_nor_a_command_channel(self):
|
||||
"""Health metrics are SNMP; the BMC's sensors come through get_environment."""
|
||||
from napalm_device_types import HealthMetricsMixin
|
||||
|
||||
assert not issubclass(ManagementControllerDriver, HealthMetricsMixin)
|
||||
assert not hasattr(ManagementControllerDriver, "get_health_metrics")
|
||||
assert not hasattr(ManagementControllerDriver, "run_command")
|
||||
assert not hasattr(ManagementControllerDriver, "open_stream")
|
||||
|
||||
def test_docstring_says_reboot_host_restarts_the_bmc_not_the_server(self):
|
||||
doc = ManagementControllerDriver.__doc__ or ""
|
||||
assert "reboot_host" in doc and "Manager.Reset" in doc
|
||||
assert "set_power_state" in doc
|
||||
assert "get_environment" in doc
|
||||
|
||||
def test_its_return_types_are_exported(self):
|
||||
import napalm_device_types as package
|
||||
|
||||
for name in (
|
||||
"PowerAction",
|
||||
"PowerStateDict",
|
||||
"HardwareInventoryDict",
|
||||
"EventLogEntryDict",
|
||||
"FirmwareDict",
|
||||
"ManagementProtocolsDict",
|
||||
):
|
||||
assert name in package.__all__ and hasattr(package, name), name
|
||||
|
||||
def test_a_server_driver_can_rank_it_first(self):
|
||||
class Idrac(ManagementControllerDriver):
|
||||
pass
|
||||
|
||||
assert primary_role_of(Idrac) == "management_controller"
|
||||
@@ -0,0 +1,53 @@
|
||||
"""SNMP health: drivers declare data, the application polls (docs/STRUCTURE.md).
|
||||
|
||||
``SnmpHealthProfile`` is the shape a driver states its device's OIDs in.
|
||||
``HealthMetricsMixin``, which collected in the base package, is deprecated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import ClassVar, get_type_hints
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types import (
|
||||
HealthMetricsMixin,
|
||||
SnmpHealthProfile,
|
||||
SnmpInterfacesSpec,
|
||||
SnmpMemorySpec,
|
||||
SnmpValueSpec,
|
||||
)
|
||||
|
||||
|
||||
def test_a_profile_says_where_each_value_lives():
|
||||
assert set(get_type_hints(SnmpHealthProfile)) == {"uptime", "cpu", "memory", "interfaces"}
|
||||
assert set(get_type_hints(SnmpValueSpec)) == {"oids", "table", "fold", "pattern"}
|
||||
assert set(get_type_hints(SnmpMemorySpec)) == {"total", "used", "free", "percent", "unit"}
|
||||
assert set(get_type_hints(SnmpInterfacesSpec)) == {"skip", "tx_errors_are_drops"}
|
||||
|
||||
|
||||
def test_every_key_is_optional():
|
||||
for spec in (SnmpHealthProfile, SnmpValueSpec, SnmpMemorySpec, SnmpInterfacesSpec):
|
||||
assert spec.__required_keys__ == frozenset(), spec.__name__
|
||||
|
||||
|
||||
def test_a_driver_declares_its_profile_as_a_class_attribute():
|
||||
class Driver:
|
||||
SNMP_HEALTH_PROFILE: ClassVar[SnmpHealthProfile] = {
|
||||
"cpu": {"oids": ["1.3.6.1.4.1.9.1", "1.3.6.1.4.1.9.2"]},
|
||||
"memory": {"percent": {"table": "1.3.6.1.4.1.9.3", "fold": "first"}},
|
||||
}
|
||||
|
||||
assert Driver.SNMP_HEALTH_PROFILE["cpu"]["oids"][0] == "1.3.6.1.4.1.9.1"
|
||||
|
||||
|
||||
def test_the_collector_in_the_base_package_warns_that_it_is_deprecated():
|
||||
async def get(oid):
|
||||
return None
|
||||
|
||||
async def walk(oid):
|
||||
return {}
|
||||
|
||||
with pytest.warns(DeprecationWarning, match="SNMP_HEALTH_PROFILE"):
|
||||
asyncio.run(HealthMetricsMixin.get_health_metrics(get, walk))
|
||||
@@ -0,0 +1,480 @@
|
||||
"""OpenSSH server hardening: removing algorithms a scanner flagged, without locking anyone out.
|
||||
|
||||
The plan is pure and tested on its own. The mixin is tested against a fake host
|
||||
that keeps files and answers ``sshd -T`` from them, so every path -- the
|
||||
drop-in, the sshd_config fallback, a refused config, a reload the new session
|
||||
cannot get through -- runs without a machine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types.channel import CommandResult
|
||||
from napalm_device_types.ssh_server import (
|
||||
DROPIN_PATH,
|
||||
MAIN_CONFIG,
|
||||
OpenSshServerMixin,
|
||||
has_dropin_include,
|
||||
host_key_algorithms,
|
||||
parse_sshd_effective,
|
||||
parse_sshd_hostkeys,
|
||||
plan_ssh_hardening,
|
||||
render_dropin,
|
||||
render_main_config,
|
||||
)
|
||||
|
||||
SSHD_T = """\
|
||||
port 22
|
||||
kexalgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,diffie-hellman-group14-sha256
|
||||
hostkeyalgorithms ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256
|
||||
ciphers chacha20-poly1305@openssh.com,aes128-ctr,aes256-gcm@openssh.com
|
||||
macs umac-64-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha1,hmac-sha2-256
|
||||
passwordauthentication no
|
||||
hostkey /etc/ssh/ssh_host_ecdsa_key
|
||||
hostkey /etc/ssh/ssh_host_ed25519_key
|
||||
hostkey /etc/ssh/ssh_host_rsa_key
|
||||
"""
|
||||
|
||||
#: What ``ssh-keygen -l`` prints per host key type.
|
||||
KEYGEN = {
|
||||
"/etc/ssh/ssh_host_ecdsa_key": "256 SHA256:aaaa root@host (ECDSA)",
|
||||
"/etc/ssh/ssh_host_ed25519_key": "256 SHA256:bbbb root@host (ED25519)",
|
||||
"/etc/ssh/ssh_host_rsa_key": "3072 SHA256:cccc root@host (RSA)",
|
||||
}
|
||||
|
||||
#: The lock-out case: sshd offers ECDSA, sk- and certificate algorithms, but the
|
||||
#: host has only an ed25519 and an RSA key.
|
||||
SSHD_T_FEW_KEYS = SSHD_T.replace(
|
||||
"hostkeyalgorithms ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256",
|
||||
"hostkeyalgorithms ssh-ed25519-cert-v01@openssh.com,ecdsa-sha2-nistp256-cert-v01@openssh.com,"
|
||||
"ssh-ed25519,ecdsa-sha2-nistp256,sk-ssh-ed25519@openssh.com,rsa-sha2-512,rsa-sha2-256",
|
||||
).replace("hostkey /etc/ssh/ssh_host_ecdsa_key\n", "")
|
||||
|
||||
PARAMIKO = {
|
||||
"kex": ["curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "diffie-hellman-group14-sha256"],
|
||||
"hostkey": ["ssh-ed25519", "ecdsa-sha2-nistp256", "rsa-sha2-512"],
|
||||
"cipher": ["aes128-ctr", "aes256-gcm@openssh.com"],
|
||||
"mac": ["hmac-sha2-256", "hmac-sha2-256-etm@openssh.com", "hmac-sha1"],
|
||||
}
|
||||
|
||||
|
||||
# ── parse ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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["mac"][-1] == "hmac-sha2-256"
|
||||
|
||||
|
||||
def test_parse_refuses_output_without_the_lists():
|
||||
with pytest.raises(ValueError):
|
||||
parse_sshd_effective("port 22\n")
|
||||
|
||||
|
||||
# ── plan ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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"]})
|
||||
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"]
|
||||
assert plan.refused == []
|
||||
|
||||
|
||||
def test_plan_ignores_an_algorithm_the_server_no_longer_offers():
|
||||
plan = plan_ssh_hardening(parse_sshd_effective(SSHD_T), {"cipher": ["3des-cbc"]})
|
||||
assert plan.changed_classes == []
|
||||
assert [r.algorithm for r in plan.not_offered] == ["3des-cbc"]
|
||||
|
||||
|
||||
def test_plan_never_removes_the_last_algorithm_of_a_class():
|
||||
plan = plan_ssh_hardening(
|
||||
parse_sshd_effective(SSHD_T),
|
||||
{"cipher": ["chacha20-poly1305@openssh.com", "aes128-ctr", "aes256-gcm@openssh.com"]},
|
||||
)
|
||||
# The first in the server's order stays, the rest go.
|
||||
assert plan.keep["cipher"] == ["chacha20-poly1305@openssh.com"]
|
||||
assert plan.remove["cipher"] == ["aes128-ctr", "aes256-gcm@openssh.com"]
|
||||
assert [(r.kind, r.algorithm, r.reason) for r in plan.refused] == [
|
||||
("cipher", "chacha20-poly1305@openssh.com", "last")
|
||||
]
|
||||
|
||||
|
||||
def test_plan_keeps_one_algorithm_the_managing_client_still_speaks():
|
||||
"""paramiko knows no sntrup or plain curve25519: removing everything it does
|
||||
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"]},
|
||||
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"]
|
||||
|
||||
|
||||
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"]},
|
||||
)
|
||||
assert plan.refused == []
|
||||
assert plan.keep["kex"] == ["sntrup761x25519-sha512@openssh.com", "curve25519-sha256"]
|
||||
|
||||
|
||||
def test_plan_counts_only_host_key_algorithms_the_host_has_a_key_for():
|
||||
"""Without ed25519 and RSA, only ECDSA, sk- and cert algorithms stay: offered,
|
||||
but the host has no key for them, so nobody would get in."""
|
||||
eff = parse_sshd_effective(SSHD_T_FEW_KEYS)
|
||||
plan = plan_ssh_hardening(
|
||||
eff,
|
||||
{"hostkey": ["ssh-ed25519", "rsa-sha2-256", "rsa-sha2-512"]},
|
||||
host_keys={"ssh-ed25519", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"},
|
||||
)
|
||||
assert "ssh-ed25519" in plan.keep["hostkey"]
|
||||
assert [(r.algorithm, r.reason) for r in plan.refused] == [("ssh-ed25519", "last")]
|
||||
assert plan.remove["hostkey"] == ["rsa-sha2-512", "rsa-sha2-256"]
|
||||
|
||||
|
||||
def test_plan_client_guard_counts_only_usable_host_keys():
|
||||
eff = parse_sshd_effective(SSHD_T_FEW_KEYS)
|
||||
plan = plan_ssh_hardening(
|
||||
eff,
|
||||
{"hostkey": ["ssh-ed25519", "rsa-sha2-256", "rsa-sha2-512"]},
|
||||
client=PARAMIKO,
|
||||
host_keys={"ssh-ed25519", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"},
|
||||
)
|
||||
# paramiko speaks ecdsa-sha2-nistp256, but the host has no such key.
|
||||
assert [(r.algorithm, r.reason) for r in plan.refused] == [("ssh-ed25519", "client")]
|
||||
|
||||
|
||||
def test_plan_removes_a_host_key_algorithm_when_a_usable_one_stays():
|
||||
plan = plan_ssh_hardening(
|
||||
parse_sshd_effective(SSHD_T_FEW_KEYS),
|
||||
{"hostkey": ["rsa-sha2-256"]},
|
||||
host_keys={"ssh-ed25519", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"},
|
||||
)
|
||||
assert plan.remove == {"hostkey": ["rsa-sha2-256"]}
|
||||
assert plan.refused == []
|
||||
|
||||
|
||||
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")]
|
||||
|
||||
|
||||
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",
|
||||
}
|
||||
assert host_key_algorithms("521 SHA256:x no comment (ECDSA)") == {"ecdsa-sha2-nistp521"}
|
||||
assert host_key_algorithms("") is None
|
||||
assert host_key_algorithms("garbage") is None
|
||||
assert parse_sshd_hostkeys(SSHD_T)[0] == "/etc/ssh/ssh_host_ecdsa_key"
|
||||
|
||||
|
||||
def test_plan_rejects_an_unknown_class():
|
||||
with pytest.raises(ValueError):
|
||||
plan_ssh_hardening(parse_sshd_effective(SSHD_T), {"compression": ["zlib"]})
|
||||
|
||||
|
||||
# ── render ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_dropin_states_full_lists_for_the_changed_classes_only():
|
||||
plan = plan_ssh_hardening(parse_sshd_effective(SSHD_T), {"mac": ["hmac-sha1"]})
|
||||
text = render_dropin(plan.keep, ["mac"])
|
||||
assert "MACs umac-64-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-256\n" in text
|
||||
assert "KexAlgorithms" not in text
|
||||
assert text.startswith("#") # says who wrote it
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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"
|
||||
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("#"))
|
||||
assert first_directive == "MACs hmac-sha2-256"
|
||||
assert "# netork: MACs hmac-sha1,hmac-sha2-256" in lines
|
||||
# A Match block is left alone: it is not the global setting.
|
||||
assert " MACs hmac-sha1" in lines
|
||||
|
||||
|
||||
def test_main_config_rewrite_is_idempotent():
|
||||
main = "Port 22\nMACs hmac-sha1,hmac-sha2-256\n"
|
||||
once = render_main_config(main, {"mac": ["hmac-sha2-256"]}, ["mac"])
|
||||
twice = render_main_config(once, {"mac": ["hmac-sha2-256"]}, ["mac"])
|
||||
assert once == twice
|
||||
|
||||
|
||||
# ── the mixin, against a fake host ───────────────────────────────────────
|
||||
|
||||
_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"}
|
||||
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.syntax_ok = True
|
||||
self.reloads = 0
|
||||
|
||||
def _effective(self) -> Dict[str, List[str]]:
|
||||
eff = {k: list(v) for k, v in self.defaults.items()}
|
||||
seen = set()
|
||||
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()
|
||||
elif re.match(r"\s*match\s", line, re.I):
|
||||
break
|
||||
else:
|
||||
lines.append(line)
|
||||
for line in lines:
|
||||
parts = line.split()
|
||||
if len(parts) == 2 and parts[0].lower() in _DIRECTIVE:
|
||||
kind = _DIRECTIVE[parts[0].lower()]
|
||||
if kind not in seen:
|
||||
seen.add(kind)
|
||||
eff[kind] = parts[1].split(",")
|
||||
return eff
|
||||
|
||||
#: What a real ``sudo -S`` leaves on stdin when it does not prompt (NOPASSWD,
|
||||
#: 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:
|
||||
self.ran.append(command)
|
||||
if privileged and stdin is not None:
|
||||
stdin = f"{self.SUDO_PASSWORD}\n".encode() + stdin
|
||||
m = re.search(r"mktemp", command)
|
||||
if m:
|
||||
assert not privileged, "the content must reach a file without sudo in the way"
|
||||
path = f"/tmp/netork-ssh.{len(self.files)}"
|
||||
self.files[path] = (stdin or b"").decode()
|
||||
return CommandResult(path, "", 0)
|
||||
assert privileged, command
|
||||
m = re.search(r'install -m "\$m" -- \'?([^\' ]+)\'? \'?([^\' ]+)\'?', command)
|
||||
if m:
|
||||
src, tmp = m.group(1), m.group(2)
|
||||
target = re.search(r"mv -f -- '?[^' ]+'? '?([^' ;]+)'?", command).group(1)
|
||||
ref = re.search(r"r='?([^' ;]+)'?; m=\$\(stat -c %a", command).group(1)
|
||||
self.modes[target] = self.modes.get(ref, "644") if ref in self.files else "644"
|
||||
self.files[target] = self.files.pop(src)
|
||||
assert tmp.endswith(".netork-tmp")
|
||||
return CommandResult("", "", 0)
|
||||
if "ssh-keygen -l" in command:
|
||||
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)
|
||||
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 "))
|
||||
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)
|
||||
m = re.match(r"cat -- '?([^' ]+)'?$", command)
|
||||
if m:
|
||||
path = m.group(1)
|
||||
if path in self.files:
|
||||
return CommandResult(self.files[path], "", 0)
|
||||
return CommandResult("", "No such file", 1)
|
||||
m = re.match(r"rm -f -- '?([^' ]+)'?$", command)
|
||||
if m:
|
||||
self.files.pop(m.group(1), None)
|
||||
self.modes.pop(m.group(1), None)
|
||||
return CommandResult("", "", 0)
|
||||
if "reload" in command or "HUP" in command:
|
||||
self.reloads += 1
|
||||
return CommandResult("", "", 0)
|
||||
if command.startswith("mkdir"):
|
||||
return CommandResult("", "", 0)
|
||||
raise AssertionError(f"unexpected command: {command}")
|
||||
|
||||
|
||||
def test_mixin_writes_the_dropin_checks_it_and_reloads():
|
||||
host = FakeHost()
|
||||
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
|
||||
assert "hmac-sha1" not in host._effective()["mac"]
|
||||
assert host.reloads == 1
|
||||
assert any("sshd -t" in c for c in host.ran)
|
||||
assert lines # it says what it does
|
||||
|
||||
|
||||
def test_mixin_keeps_what_an_earlier_fix_wrote():
|
||||
host = FakeHost()
|
||||
host.harden_ssh_server({"hostkey": ["ecdsa-sha2-nistp256"]})
|
||||
host.harden_ssh_server({"mac": ["hmac-sha1"]})
|
||||
eff = host._effective()
|
||||
assert "ecdsa-sha2-nistp256" not in eff["hostkey"]
|
||||
assert "hmac-sha1" not in eff["mac"]
|
||||
|
||||
|
||||
def test_mixin_falls_back_to_sshd_config_without_an_include():
|
||||
host = FakeHost(main="Port 22\nMACs hmac-sha1,hmac-sha2-256\n")
|
||||
result = host.harden_ssh_server({"mac": ["hmac-sha1"]})
|
||||
assert result.path == MAIN_CONFIG
|
||||
assert result.backup and host.files[result.backup] == "Port 22\nMACs hmac-sha1,hmac-sha2-256\n"
|
||||
assert host._effective()["mac"] == ["hmac-sha2-256"]
|
||||
|
||||
|
||||
def test_mixin_restores_when_sshd_rejects_the_config():
|
||||
host = FakeHost()
|
||||
host.syntax_ok = False
|
||||
result = host.harden_ssh_server({"mac": ["hmac-sha1"]})
|
||||
assert not result.applied and result.rolled_back
|
||||
assert DROPIN_PATH not in host.files
|
||||
assert host.reloads == 0
|
||||
assert "bad line 3" in result.message
|
||||
|
||||
|
||||
def test_mixin_restores_when_the_effective_lists_do_not_change():
|
||||
"""Another file read before ours sets the list: ours is valid and ignored."""
|
||||
host = FakeHost(main="MACs hmac-sha1,hmac-sha2-256\nInclude /etc/ssh/sshd_config.d/*.conf\n")
|
||||
result = host.harden_ssh_server({"mac": ["hmac-sha1"]})
|
||||
assert not result.applied and result.rolled_back
|
||||
assert DROPIN_PATH not in host.files
|
||||
assert "MACs" in result.message
|
||||
|
||||
|
||||
def test_mixin_rolls_back_when_a_new_session_cannot_get_in():
|
||||
host = FakeHost()
|
||||
host.files[DROPIN_PATH] = "# netOrk\nHostKeyAlgorithms ssh-ed25519\n"
|
||||
before = host.files[DROPIN_PATH]
|
||||
result = host.harden_ssh_server({"mac": ["hmac-sha1"]}, verify=lambda: False)
|
||||
assert not result.applied and result.rolled_back
|
||||
assert host.files[DROPIN_PATH] == before
|
||||
assert host.reloads == 2 # the change, and the way back
|
||||
|
||||
|
||||
def test_mixin_does_nothing_when_nothing_would_change():
|
||||
host = FakeHost()
|
||||
result = host.harden_ssh_server({"cipher": ["3des-cbc"]})
|
||||
assert not result.applied and not result.rolled_back
|
||||
assert host.reloads == 0
|
||||
assert DROPIN_PATH not in host.files
|
||||
|
||||
|
||||
def test_declared_on_no_base_class():
|
||||
"""hasattr is the capability probe: only a driver that mixes it in answers True."""
|
||||
from napalm_device_types import DeviceTypeDriver, OSDriver
|
||||
|
||||
assert not hasattr(DeviceTypeDriver, "harden_ssh_server")
|
||||
assert not hasattr(OSDriver, "harden_ssh_server")
|
||||
|
||||
|
||||
def test_main_config_second_fix_keeps_what_the_first_set():
|
||||
host = FakeHost(main="Port 22\n")
|
||||
host.harden_ssh_server({"hostkey": ["ecdsa-sha2-nistp256"]})
|
||||
host.harden_ssh_server({"mac": ["hmac-sha1"]})
|
||||
eff = host._effective()
|
||||
assert "ecdsa-sha2-nistp256" not in eff["hostkey"]
|
||||
assert "hmac-sha1" not in eff["mac"]
|
||||
|
||||
|
||||
def test_the_sudo_password_never_reaches_a_file():
|
||||
"""napalm-linux puts the sudo password in front of stdin for ``sudo -S``. A
|
||||
sudo that does not prompt leaves it there, and a ``cat`` under sudo wrote it
|
||||
into the drop-in (NetOrk/netork#1027, first run on a real host). The content
|
||||
now reaches a file of the login user first; root only installs that file."""
|
||||
host = FakeHost()
|
||||
host.harden_ssh_server({"mac": ["hmac-sha1"]})
|
||||
assert host.SUDO_PASSWORD not in host.files[DROPIN_PATH]
|
||||
assert host.files[DROPIN_PATH].startswith("# Written by netOrk")
|
||||
assert not [p for p in host.files if p.startswith("/tmp/")]
|
||||
|
||||
|
||||
def test_restoring_an_earlier_dropin_leaks_nothing_either():
|
||||
host = FakeHost()
|
||||
host.files[DROPIN_PATH] = "# netOrk\nHostKeyAlgorithms ssh-ed25519\n"
|
||||
host.harden_ssh_server({"mac": ["hmac-sha1"]}, verify=lambda: False)
|
||||
assert host.files[DROPIN_PATH] == "# netOrk\nHostKeyAlgorithms ssh-ed25519\n"
|
||||
|
||||
|
||||
def test_mixin_reports_a_failed_way_back_instead_of_raising():
|
||||
class ReloadFails(FakeHost):
|
||||
def run_command(self, command, **kwargs):
|
||||
if "reload" in command or "HUP" in command:
|
||||
return CommandResult("", "Failed to reload ssh.service", 1)
|
||||
return super().run_command(command, **kwargs)
|
||||
|
||||
host = ReloadFails()
|
||||
result = host.harden_ssh_server({"mac": ["hmac-sha1"]})
|
||||
|
||||
assert not result.applied and not result.rolled_back
|
||||
assert "putting the previous configuration back failed" in result.message
|
||||
|
||||
|
||||
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] = []
|
||||
result = host.harden_ssh_server(
|
||||
{"hostkey": ["ssh-ed25519", "rsa-sha2-256", "rsa-sha2-512"]}, log=lines.append
|
||||
)
|
||||
assert result.applied
|
||||
assert "ssh-ed25519" in host._effective()["hostkey"]
|
||||
assert any("Keeping ssh-ed25519" in line for line in lines)
|
||||
|
||||
|
||||
def test_mixin_removes_a_host_key_algorithm_when_a_usable_one_stays():
|
||||
host = FakeHost(sshd_t=SSHD_T_FEW_KEYS)
|
||||
result = host.harden_ssh_server({"hostkey": ["rsa-sha2-256", "rsa-sha2-512"]})
|
||||
assert result.applied and result.plan.refused == []
|
||||
eff = host._effective()["hostkey"]
|
||||
assert "rsa-sha2-256" not in eff and "ssh-ed25519" in eff
|
||||
|
||||
|
||||
def test_mixin_leaves_host_keys_alone_when_it_cannot_read_them():
|
||||
host = FakeHost()
|
||||
host.keygen_ok = False
|
||||
result = host.harden_ssh_server({"hostkey": ["ecdsa-sha2-nistp256"]})
|
||||
assert not result.applied and host.reloads == 0
|
||||
assert DROPIN_PATH not in host.files
|
||||
assert [r.reason for r in result.plan.refused] == ["host_keys_unknown"]
|
||||
|
||||
|
||||
def test_a_0600_sshd_config_stays_0600_and_so_does_its_copy():
|
||||
host = FakeHost(main="Port 22\nMACs hmac-sha1,hmac-sha2-256\n")
|
||||
host.modes[MAIN_CONFIG] = "600"
|
||||
result = host.harden_ssh_server({"mac": ["hmac-sha1"]})
|
||||
assert result.applied
|
||||
assert host.modes[MAIN_CONFIG] == "600"
|
||||
assert host.modes[result.backup] == "600"
|
||||
|
||||
|
||||
def test_a_new_dropin_gets_644():
|
||||
host = FakeHost()
|
||||
host.harden_ssh_server({"mac": ["hmac-sha1"]})
|
||||
assert host.modes[DROPIN_PATH] == "644"
|
||||
@@ -0,0 +1,102 @@
|
||||
"""SystemIdentityMixin: the SMBIOS identity of a host, for matching it to its BMC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types import (
|
||||
SYSTEM_IDENTITY_COMMAND,
|
||||
SystemIdentityMixin,
|
||||
is_placeholder_identity,
|
||||
parse_system_identity,
|
||||
)
|
||||
|
||||
UUID = "31303043-534D-C40C-7A72-6F1300000000"
|
||||
|
||||
|
||||
def _out(uuid: str, product_serial: str, board_serial: str) -> str:
|
||||
return f"IDBEGIN\n{uuid}\n{product_serial}\n{board_serial}\nIDEND\n"
|
||||
|
||||
|
||||
# ── parsing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_reads_uuid_and_both_serials():
|
||||
assert parse_system_identity(_out(UUID, "S123", "ZM155S026022")) == {
|
||||
"system_uuid": "31303043-534d-c40c-7a72-6f1300000000",
|
||||
"product_serial": "S123",
|
||||
"board_serial": "ZM155S026022",
|
||||
}
|
||||
|
||||
|
||||
def test_a_supermicro_placeholder_serial_is_dropped():
|
||||
# pve-dual, a Supermicro X10DRL-i: the product serial is the board vendor's default.
|
||||
identity = parse_system_identity(_out(UUID, "0123456789", "ZM155S026022"))
|
||||
assert identity["product_serial"] == ""
|
||||
assert identity["board_serial"] == "ZM155S026022"
|
||||
|
||||
|
||||
@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"],
|
||||
)
|
||||
def test_placeholders(value):
|
||||
assert is_placeholder_identity(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"],
|
||||
)
|
||||
def test_placeholder_uuids(uuid):
|
||||
assert parse_system_identity(_out(uuid, "", ""))["system_uuid"] == ""
|
||||
|
||||
|
||||
def test_real_values_are_not_placeholders():
|
||||
assert not is_placeholder_identity("ZM155S026022")
|
||||
assert not is_placeholder_identity(UUID)
|
||||
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
def test_output_without_its_frame_is_not_a_reading():
|
||||
with pytest.raises(ValueError):
|
||||
parse_system_identity("sudo: a password is required\n")
|
||||
|
||||
|
||||
def test_terminal_noise_around_the_frame_is_ignored():
|
||||
assert parse_system_identity("\x1b[?2004h" + _out(UUID, "S1", "B1"))["board_serial"] == "B1"
|
||||
|
||||
|
||||
# ── the mixin ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _Host(SystemIdentityMixin):
|
||||
def __init__(self, output: str):
|
||||
self.output = output
|
||||
self.calls: list = []
|
||||
|
||||
def _run_system_identity_command(self, command: str, *, privileged: bool) -> str:
|
||||
self.calls.append((command, privileged))
|
||||
return self.output
|
||||
|
||||
|
||||
def test_the_driver_only_carries_the_command_across():
|
||||
host = _Host(_out(UUID, "S1", "B1"))
|
||||
|
||||
assert host.get_system_identity()["board_serial"] == "B1"
|
||||
((command, privileged),) = host.calls
|
||||
assert command == SYSTEM_IDENTITY_COMMAND
|
||||
assert privileged is True
|
||||
|
||||
|
||||
def test_the_command_reads_the_three_dmi_files():
|
||||
for name in ("product_uuid", "product_serial", "board_serial"):
|
||||
assert f"/sys/class/dmi/id/{name}" in SYSTEM_IDENTITY_COMMAND
|
||||
@@ -0,0 +1,412 @@
|
||||
"""systemd services: listing them in one round trip, and starting and stopping them.
|
||||
|
||||
What systemd reports, and how a unit is started or stopped, is the same on
|
||||
every host that runs it -- so the command, its parse, the name check and the
|
||||
reading of the exit status live here once, and a driver only carries a command
|
||||
across (napalm-linux#7, napalm-proxmox#6).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types import OSDriver
|
||||
from napalm_device_types.systemd import (
|
||||
ACTION_TIMEOUT,
|
||||
SERVICE_ACTIONS,
|
||||
SYSTEMD_SERVICES_COMMAND,
|
||||
SystemdServicesMixin,
|
||||
SystemdUnavailable,
|
||||
parse_action_result,
|
||||
parse_systemd_services,
|
||||
service_action_command,
|
||||
unit_name,
|
||||
)
|
||||
|
||||
FILES = """\
|
||||
apparmor.service enabled enabled
|
||||
ssh.service enabled enabled
|
||||
sshd.service alias -
|
||||
getty@.service enabled enabled
|
||||
rsync.service disabled enabled
|
||||
cups.service indirect enabled
|
||||
plymouth-quit.service static -
|
||||
systemd-networkd-wait-online.service enabled-runtime enabled
|
||||
nfs-server.service masked enabled
|
||||
"""
|
||||
|
||||
UNITS = """\
|
||||
MainPID=812
|
||||
Id=ssh.service
|
||||
Names=ssh.service sshd.service
|
||||
LoadState=loaded
|
||||
ActiveState=active
|
||||
SubState=running
|
||||
UnitFileState=enabled
|
||||
|
||||
MainPID=0
|
||||
Id=apparmor.service
|
||||
Names=apparmor.service
|
||||
LoadState=loaded
|
||||
ActiveState=active
|
||||
SubState=exited
|
||||
UnitFileState=enabled
|
||||
|
||||
MainPID=900
|
||||
Id=getty@tty1.service
|
||||
Names=getty@tty1.service
|
||||
LoadState=loaded
|
||||
ActiveState=active
|
||||
SubState=running
|
||||
UnitFileState=enabled
|
||||
|
||||
MainPID=0
|
||||
Id=systemd-fsck@dev-disk-by\\x2dlabel-BOOT.service
|
||||
Names=systemd-fsck@dev-disk-by\\x2dlabel-BOOT.service
|
||||
LoadState=loaded
|
||||
ActiveState=inactive
|
||||
SubState=dead
|
||||
UnitFileState=static
|
||||
|
||||
MainPID=0
|
||||
Id=display-manager.service
|
||||
Names=display-manager.service
|
||||
LoadState=not-found
|
||||
ActiveState=inactive
|
||||
SubState=dead
|
||||
UnitFileState=
|
||||
|
||||
MainPID=0
|
||||
Id=nfs-server.service
|
||||
Names=nfs-server.service
|
||||
LoadState=masked
|
||||
ActiveState=inactive
|
||||
SubState=dead
|
||||
UnitFileState=masked
|
||||
|
||||
MainPID=0
|
||||
Id=systemd-networkd-wait-online.service
|
||||
Names=systemd-networkd-wait-online.service
|
||||
LoadState=loaded
|
||||
ActiveState=active
|
||||
SubState=exited
|
||||
UnitFileState=enabled-runtime
|
||||
"""
|
||||
|
||||
|
||||
GENERATED_UNIT = """
|
||||
MainPID=0
|
||||
Id=rrdcached.service
|
||||
Names=rrdcached.service
|
||||
LoadState=loaded
|
||||
ActiveState=active
|
||||
SubState=running
|
||||
UnitFileState=generated
|
||||
"""
|
||||
|
||||
|
||||
def _wire(
|
||||
files: str = FILES,
|
||||
units: str = UNITS,
|
||||
*,
|
||||
generated: str = "",
|
||||
noise: str = "",
|
||||
end: bool = True,
|
||||
) -> str:
|
||||
"""The report as the command prints it, framed."""
|
||||
tail = "SVC_END\n" if end else ""
|
||||
return f"{noise}SVC_BEGIN\n[files]\n{files}[units]\n{units}[generated]\n{generated}{tail}"
|
||||
|
||||
|
||||
def _by_name(services):
|
||||
return {s["name"]: s for s in services}
|
||||
|
||||
|
||||
class TestParseSystemdServices:
|
||||
def test_a_loaded_unit_is_read_with_its_state(self):
|
||||
services = _by_name(parse_systemd_services(_wire()))
|
||||
|
||||
assert services["ssh"] == {"name": "ssh", "running": True, "enabled": True, "pid": 812}
|
||||
assert services["apparmor"] == {
|
||||
"name": "apparmor",
|
||||
"running": False,
|
||||
"enabled": True,
|
||||
"pid": 0,
|
||||
}
|
||||
|
||||
def test_enabled_means_enabled_now_not_merely_installed(self):
|
||||
services = _by_name(parse_systemd_services(_wire()))
|
||||
|
||||
assert services["systemd-networkd-wait-online"]["enabled"] is True
|
||||
assert services[r"systemd-fsck@dev-disk-by\x2dlabel-BOOT"]["enabled"] is False
|
||||
assert services["nfs-server"]["enabled"] is False
|
||||
|
||||
def test_a_generated_unit_takes_its_boot_state_from_is_enabled(self):
|
||||
"""A SysV script's unit is generated; only is-enabled knows its rc links."""
|
||||
raw = _wire(units=UNITS + GENERATED_UNIT, generated="rrdcached.service enabled\n")
|
||||
|
||||
assert _by_name(parse_systemd_services(raw))["rrdcached"]["enabled"] is True
|
||||
|
||||
def test_a_generated_unit_is_not_enabled_unless_is_enabled_says_so(self):
|
||||
raw = _wire(units=UNITS + GENERATED_UNIT, generated="rrdcached.service disabled\n")
|
||||
|
||||
assert _by_name(parse_systemd_services(raw))["rrdcached"]["enabled"] is False
|
||||
|
||||
def test_a_unit_that_is_not_there_is_left_out(self):
|
||||
assert "display-manager" not in _by_name(parse_systemd_services(_wire()))
|
||||
|
||||
def test_an_installed_unit_that_is_not_loaded_is_listed(self):
|
||||
services = _by_name(parse_systemd_services(_wire()))
|
||||
|
||||
assert services["rsync"] == {"name": "rsync", "running": False, "enabled": False, "pid": 0}
|
||||
assert services["cups"]["enabled"] is False
|
||||
|
||||
def test_templates_and_static_files_that_are_not_loaded_are_not(self):
|
||||
services = _by_name(parse_systemd_services(_wire()))
|
||||
|
||||
assert "getty@" not in services
|
||||
assert "plymouth-quit" not in services
|
||||
assert services["getty@tty1"]["running"] is True
|
||||
|
||||
def test_an_alias_never_appears_beside_its_unit(self):
|
||||
assert "sshd" not in _by_name(parse_systemd_services(_wire()))
|
||||
|
||||
def test_an_alias_that_older_systemd_calls_enabled_does_not_either(self):
|
||||
files = (
|
||||
"\n".join(
|
||||
"sshd.service enabled enabled" if line.startswith("sshd.service") else line
|
||||
for line in FILES.splitlines()
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
assert "sshd" not in _by_name(parse_systemd_services(_wire(files=files)))
|
||||
|
||||
def test_an_escaped_name_survives(self):
|
||||
assert r"systemd-fsck@dev-disk-by\x2dlabel-BOOT" in _by_name(
|
||||
parse_systemd_services(_wire())
|
||||
)
|
||||
|
||||
def test_blocks_run_together_are_still_told_apart(self):
|
||||
"""xargs may split the unit list across two systemctl runs."""
|
||||
units = UNITS.replace(
|
||||
"UnitFileState=enabled\n\nMainPID=0\nId=apparmor",
|
||||
"UnitFileState=enabled\nMainPID=0\nId=apparmor",
|
||||
)
|
||||
|
||||
services = _by_name(parse_systemd_services(_wire(units=units)))
|
||||
|
||||
assert services["ssh"]["pid"] == 812
|
||||
assert services["apparmor"]["running"] is False
|
||||
|
||||
def test_the_list_is_sorted_by_name(self):
|
||||
names = [s["name"] for s in parse_systemd_services(_wire())]
|
||||
|
||||
assert names == sorted(names)
|
||||
|
||||
def test_terminal_colours_in_the_report_are_dropped(self):
|
||||
files = FILES.replace(
|
||||
"rsync.service disabled",
|
||||
"rsync.service \x1b[0;1;31mdisabled\x1b[0m",
|
||||
)
|
||||
|
||||
assert "rsync" in _by_name(parse_systemd_services(_wire(files=files)))
|
||||
|
||||
def test_whatever_surrounds_the_frame_is_ignored(self):
|
||||
noisy = _wire(noise="user@host:~$ systemctl ...\n") + "user@host:~$ "
|
||||
|
||||
assert "ssh" in _by_name(parse_systemd_services(noisy))
|
||||
|
||||
def test_a_cut_short_report_raises(self):
|
||||
"""A missing tail must not read as services that went away."""
|
||||
with pytest.raises(ValueError):
|
||||
parse_systemd_services(_wire(end=False))
|
||||
|
||||
def test_output_without_the_frame_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_systemd_services("bash: systemctl: command not found\n")
|
||||
|
||||
def test_a_host_without_systemd_says_so(self):
|
||||
raw = "SVC_BEGIN\n[no-systemd]\n[files]\n[units]\nSVC_END\n"
|
||||
|
||||
with pytest.raises(SystemdUnavailable):
|
||||
parse_systemd_services(raw)
|
||||
|
||||
def test_no_systemd_is_a_not_implemented_error(self):
|
||||
assert issubclass(SystemdUnavailable, NotImplementedError)
|
||||
|
||||
|
||||
class TestTheCommand:
|
||||
def test_the_frame_is_not_in_the_command_itself(self):
|
||||
"""An echoing transport must not show the end marker early."""
|
||||
assert "SVC_END" not in SYSTEMD_SERVICES_COMMAND
|
||||
assert "SVC_BEGIN" not in SYSTEMD_SERVICES_COMMAND
|
||||
|
||||
def test_it_changes_nothing(self):
|
||||
for verb in ("start", "stop", "restart", "enable", "disable", "mask"):
|
||||
assert f"systemctl {verb}" not in SYSTEMD_SERVICES_COMMAND
|
||||
|
||||
@pytest.mark.skipif(not os.path.isdir("/run/systemd/system"), reason="needs systemd")
|
||||
def test_it_runs_and_parses_on_this_host(self):
|
||||
out = subprocess.run(
|
||||
["sh", "-c", SYSTEMD_SERVICES_COMMAND], capture_output=True, text=True, timeout=60
|
||||
).stdout
|
||||
|
||||
services = _by_name(parse_systemd_services(out))
|
||||
|
||||
assert "systemd-journald" in services
|
||||
assert services["systemd-journald"]["running"] is True
|
||||
|
||||
|
||||
class TestUnitName:
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "name"),
|
||||
[
|
||||
("ssh", "ssh"),
|
||||
("ssh.service", "ssh"),
|
||||
("getty@tty1", "getty@tty1"),
|
||||
("wg-quick@wg0", "wg-quick@wg0"),
|
||||
("snapd.apparmor", "snapd.apparmor"),
|
||||
("systemd-backlight@backlight:acpi_video0", "systemd-backlight@backlight:acpi_video0"),
|
||||
(r"systemd-fsck@dev-disk-by\x2dlabel-BOOT", r"systemd-fsck@dev-disk-by\x2dlabel-BOOT"),
|
||||
],
|
||||
)
|
||||
def test_a_unit_name_is_accepted(self, raw, name):
|
||||
assert unit_name(raw) == name
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"",
|
||||
"-x",
|
||||
"foo@",
|
||||
"foo@.service",
|
||||
"a b",
|
||||
"a;b",
|
||||
"$(id)",
|
||||
"a/b",
|
||||
r"bad\x2",
|
||||
"ssh\n",
|
||||
"x" * 256,
|
||||
],
|
||||
)
|
||||
def test_anything_else_is_refused(self, raw):
|
||||
with pytest.raises(ValueError):
|
||||
unit_name(raw)
|
||||
|
||||
|
||||
class TestServiceActionCommand:
|
||||
def test_the_command_is_bounded_and_never_asks(self):
|
||||
cmd = service_action_command("getty@tty1", "restart")
|
||||
|
||||
assert cmd.startswith(f"timeout {ACTION_TIMEOUT} systemctl --no-ask-password restart -- ")
|
||||
assert "getty@tty1.service" in cmd
|
||||
|
||||
def test_an_escaped_name_is_quoted_for_the_shell(self):
|
||||
cmd = service_action_command(r"systemd-fsck@dev-disk-by\x2dlabel-BOOT", "stop")
|
||||
|
||||
assert r"'systemd-fsck@dev-disk-by\x2dlabel-BOOT.service'" in cmd
|
||||
|
||||
def test_its_exit_status_is_printed_after_it(self):
|
||||
assert service_action_command("ssh", "start").endswith("; echo __SVC_RC=$?")
|
||||
|
||||
def test_the_actions(self):
|
||||
assert SERVICE_ACTIONS == ("start", "stop", "restart", "enable", "disable")
|
||||
|
||||
def test_an_unknown_action_is_refused(self):
|
||||
with pytest.raises(ValueError):
|
||||
service_action_command("ssh", "mask")
|
||||
|
||||
def test_an_invalid_name_is_refused(self):
|
||||
with pytest.raises(ValueError):
|
||||
service_action_command("ssh; reboot", "stop")
|
||||
|
||||
|
||||
class TestParseActionResult:
|
||||
def test_exit_status_zero_is_success(self):
|
||||
assert parse_action_result("__SVC_RC=0\n") == {"success": True, "output": ""}
|
||||
|
||||
def test_what_systemctl_printed_comes_back_without_the_marker(self):
|
||||
raw = (
|
||||
"Created symlink /etc/systemd/system/multi-user.target.wants/cron.service.\n__SVC_RC=0"
|
||||
)
|
||||
|
||||
result = parse_action_result(raw)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["output"].startswith("Created symlink")
|
||||
assert "__SVC_RC" not in result["output"]
|
||||
|
||||
def test_terminal_colours_are_dropped(self):
|
||||
"""systemctl colours its errors when a transport gives it a terminal."""
|
||||
raw = (
|
||||
"\x1b[0;1;31mFailed to restart x.service: Unit x.service not found.\x1b[0m\n"
|
||||
"__SVC_RC=5\n"
|
||||
)
|
||||
|
||||
assert parse_action_result(raw)["output"] == (
|
||||
"Failed to restart x.service: Unit x.service not found."
|
||||
)
|
||||
|
||||
def test_a_failure_keeps_its_message(self):
|
||||
raw = "Failed to start foo.service: Unit foo.service not found.\n__SVC_RC=5\n"
|
||||
|
||||
assert parse_action_result(raw) == {
|
||||
"success": False,
|
||||
"output": "Failed to start foo.service: Unit foo.service not found.",
|
||||
}
|
||||
|
||||
def test_a_job_still_running_at_the_timeout_is_not_called_done(self):
|
||||
result = parse_action_result("__SVC_RC=124\n")
|
||||
|
||||
assert result["success"] is False
|
||||
assert str(ACTION_TIMEOUT) in result["output"]
|
||||
|
||||
def test_no_exit_status_is_no_success(self):
|
||||
assert parse_action_result("Connection reset\n")["success"] is False
|
||||
|
||||
def test_the_echoed_command_is_not_taken_for_the_status(self):
|
||||
raw = "timeout 45 systemctl restart -- cron.service 2>&1; echo __SVC_RC=$?\n__SVC_RC=1\n"
|
||||
|
||||
assert parse_action_result(raw)["success"] is False
|
||||
|
||||
|
||||
class _Driver(SystemdServicesMixin):
|
||||
def __init__(self, reply: str) -> None:
|
||||
self.reply = reply
|
||||
self.calls: list = []
|
||||
|
||||
def _run_service_command(self, command: str, *, privileged: bool, timeout: int) -> str:
|
||||
self.calls.append((command, privileged, timeout))
|
||||
return self.reply
|
||||
|
||||
|
||||
class TestSystemdServicesMixin:
|
||||
def test_listing_runs_the_command_unprivileged(self):
|
||||
driver = _Driver(_wire())
|
||||
|
||||
assert "ssh" in _by_name(driver.get_services())
|
||||
assert driver.calls == [(SYSTEMD_SERVICES_COMMAND, False, ACTION_TIMEOUT + 15)]
|
||||
|
||||
def test_an_action_runs_privileged_and_reports_its_outcome(self):
|
||||
driver = _Driver("__SVC_RC=0\n")
|
||||
|
||||
assert driver.manage_service("cron", "restart") == {"success": True, "output": ""}
|
||||
command, privileged, timeout = driver.calls[0]
|
||||
assert command == service_action_command("cron", "restart")
|
||||
assert privileged is True
|
||||
assert timeout > ACTION_TIMEOUT
|
||||
|
||||
def test_an_invalid_request_is_refused_before_anything_is_sent(self):
|
||||
driver = _Driver("__SVC_RC=0\n")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
driver.manage_service("cron;reboot", "stop")
|
||||
assert driver.calls == []
|
||||
|
||||
def test_not_every_os_driver_has_it(self):
|
||||
assert not hasattr(OSDriver, "manage_service")
|
||||
assert callable(getattr(SystemdServicesMixin, "manage_service"))
|
||||
@@ -5,6 +5,7 @@ from napalm_device_types import (
|
||||
AccessPointDriver,
|
||||
FirewallDriver,
|
||||
HypervisorDriver,
|
||||
ManagementControllerDriver,
|
||||
OSDriver,
|
||||
ResidentialGatewayDriver,
|
||||
StorageDriver,
|
||||
@@ -16,6 +17,7 @@ BASE_CLASSES = [
|
||||
(AccessPointDriver, "Access Point"),
|
||||
(FirewallDriver, "Firewall"),
|
||||
(HypervisorDriver, "Hypervisor"),
|
||||
(ManagementControllerDriver, "Management Controller"),
|
||||
(OSDriver, "OS"),
|
||||
(ResidentialGatewayDriver, "Gateway"),
|
||||
(StorageDriver, "Storage"),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""SNMP interface counters: 64-bit where the agent has ifXTable, 32-bit where not."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Optional
|
||||
|
||||
from napalm_device_types._ucd_metrics import (
|
||||
OID_IF_DESCR,
|
||||
OID_IF_HC_IN_OCT,
|
||||
OID_IF_HC_OUT_OCT,
|
||||
OID_IF_HIGH_SPEED,
|
||||
OID_IF_IN_OCT,
|
||||
OID_IF_OUT_OCT,
|
||||
OID_IF_SPEED,
|
||||
collect_ucd_metrics,
|
||||
)
|
||||
|
||||
LEGACY = {
|
||||
OID_IF_DESCR: {"1": "eth0"},
|
||||
OID_IF_SPEED: {"1": "4294967295"},
|
||||
OID_IF_IN_OCT: {"1": "100"},
|
||||
OID_IF_OUT_OCT: {"1": "200"},
|
||||
}
|
||||
|
||||
|
||||
def _collect(tables: Dict[str, Dict[str, str]]) -> dict:
|
||||
async def get(oid: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
async def walk(oid: str) -> Dict[str, str]:
|
||||
return tables.get(oid, {})
|
||||
|
||||
return asyncio.run(collect_ucd_metrics(get, walk))
|
||||
|
||||
|
||||
def test_the_64_bit_counters_and_high_speed_win():
|
||||
tables = {
|
||||
**LEGACY,
|
||||
OID_IF_HC_IN_OCT: {"1": "8589934592"},
|
||||
OID_IF_HC_OUT_OCT: {"1": "17179869184"},
|
||||
OID_IF_HIGH_SPEED: {"1": "10000"},
|
||||
}
|
||||
|
||||
eth0 = _collect(tables)["interfaces"]["eth0"]
|
||||
|
||||
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():
|
||||
eth0 = _collect(LEGACY)["interfaces"]["eth0"]
|
||||
|
||||
assert (eth0["rx_bytes"], eth0["tx_bytes"], eth0["speed_mbps"]) == (100, 200, 4294)
|
||||
@@ -0,0 +1,664 @@
|
||||
"""get_virtual_ips: the addresses a host's HA configuration lets float between machines.
|
||||
|
||||
A virtual IP is not visible from the address list alone: vip-manager and
|
||||
keepalived put it on whichever node is master *right now*, and a standby has no
|
||||
trace of it in ``ip addr``. What every member has is the configuration that
|
||||
declares it. Reading that is the same on every Linux host, so the command and
|
||||
its parse live here once, and a driver only carries the command across
|
||||
(netOrk #829).
|
||||
|
||||
The configuration also holds secrets -- vip-manager's etcd password, keepalived's
|
||||
``auth_pass`` -- so the command filters on the host: only allowlisted fields
|
||||
ever leave it. The tests below run the command for real to hold it to that.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types import OSDriver
|
||||
from napalm_device_types.virtual_ips import (
|
||||
VIRTUAL_IPS_COMMAND,
|
||||
VirtualIpsMixin,
|
||||
parse_virtual_ips,
|
||||
)
|
||||
|
||||
SECRET = "s3cr3t-do-not-leak"
|
||||
|
||||
|
||||
def _wire(*sections: str, noise: str = "") -> str:
|
||||
"""The report as the command prints it, framed."""
|
||||
return f"{noise}VIPS_BEGIN\n{''.join(sections)}VIPS_END\n"
|
||||
|
||||
|
||||
def _section(mechanism: str, unit: str, *records: str) -> str:
|
||||
return f"[{mechanism} {unit}]\n" + "".join(f"{r}\n" for r in records)
|
||||
|
||||
|
||||
def _vip_manager(*records: str, unit: str = "vip-manager.service") -> str:
|
||||
return _section("vip-manager", unit, *records)
|
||||
|
||||
|
||||
def _keepalived(*records: str, unit: str = "keepalived.service") -> str:
|
||||
return _section("keepalived", unit, *records)
|
||||
|
||||
|
||||
DB01 = _vip_manager(
|
||||
"active active",
|
||||
"arg config /etc/default/vip-manager.yml",
|
||||
"cfg ip 10.7.224.10",
|
||||
"cfg netmask 24",
|
||||
"cfg interface ens7",
|
||||
'cfg trigger-key "/service/netork-db/leader"',
|
||||
)
|
||||
|
||||
|
||||
class TestVipManager:
|
||||
def test_the_5x_yaml_declares_the_address(self):
|
||||
reading = parse_virtual_ips(_wire(DB01))
|
||||
|
||||
assert reading == {
|
||||
"vip_manager": [
|
||||
{
|
||||
"mechanism": "vip-manager",
|
||||
"instance": "vip-manager",
|
||||
"address": "10.7.224.10",
|
||||
"prefix_length": 24,
|
||||
"interface": "ens7",
|
||||
"group_key": "/service/netork-db/leader",
|
||||
"running": True,
|
||||
}
|
||||
],
|
||||
"keepalived": [],
|
||||
}
|
||||
|
||||
def test_the_1x_unit_takes_its_values_from_the_environment_file(self):
|
||||
"""Ubuntu's 1.0.2 package: ``-ip=${VIP_IP}`` on the command line,
|
||||
the values in /etc/default/vip-manager."""
|
||||
section = _vip_manager(
|
||||
"active active",
|
||||
"arg ip ${VIP_IP}",
|
||||
"arg mask $VIP_MASK",
|
||||
"arg iface ${VIP_IFACE}",
|
||||
"arg key ${VIP_KEY}",
|
||||
"env VIP_IP 10.0.0.10",
|
||||
"env VIP_MASK 255.255.255.0",
|
||||
"env VIP_IFACE eth0",
|
||||
'env VIP_KEY "/service/pg/leader"',
|
||||
)
|
||||
|
||||
[entry] = parse_virtual_ips(_wire(section))["vip_manager"]
|
||||
|
||||
assert (entry["address"], entry["prefix_length"], entry["interface"]) == (
|
||||
"10.0.0.10",
|
||||
24,
|
||||
"eth0",
|
||||
)
|
||||
assert entry["group_key"] == "/service/pg/leader"
|
||||
|
||||
def test_a_flag_beats_the_environment_which_beats_the_config_file(self):
|
||||
section = _vip_manager(
|
||||
"active active",
|
||||
"arg ip 10.0.0.1",
|
||||
"env VIP_IP 10.0.0.2",
|
||||
"env VIP_NETMASK 16",
|
||||
"cfg ip 10.0.0.3",
|
||||
"cfg netmask 24",
|
||||
"cfg interface eth1",
|
||||
)
|
||||
|
||||
[entry] = parse_virtual_ips(_wire(section))["vip_manager"]
|
||||
|
||||
assert (entry["address"], entry["prefix_length"], entry["interface"]) == (
|
||||
"10.0.0.1",
|
||||
16,
|
||||
"eth1",
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected",
|
||||
[
|
||||
("10.0.0.10 # the database", "10.0.0.10"),
|
||||
("'10.0.0.10'", "10.0.0.10"),
|
||||
('"fd00::10"', "fd00::10"),
|
||||
("FD00:0:0::10", "fd00::10"),
|
||||
],
|
||||
)
|
||||
def test_values_lose_quotes_and_comments(self, value, expected):
|
||||
section = _vip_manager("active active", f"cfg ip {value}")
|
||||
|
||||
[entry] = parse_virtual_ips(_wire(section))["vip_manager"]
|
||||
|
||||
assert entry["address"] == expected
|
||||
|
||||
def test_a_stopped_unit_still_declares_its_address(self):
|
||||
"""A standby whose vip-manager is down is still a member -- one that
|
||||
cannot take the address over, which is worth knowing."""
|
||||
section = _vip_manager("active inactive", "cfg ip 10.0.0.10")
|
||||
|
||||
[entry] = parse_virtual_ips(_wire(section))["vip_manager"]
|
||||
|
||||
assert entry["running"] is False
|
||||
assert entry["prefix_length"] is None
|
||||
|
||||
def test_a_unit_without_an_address_declares_nothing(self):
|
||||
section = _vip_manager("active active", "arg ip ${VIP_IP}", "cfg netmask 24")
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
[entry] = parse_virtual_ips(_wire(section))["vip_manager"]
|
||||
|
||||
assert entry["instance"] == "vip-manager@pg16"
|
||||
|
||||
def test_an_unreadable_config_is_not_looked_at(self):
|
||||
"""None, not []: the reading must not say the address is gone."""
|
||||
section = _vip_manager("active active", "unreadable /etc/default/vip-manager.yml")
|
||||
|
||||
reading = parse_virtual_ips(_wire(section, _keepalived("active inactive")))
|
||||
|
||||
assert reading["vip_manager"] is None
|
||||
assert reading["keepalived"] == []
|
||||
|
||||
|
||||
class TestKeepalived:
|
||||
TWO_INSTANCES = _keepalived(
|
||||
"active active",
|
||||
"instance VI_DB",
|
||||
"interface eth0",
|
||||
"virtual_router_id 51",
|
||||
"vip 10.0.0.10/24 dev eth0",
|
||||
"vip 10.0.0.11",
|
||||
"end",
|
||||
"instance web",
|
||||
"vip fd00::80",
|
||||
"interface eth1",
|
||||
"end",
|
||||
)
|
||||
|
||||
def test_each_address_of_each_instance(self):
|
||||
reading = parse_virtual_ips(_wire(self.TWO_INSTANCES))
|
||||
|
||||
assert reading["vip_manager"] == []
|
||||
assert reading["keepalived"] == [
|
||||
{
|
||||
"mechanism": "keepalived",
|
||||
"instance": "VI_DB",
|
||||
"address": "10.0.0.10",
|
||||
"prefix_length": 24,
|
||||
"interface": "eth0",
|
||||
"group_key": "vrid:51",
|
||||
"running": True,
|
||||
},
|
||||
{
|
||||
"mechanism": "keepalived",
|
||||
"instance": "VI_DB",
|
||||
"address": "10.0.0.11",
|
||||
"prefix_length": 32,
|
||||
"interface": "eth0",
|
||||
"group_key": "vrid:51",
|
||||
"running": True,
|
||||
},
|
||||
{
|
||||
"mechanism": "keepalived",
|
||||
"instance": "web",
|
||||
"address": "fd00::80",
|
||||
"prefix_length": 128,
|
||||
"interface": "eth1",
|
||||
"group_key": None,
|
||||
"running": True,
|
||||
},
|
||||
]
|
||||
|
||||
def test_dev_beats_the_instance_interface(self):
|
||||
section = _keepalived(
|
||||
"active active", "instance a", "interface eth0", "vip 10.0.0.10/24 dev eth9", "end"
|
||||
)
|
||||
|
||||
[entry] = parse_virtual_ips(_wire(section))["keepalived"]
|
||||
|
||||
assert entry["interface"] == "eth9"
|
||||
|
||||
@pytest.mark.parametrize("line", ["vip $VIP", "vip @node1", "vip not-an-address"])
|
||||
def test_what_is_no_address_is_skipped(self, line):
|
||||
"""keepalived's ``$VAR`` substitution and ``@host`` conditionals are
|
||||
not resolved here; an entry that is no address is no entry."""
|
||||
section = _keepalived("active active", "instance a", line, "vip 10.0.0.12", "end")
|
||||
|
||||
assert [e["address"] for e in parse_virtual_ips(_wire(section))["keepalived"]] == [
|
||||
"10.0.0.12"
|
||||
]
|
||||
|
||||
def test_an_instance_cut_off_by_the_end_of_the_file_still_counts(self):
|
||||
section = _keepalived("active active", "instance a", "vip 10.0.0.10")
|
||||
|
||||
assert len(parse_virtual_ips(_wire(section))["keepalived"]) == 1
|
||||
|
||||
def test_without_a_config_file_it_declares_nothing(self):
|
||||
section = _keepalived("active inactive", "missing /etc/keepalived/keepalived.conf")
|
||||
|
||||
assert parse_virtual_ips(_wire(section))["keepalived"] == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"record", ["unreadable /etc/keepalived/conf.d/x.conf", "too-deep /etc/keepalived/a.conf"]
|
||||
)
|
||||
def test_an_include_it_could_not_follow_is_not_looked_at(self, record):
|
||||
section = _keepalived("active active", "instance a", "vip 10.0.0.10", "end", record)
|
||||
|
||||
assert parse_virtual_ips(_wire(section))["keepalived"] is None
|
||||
|
||||
|
||||
class TestTheReport:
|
||||
def test_no_units_is_looked_and_found_nothing(self):
|
||||
assert parse_virtual_ips(_wire("[systemd]\n")) == {"vip_manager": [], "keepalived": []}
|
||||
|
||||
def test_a_host_without_systemd_is_not_looked_at(self):
|
||||
assert parse_virtual_ips(_wire("[no-systemd]\n")) == {
|
||||
"vip_manager": None,
|
||||
"keepalived": None,
|
||||
}
|
||||
|
||||
def test_noise_before_the_report_is_ignored(self):
|
||||
reading = parse_virtual_ips(_wire(DB01, noise="$ sh -c '...'\nWelcome to Ubuntu\n"))
|
||||
|
||||
assert reading["vip_manager"][0]["address"] == "10.7.224.10"
|
||||
|
||||
def test_a_report_cut_short_raises_without_quoting_it(self):
|
||||
"""The output may carry configuration; an error message must not."""
|
||||
with pytest.raises(ValueError) as caught:
|
||||
parse_virtual_ips(f"VIPS_BEGIN\n{DB01}")
|
||||
|
||||
assert "10.7.224.10" not in str(caught.value)
|
||||
|
||||
def test_entries_are_sorted(self):
|
||||
reading = parse_virtual_ips(
|
||||
_wire(
|
||||
_vip_manager("active active", "cfg ip 10.0.0.20", unit="vip-manager@b.service"),
|
||||
_vip_manager("active active", "cfg ip 10.0.0.3", unit="vip-manager@a.service"),
|
||||
)
|
||||
)
|
||||
|
||||
assert [e["address"] for e in reading["vip_manager"]] == ["10.0.0.3", "10.0.0.20"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The command itself, run against a stub systemctl and real files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DB01_YAML = f"""# managed by hand, see infrastructure/docs/DATABASE_CLUSTER.md
|
||||
ip: 10.7.224.10
|
||||
netmask: 24
|
||||
interface: ens7
|
||||
trigger-key: "/service/netork-db/leader"
|
||||
trigger-value: "db-01"
|
||||
dcs-type: etcd
|
||||
dcs-endpoints:
|
||||
- http://10.7.234.12:2379
|
||||
etcd-user: patroni
|
||||
etcd-password: {SECRET}
|
||||
"""
|
||||
|
||||
KEEPALIVED_CONF = f"""! Configuration File for keepalived
|
||||
global_defs {{
|
||||
notification_email {{ ops@example.org }}
|
||||
router_id LVS_{SECRET}
|
||||
}}
|
||||
|
||||
vrrp_script chk_haproxy {{
|
||||
script "/usr/local/bin/check {SECRET}"
|
||||
}}
|
||||
|
||||
vrrp_instance VI_WEB {{
|
||||
state MASTER
|
||||
interface eth0
|
||||
virtual_router_id 51
|
||||
priority 101
|
||||
authentication {{
|
||||
auth_type PASS
|
||||
auth_pass {SECRET}
|
||||
}}
|
||||
virtual_ipaddress {{
|
||||
192.0.2.10/24 dev eth0 label eth0:1
|
||||
}}
|
||||
notify_master "/etc/keepalived/master.sh {SECRET}"
|
||||
}}
|
||||
|
||||
include conf.d/*.conf
|
||||
"""
|
||||
|
||||
INCLUDED_CONF = """vrrp_instance VI_DB { interface eth1
|
||||
virtual_router_id 52
|
||||
virtual_ipaddress { 192.0.2.20 }
|
||||
virtual_ipaddress_excluded {
|
||||
2001:db8::20/64
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _awks() -> list:
|
||||
found = []
|
||||
if shutil.which("mawk"):
|
||||
found.append(pytest.param(["mawk"], id="mawk"))
|
||||
if shutil.which("busybox"):
|
||||
found.append(pytest.param(["busybox", "awk"], id="busybox"))
|
||||
if shutil.which("gawk"):
|
||||
found.append(pytest.param(["gawk"], id="gawk"))
|
||||
return found or [pytest.param(None, marks=pytest.mark.skip(reason="no awk to run"))]
|
||||
|
||||
|
||||
class _Host:
|
||||
"""A host as far as the command can tell: units, their properties, files."""
|
||||
|
||||
def __init__(self, root: Path, awk: list) -> None:
|
||||
self.root = root
|
||||
self.bin = root / "bin"
|
||||
self.bin.mkdir()
|
||||
self.state = root / "systemd"
|
||||
self.state.mkdir()
|
||||
#: Stands in for /run/systemd/system, there while systemd is PID 1.
|
||||
self.booted = root / "run-systemd-system"
|
||||
self.booted.mkdir()
|
||||
(self.state / "units").write_text("")
|
||||
stub = self.bin / "systemctl"
|
||||
stub.write_text(
|
||||
"#!/bin/sh\n"
|
||||
f"d={self.state}\n"
|
||||
'case "$1" in\n'
|
||||
f' list-units) {shutil.which("cat")} "$d/units" ;;\n'
|
||||
f' show) for last; do :; done; {shutil.which("cat")} "$d/show-$last" ;;\n'
|
||||
"esac\n"
|
||||
)
|
||||
stub.chmod(0o755)
|
||||
wrapper = self.bin / "awk"
|
||||
program = " ".join([shutil.which(awk[0]) or awk[0], *awk[1:]])
|
||||
wrapper.write_text(f'#!/bin/sh\nexec {program} "$@"\n')
|
||||
wrapper.chmod(0o755)
|
||||
|
||||
def unit(self, name: str, *properties: str) -> None:
|
||||
with (self.state / "units").open("a") as units:
|
||||
units.write(f"{name} loaded active running {name}\n")
|
||||
(self.state / f"show-{name}").write_text("".join(f"{p}\n" for p in properties))
|
||||
|
||||
def file(self, relative: str, content: str) -> Path:
|
||||
path = self.root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
return path
|
||||
|
||||
def run(self, *, systemctl: bool = True, booted: bool = True) -> str:
|
||||
if not systemctl:
|
||||
(self.bin / "systemctl").unlink()
|
||||
if not booted:
|
||||
self.booted.rmdir()
|
||||
assert VIRTUAL_IPS_COMMAND.count("/run/systemd/system") == 1
|
||||
command = VIRTUAL_IPS_COMMAND.replace("/run/systemd/system", str(self.booted))
|
||||
return subprocess.run(
|
||||
["/bin/sh", "-c", command],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={"PATH": str(self.bin)},
|
||||
timeout=30,
|
||||
).stdout
|
||||
|
||||
|
||||
def _exec_start(*argv: str) -> str:
|
||||
return (
|
||||
f"ExecStart={{ path={argv[0]} ; argv[]={' '.join(argv)} ; ignore_errors=no ; "
|
||||
"start_time=[Mon 2026-10-05 06:50:00 UTC] ; stop_time=[n/a] ; pid=758 ; "
|
||||
"code=(null) ; status=0/0 }"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(params=_awks())
|
||||
def host(request, tmp_path) -> _Host:
|
||||
return _Host(tmp_path, request.param)
|
||||
|
||||
|
||||
class TestTheCommandOnAHost:
|
||||
def test_vip_manager_5x_as_on_db_01(self, host):
|
||||
config = host.file("etc/default/vip-manager.yml", DB01_YAML)
|
||||
host.unit(
|
||||
"vip-manager.service",
|
||||
"ActiveState=active",
|
||||
_exec_start("/usr/bin/vip-manager", f"--config={config}"),
|
||||
f"Environment=VIP_ETCD_PASSWORD={SECRET} VIP_INTERFACE=ens7",
|
||||
)
|
||||
|
||||
out = host.run()
|
||||
|
||||
assert SECRET not in out
|
||||
assert "10.7.234.12" not in out # the etcd endpoints stay on the host
|
||||
assert parse_virtual_ips(out)["vip_manager"] == [
|
||||
{
|
||||
"mechanism": "vip-manager",
|
||||
"instance": "vip-manager",
|
||||
"address": "10.7.224.10",
|
||||
"prefix_length": 24,
|
||||
"interface": "ens7",
|
||||
"group_key": "/service/netork-db/leader",
|
||||
"running": True,
|
||||
}
|
||||
]
|
||||
|
||||
def test_vip_manager_1x_with_its_environment_file(self, host):
|
||||
envfile = host.file(
|
||||
"etc/default/vip-manager",
|
||||
f'VIP_IP="10.0.0.10"\nexport VIP_MASK=24\nVIP_IFACE=eth0\nVIP_KEY=/service/pg/leader\n'
|
||||
f"VIP_ETCD_PASSWORD={SECRET}\n",
|
||||
)
|
||||
host.unit(
|
||||
"vip-manager.service",
|
||||
"ActiveState=active",
|
||||
_exec_start(
|
||||
"/usr/bin/vip-manager",
|
||||
"-ip=${VIP_IP}",
|
||||
"-mask=${VIP_MASK}",
|
||||
"-iface=${VIP_IFACE}",
|
||||
"-key=${VIP_KEY}",
|
||||
"-etcd_password",
|
||||
SECRET,
|
||||
),
|
||||
f"EnvironmentFiles={envfile} (ignore_errors=yes)",
|
||||
f"EnvironmentFiles={host.root}/etc/default/missing (ignore_errors=yes)",
|
||||
)
|
||||
|
||||
out = host.run()
|
||||
|
||||
assert SECRET not in out
|
||||
[entry] = parse_virtual_ips(out)["vip_manager"]
|
||||
assert (entry["address"], entry["prefix_length"], entry["interface"]) == (
|
||||
"10.0.0.10",
|
||||
24,
|
||||
"eth0",
|
||||
)
|
||||
|
||||
def test_keepalived_with_includes(self, host):
|
||||
config = host.file("etc/keepalived/keepalived.conf", KEEPALIVED_CONF)
|
||||
host.file("etc/keepalived/conf.d/db.conf", INCLUDED_CONF)
|
||||
host.unit(
|
||||
"keepalived.service",
|
||||
"ActiveState=active",
|
||||
_exec_start("/usr/sbin/keepalived", "--dont-fork", "-f", str(config)),
|
||||
)
|
||||
|
||||
out = host.run()
|
||||
|
||||
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"]] == [
|
||||
("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"),
|
||||
]
|
||||
assert reading["vip_manager"] == []
|
||||
|
||||
def test_an_include_that_includes_itself_is_not_followed_forever(self, host):
|
||||
config = host.file(
|
||||
"etc/keepalived/keepalived.conf",
|
||||
"vrrp_instance a {\n virtual_ipaddress {\n 192.0.2.30\n }\n}\n"
|
||||
"include keepalived.conf\n",
|
||||
)
|
||||
host.unit(
|
||||
"keepalived.service",
|
||||
"ActiveState=active",
|
||||
_exec_start("/usr/sbin/keepalived", f"--use-file={config}"),
|
||||
)
|
||||
|
||||
assert parse_virtual_ips(host.run())["keepalived"] is None
|
||||
|
||||
def test_an_include_pattern_is_never_run_as_shell(self, host):
|
||||
"""The include line comes from a file and the command runs as root."""
|
||||
marker = host.root / "PWNED"
|
||||
config = host.file(
|
||||
"etc/keepalived/keepalived.conf",
|
||||
f"include /etc/x.conf;touch${{IFS}}{marker}\ninclude $(touch {marker})\n",
|
||||
)
|
||||
host.unit(
|
||||
"keepalived.service",
|
||||
"ActiveState=active",
|
||||
_exec_start("/usr/sbin/keepalived", "-f", str(config)),
|
||||
)
|
||||
|
||||
out = host.run()
|
||||
|
||||
assert not marker.exists()
|
||||
assert parse_virtual_ips(out)["keepalived"] is None
|
||||
|
||||
@pytest.mark.skipif(os.geteuid() == 0, reason="root reads every file")
|
||||
def test_a_config_it_may_not_read_is_reported_as_such(self, host):
|
||||
config = host.file("etc/default/vip-manager.yml", DB01_YAML)
|
||||
config.chmod(0o000)
|
||||
host.unit(
|
||||
"vip-manager.service",
|
||||
"ActiveState=active",
|
||||
_exec_start("/usr/bin/vip-manager", "--config", str(config)),
|
||||
)
|
||||
|
||||
out = host.run()
|
||||
|
||||
assert parse_virtual_ips(out)["vip_manager"] is None
|
||||
|
||||
def test_no_units_at_all(self, host):
|
||||
assert parse_virtual_ips(host.run()) == {"vip_manager": [], "keepalived": []}
|
||||
|
||||
def test_a_host_without_systemd(self, host):
|
||||
assert parse_virtual_ips(host.run(systemctl=False)) == {
|
||||
"vip_manager": None,
|
||||
"keepalived": None,
|
||||
}
|
||||
|
||||
def test_systemctl_without_systemd_running_is_not_looked_at(self, host):
|
||||
"""A container that carries systemctl but runs something else as PID 1:
|
||||
systemctl lists no units there, which is not "none declared"."""
|
||||
config = host.file(
|
||||
"etc/keepalived/keepalived.conf", "vrrp_instance a { virtual_ipaddress { 192.0.2.4 } }"
|
||||
)
|
||||
host.unit("keepalived.service", "ActiveState=active", _exec_start("/k", "-f", str(config)))
|
||||
|
||||
assert parse_virtual_ips(host.run(booted=False)) == {
|
||||
"vip_manager": None,
|
||||
"keepalived": None,
|
||||
}
|
||||
|
||||
def test_braces_on_their_own_line(self, host):
|
||||
"""keepalived takes ``vrrp_instance VI_2`` and its ``{`` on separate lines,
|
||||
the same for every block; the secrets inside stay on the host."""
|
||||
config = host.file(
|
||||
"etc/keepalived/keepalived.conf",
|
||||
"vrrp_instance VI_1 {\n interface eth0\n"
|
||||
" virtual_ipaddress {\n 192.0.2.50\n }\n}\n"
|
||||
"vrrp_instance VI_2\n{\n interface eth1\n virtual_router_id 53\n"
|
||||
f" authentication\n {{\n auth_type PASS\n auth_pass {SECRET}\n }}\n"
|
||||
" virtual_ipaddress\n\n {\n 192.0.2.60/24\n }\n"
|
||||
f" notify_master /etc/keepalived/master.sh {SECRET}\n}}\n",
|
||||
)
|
||||
host.unit(
|
||||
"keepalived.service",
|
||||
"ActiveState=active",
|
||||
_exec_start("/usr/sbin/keepalived", "-f", str(config)),
|
||||
)
|
||||
|
||||
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"]] == [
|
||||
("VI_1", "192.0.2.50", 32, "eth0", None),
|
||||
("VI_2", "192.0.2.60", 24, "eth1", "vrid:53"),
|
||||
]
|
||||
|
||||
|
||||
class TestTheCommand:
|
||||
def test_the_frame_is_not_in_the_command_itself(self):
|
||||
assert "VIPS_BEGIN" not in VIRTUAL_IPS_COMMAND
|
||||
assert "VIPS_END" not in VIRTUAL_IPS_COMMAND
|
||||
|
||||
def test_it_writes_and_changes_nothing(self):
|
||||
for verb in ("sudo", " > ", ">>", "kill", "rm ", "start", "stop", "restart", "reload"):
|
||||
assert verb not in VIRTUAL_IPS_COMMAND
|
||||
|
||||
def test_it_is_posix_sh(self):
|
||||
result = subprocess.run(
|
||||
["sh", "-n", "-c", VIRTUAL_IPS_COMMAND], capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
def test_it_is_one_line(self):
|
||||
"""About 4 kB with its awk program -- past the line a terminal takes, so a
|
||||
driver sends it on an exec channel. One argument there, one line."""
|
||||
driver = _Driver(_wire())
|
||||
driver.get_virtual_ips()
|
||||
|
||||
[(command, _privileged)] = driver.calls
|
||||
assert "\n" not in command
|
||||
|
||||
|
||||
class _Driver(VirtualIpsMixin):
|
||||
def __init__(self, privileged: str, unprivileged: str = "") -> None:
|
||||
self.answers = {True: privileged, False: unprivileged}
|
||||
self.calls: list = []
|
||||
|
||||
def _run_virtual_ips_command(self, command: str, *, privileged: bool) -> str:
|
||||
self.calls.append((command, privileged))
|
||||
return self.answers[privileged]
|
||||
|
||||
|
||||
class TestTheTemplate:
|
||||
def test_a_driver_supplies_only_the_transport(self):
|
||||
driver = _Driver(_wire(DB01))
|
||||
|
||||
reading = driver.get_virtual_ips()
|
||||
|
||||
assert reading["vip_manager"][0]["address"] == "10.7.224.10"
|
||||
[(command, privileged)] = driver.calls
|
||||
assert privileged is True
|
||||
assert command.startswith("sh -c '")
|
||||
assert subprocess.run(["sh", "-n", "-c", command]).returncode == 0
|
||||
|
||||
def test_without_root_it_reads_what_it_can(self):
|
||||
"""The config may be root's alone; what is readable is still worth having."""
|
||||
driver = _Driver(
|
||||
"sudo: a password is required\n",
|
||||
_wire(_vip_manager("active active", "unreadable /etc/default/vip-manager.yml")),
|
||||
)
|
||||
|
||||
reading = driver.get_virtual_ips()
|
||||
|
||||
assert reading["vip_manager"] is None
|
||||
assert [p for _, p in driver.calls] == [True, False]
|
||||
|
||||
def test_not_every_os_driver_has_it(self):
|
||||
assert not issubclass(OSDriver, VirtualIpsMixin)
|
||||
assert not hasattr(OSDriver, "get_virtual_ips")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""A new VM's virtual CPU model can be chosen, from a list the hypervisor offers.
|
||||
|
||||
Proxmox gives a VM created without a ``cpu`` argument the ``kvm64`` model,
|
||||
which has no AVX -- and MongoDB 5.0 and later will not start without it. Which
|
||||
model is right depends on the cluster (``host`` cannot live-migrate between
|
||||
different CPUs, ``x86-64-v3`` does not start on a CPU older than Haswell), so
|
||||
the caller chooses, from entries that say what each model provides and whether
|
||||
the node at hand can run it.
|
||||
|
||||
The declarations sit under ``TYPE_CHECKING`` (see test_role_contracts), so the
|
||||
signature is read from the source rather than from the class.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
from typing import List, get_type_hints
|
||||
|
||||
import napalm_device_types.hypervisor as hypervisor_module
|
||||
from napalm_device_types import HypervisorDriver
|
||||
from napalm_device_types.models import VMCpuTypeDict
|
||||
|
||||
|
||||
def _declared(name: str) -> ast.FunctionDef:
|
||||
tree = ast.parse(inspect.getsource(hypervisor_module))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == name:
|
||||
return node
|
||||
raise AssertionError(f"HypervisorDriver does not declare {name}()")
|
||||
|
||||
|
||||
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)}
|
||||
assert "cpu_type" in kwonly
|
||||
default = kwonly["cpu_type"]
|
||||
assert isinstance(default, ast.Constant) and default.value is None
|
||||
|
||||
|
||||
class TestCpuTypeListing:
|
||||
def test_is_declared(self):
|
||||
assert _declared("get_vm_cpu_types").returns is not None
|
||||
|
||||
def test_absent_until_a_driver_implements_it(self):
|
||||
"""netOrk probes capabilities with hasattr; a hypervisor without a
|
||||
choice of CPU model must not seem to offer one."""
|
||||
assert not hasattr(HypervisorDriver, "get_vm_cpu_types")
|
||||
|
||||
def test_entry_shape(self):
|
||||
assert get_type_hints(VMCpuTypeDict) == {
|
||||
"name": str,
|
||||
"description": str,
|
||||
"features": List[str],
|
||||
"available": bool,
|
||||
"default": bool,
|
||||
}
|
||||
@@ -51,20 +51,3 @@ class TestVMConfigCarriesWhatAHardwareViewShows:
|
||||
from napalm_device_types.models import VMPassthroughDict
|
||||
|
||||
assert get_type_hints(VMPassthroughDict) == {"slot": str, "kind": str, "config": str}
|
||||
|
||||
|
||||
class TestGuestAgentDeclaration:
|
||||
"""netOrk's cloud-init installed qemu-guest-agent on every new VM. A VMware
|
||||
guest reports its IP through open-vm-tools instead; the hypervisor says
|
||||
which, and netOrk stops hard-coding one of them."""
|
||||
|
||||
def test_default_is_qemu_guest_agent(self):
|
||||
from napalm_device_types import HypervisorDriver
|
||||
|
||||
assert HypervisorDriver.GUEST_AGENT_PACKAGES == ("qemu-guest-agent",)
|
||||
assert HypervisorDriver.GUEST_AGENT_RUNCMD == ("systemctl enable --now qemu-guest-agent",)
|
||||
|
||||
def test_attributes_are_not_methods(self):
|
||||
from napalm_device_types import HypervisorDriver
|
||||
|
||||
assert not callable(vars(HypervisorDriver)["GUEST_AGENT_PACKAGES"])
|
||||
Reference in new issue
Block a user