Author SHA1 Message Date
christianmanivong f3639d9990 Merge pull request 'chore: adopt the NAPALM driver standard' (#33) from chore/driver-standard into main
driver / standard (push) Successful in 15s
driver / lint (push) Successful in 12s
driver / types (push) Failing after 5s
driver / test (3.10) (push) Successful in 1m13s
driver / test (3.11) (push) Successful in 1m16s
driver / test (3.12) (push) Successful in 1m6s
driver / lowest (push) Successful in 38s
driver / test (3.13) (push) Successful in 1m8s
driver / build (push) Successful in 31s
driver / security (push) Successful in 1m8s
CI / driver (push) Failing after 3m5s
driver / publish (push) Skipped
2026-10-23 12:17:27 +00:00
Christian Manivong 711d5f2705 chore(release): 0.8.0
driver / standard (pull_request) Successful in 15s
driver / lint (pull_request) Successful in 12s
driver / types (pull_request) Successful in 59s
driver / test (3.10) (pull_request) Successful in 1m13s
driver / test (3.11) (pull_request) Successful in 1m18s
driver / test (3.12) (pull_request) Successful in 1m6s
driver / test (3.13) (pull_request) Successful in 59s
driver / lowest (pull_request) Successful in 45s
driver / build (pull_request) Successful in 30s
driver / security (pull_request) Successful in 1m9s
CI / driver (pull_request) Successful in 3m29s
driver / publish (pull_request) Skipped
2026-10-23 12:02:11 +02:00
Christian Manivong 457c1ca7ed chore: adopt the NAPALM driver standard
driver / standard (pull_request) Successful in 14s
driver / lint (pull_request) Successful in 14s
driver / types (pull_request) Failing after 53s
driver / test (3.10) (pull_request) Failing after 44s
driver / test (3.11) (pull_request) Failing after 1m6s
driver / test (3.12) (pull_request) Failing after 47s
driver / test (3.13) (pull_request) Failing after 36s
driver / lowest (pull_request) Failing after 19s
driver / security (pull_request) Failing after 46s
driver / build (pull_request) Successful in 22s
CI / driver (pull_request) Failing after 6m3s
driver / publish (pull_request) Skipped
- CI moves to .gitea/workflows/ci.yml and calls the shared driver workflow
  of NAPALM/driver-standard; .github/ is gone. napalm-device-types comes
  from git until 4.22.0 (AptProxyMixin) is on PyPI; the floor stays at
  4.22.0.
- requires-python >=3.10 (napalm-device-types needs it), py.typed, SPDX
  license string, a dev extra with floors (pytest-mock and responses are
  dropped: no test uses them), links to git.netork.io (README included).
  pytest no longer adds --cov by itself; CI passes it.
- mypy strict for the package; exempt for now, with 204 errors between
  them: napalm_proxmox.config_mixin, .driver, .interfaces_mixin,
  .lldp_mixin, .routing_mixin, .sdn_mixin, .system_mixin, .utils,
  .vm_contract_mixin, .vm_mixin and .vm_provision_mixin. They are recorded
  in the quality baseline with the long and complex functions and the two
  unannotated public methods.
- Coverage floor 68 % (measured 68.94 %).
2026-10-23 08:40:41 +02:00
Christian Manivong 3365207c98 fix: what bandit and the remaining lint rules found
- driver.py: the logger is defined after the imports (E402). The lazy
  paramiko client is annotated Any, as _node_ssh already is; the quoted
  paramiko.SSHClient named a module that is never imported there (F821).
- Unused locals are gone: get_facts' hostname, get_users' groups (both
  read a value nobody used), the ARP flags column is _flags; the output is
  unchanged.
- Long lines are wrapped: the apt-get and snmpd.conf commands are split
  into adjacent string literals, the power-action error message and the
  system mixin's docstring are wrapped; the text is unchanged.
- get_vm_status re-raises with raise ... from e (B904).
- The two except Exception: pass (closing the SSH session, the is_alive
  probe) now log at debug level (B110), so a swallowed failure leaves a
  trace.
- nosec for the empty "password" field of NAPALM's get_users shape and
  the ssh_password_auth flag (B105); all three are false positives.
- tests: unused loop variables.
2026-10-23 08:39:01 +02:00
Christian Manivong 3b873d91fe style: ruff format and the lint rules of the driver standard
Mechanical: ruff format, and ruff check --fix with the rule set every NAPALM
driver uses (E, F, I, UP, B at line length 100, Python 3.10). What ruff
cannot fix comes in the next commit.
2026-10-23 08:37:27 +02:00
christianmanivong 1f6859de08 Merge pull request 'feat: apt's proxy on a Proxmox VE node (NetOrk/netork#1397)' (#31) from feat/apt-proxy-verify into master
CI / test (3.10) (push) Failing after 14s
CI / test (3.11) (push) Failing after 13s
CI / test (3.12) (push) Failing after 13s
2026-10-22 22:08:25 +00:00
Christian Manivong 7776bb904a feat: apt's proxy on a Proxmox VE node (NetOrk/netork#1397)
CI / test (3.10) (push) Failing after 19s
CI / test (3.11) (push) Failing after 17s
CI / test (3.12) (push) Failing after 20s
CI / test (3.10) (pull_request) Failing after 18s
CI / test (3.11) (pull_request) Failing after 20s
CI / test (3.12) (pull_request) Failing after 19s
The node is Debian: AptProxyMixin gives it get_package_proxy(),
get_package_proxy_report(), check_package_proxy() and the fix
configure_package_proxy(), as on any Debian host. Logged in as root, the
transports are the exec path; the file is written without an exit inside
_rc's braces, which would end the shell before the status (see #30).

Requires napalm-device-types 4.22.0; version 0.7.0.
2026-10-22 19:16:40 +02:00
christianmanivong 03c7cdfa02 Merge pull request 'feat: get_service_packages, which packages a node's systemd unit is (NetOrk/netork#1236)' (#29) from feat/service-packages-1236 into master
CI / test (3.10) (push) Successful in 38s
CI / test (3.11) (push) Successful in 42s
CI / test (3.12) (push) Successful in 52s
2026-10-19 17:08:12 +00:00
Christian Manivong e0d0147d75 feat: get_service_packages, which packages a node's systemd unit is (NetOrk/netork#1236)
CI / test (3.10) (pull_request) Successful in 42s
CI / test (3.11) (pull_request) Successful in 33s
CI / test (3.12) (pull_request) Successful in 37s
CI / test (3.10) (push) Successful in 53s
CI / test (3.11) (push) Successful in 46s
CI / test (3.12) (push) Successful in 43s
Mixes in napalm-device-types' ServicePackagesMixin; the transport is the
node's exec path, as for the kernel facts. Read-only. Requires
napalm-device-types 4.20.0; version 0.6.0.
2026-10-19 17:41:14 +02:00
christianmanivong d210c9abe8 Merge pull request 'fix(ssh): verify the host key netOrk expects, report the one seen (NetOrk/netork#1167)' (#27) from fix/ssh-host-key-tofu-1167 into master
CI / test (3.10) (push) Successful in 44s
CI / test (3.11) (push) Successful in 46s
CI / test (3.12) (push) Successful in 46s
2026-10-18 19:23:46 +00:00
Christian Manivong 6bb3035857 fix(ssh): verify the host key netOrk expects, report the one seen (NetOrk/netork#1167)
CI / test (3.10) (push) Successful in 54s
CI / test (3.11) (push) Successful in 40s
CI / test (3.12) (push) Successful in 51s
CI / test (3.10) (pull_request) Successful in 42s
CI / test (3.11) (pull_request) Successful in 35s
CI / test (3.12) (pull_request) Successful in 49s
The driver accepted any SSH host key. It now verifies through
napalm-device-types' HostKeyCheck (>= 4.19.0): with a known key in
optional_args["ssh_host_keys"] any other key is refused before login, and
every key seen is reported to optional_args["ssh_host_key_observer"].
Without either argument it connects as before (first use).
2026-10-18 21:23:46 +02:00
christianmanivong d134ccbb89 Merge pull request 'feat!: report the subscription as a license, not a warning (0.4.0)' (#26) from feat/facts-not-warnings into master
CI / test (3.10) (push) Successful in 44s
CI / test (3.11) (push) Successful in 39s
CI / test (3.12) (push) Successful in 37s
2026-10-17 08:34:56 +00:00
Christian Manivong 1100560f85 feat!: report the subscription as a license, not a warning (0.4.0)
CI / test (3.10) (push) Successful in 33s
CI / test (3.11) (push) Successful in 30s
CI / test (3.12) (push) Successful in 35s
CI / test (3.10) (pull_request) Successful in 32s
CI / test (3.11) (pull_request) Successful in 31s
CI / test (3.12) (pull_request) Successful in 34s
get_device_warnings is gone; netOrk names the warnings from facts
(netOrk #1111). lldpd and the pending updates were already facts
(get_packages, get_available_updates). The subscription is now
get_licenses() from napalm-device-types 4.14's LicenseMixin: one
pve-subscription entry, the API status mapped to a LicenseStatus.

The old check compared against "NotFound" while the API answers
"notfound" (seen on all four nodes), so it never fired; the mapping is
case-insensitive.
2026-10-17 09:36:18 +02:00
christianmanivong 8cf334aaf5 Merge pull request 'feat!: fix templates with the caller's values replace run_device_action (0.3.0)' (#25) from feat/fix-templates into master
CI / test (3.10) (push) Successful in 42s
CI / test (3.11) (push) Successful in 41s
CI / test (3.12) (push) Successful in 46s
2026-10-17 06:48:18 +00:00
Christian Manivong 377e849237 docs: changelog for 0.3.0
CI / test (3.10) (push) Successful in 32s
CI / test (3.11) (push) Successful in 30s
CI / test (3.12) (push) Successful in 35s
CI / test (3.10) (pull_request) Successful in 32s
CI / test (3.11) (pull_request) Successful in 30s
CI / test (3.12) (pull_request) Successful in 34s
2026-10-17 08:29:05 +02:00
Christian Manivong 890e84ca6b feat!: fix templates with the caller's values replace run_device_action (0.3.0)
CI / test (3.10) (push) Successful in 33s
CI / test (3.11) (push) Successful in 32s
CI / test (3.12) (push) Successful in 35s
CI / test (3.10) (pull_request) Successful in 32s
CI / test (3.11) (pull_request) Successful in 30s
CI / test (3.12) (pull_request) Successful in 35s
fix_snmp overwrote snmpd.conf with an application's values (community
"public", "Managed by netOrk", netork@localhost) through a fixed
/tmp/netork_snmpd.conf, and opened ufw for a /24 guessed from ss;
install_lldpd was an action of its own. Neither read an exit status.

The driver implements napalm-device-types 4.13's templates instead:
- configure_snmp_agent(settings): net-snmp installed, snmpd.conf with
  one rocommunity line per allowed source (mktemp, install as root),
  ufw opened for those sources only (pve-firewall stays the
  operator's), snmpd enabled and restarted;
- get_management_peer() from SSH_CONNECTION;
- install_package(name), as the contract says (raises when apt fails),
  so ensure_package comes from the base package.
Every step reads its exit status.

BREAKING CHANGE: run_device_action is removed. Requires
napalm-device-types 4.13.

Part of #24.
2026-10-17 08:28:50 +02:00
christianmanivong 98df0e21ca feat: get_system_identity for matching a node to its BMC (0.2.0)
CI / test (3.10) (push) Successful in 37s
CI / test (3.11) (push) Successful in 37s
CI / test (3.12) (push) Successful in 39s
Mixes in napalm-device-types 4.9's SystemIdentityMixin over the exec
path, which runs as root: the node's SMBIOS system UUID, product serial
and board serial, vendor placeholders empty. pve-dual, a Supermicro
X10DRL-i, reports the product serial 0123456789; its board serial and
UUID are what tie it to its BMC.
2026-10-13 12:20:14 +02:00
christianmanivong 363f93c895 Merge pull request 'feat(vm-provision): provision FreeBSD and OpenBSD guests' (#16) from feat/guest-os into master
CI / test (3.10) (push) Successful in 34s
CI / test (3.11) (push) Successful in 31s
CI / test (3.12) (push) Successful in 35s
2026-10-08 06:05:05 +00:00
Christian Manivong ecff2b430d feat(vm-provision): provision FreeBSD and OpenBSD guests
CI / test (3.10) (push) Successful in 34s
CI / test (3.11) (push) Successful in 32s
CI / test (3.12) (push) Successful in 36s
CI / test (3.10) (pull_request) Successful in 34s
CI / test (3.11) (pull_request) Successful in 32s
CI / test (3.12) (pull_request) Successful in 35s
create_vm_from_cloud_init(guest_os=...) with GUEST_AGENTS from
napalm-device-types 3.0 (QEMU_GUEST_AGENTS: Linux, FreeBSD, OpenBSD). An
unknown guest_os is refused before anything is created. Found on FreeBSD
15.1 and OpenBSD 7.9 cloud images (NetOrk/netork#793):

- OS type `other` for the BSDs. OpenBSD's qemu-ga only works over ISA
  serial, and only as the second port: the image keeps its console on
  com0, so the VM gets `serial0: socket` next to `agent: 1,type=isa`.
- A BSD guest gets a network-config v2 snippet of its own
  (`cicustom: user=...,network=...`, MAC read back from net0). FreeBSD's
  nuageinit fails on the v1 Proxmox generates and then skips runcmd,
  which starts the guest agent. Linux keeps Proxmox's own.
- Packed images (FreeBSD's .qcow2.xz) are unpacked on the node after the
  packed file's checksum is verified; only the unpacked file is cached.
  download-url unpacks ISOs only, so they always take the SSH path.

Fixed on the way:
- get_vm_status skipped loopback only when it was called `lo`; OpenBSD's
  agent lists `lo0` first, so 127.0.0.1 would have been the VM's IP.
  Loopback is now recognised by its address.
- destroy_vm read cicustom after deleting the VM, when its config was gone,
  so snippets stayed on the node; it now reads them first and removes all
  of them (#15).
2026-10-08 07:53:58 +02:00
christianmanivong 0d71b98e6a Merge pull request 'ci: run the tests and build the package on every push and pull request' (#14) from ci/workflow into master
CI / test (3.10) (push) Successful in 33s
CI / test (3.11) (push) Successful in 31s
CI / test (3.12) (push) Successful in 37s
2026-10-07 06:51:13 +00:00
Christian Manivong 0dc6fcb43a test: destroy_vm on a stopped VM no longer spins for a minute and gigabytes
CI / test (3.10) (push) Successful in 34s
CI / test (3.11) (push) Successful in 31s
CI / test (3.12) (push) Successful in 35s
CI / test (3.10) (pull_request) Successful in 32s
CI / test (3.11) (pull_request) Successful in 32s
CI / test (3.12) (pull_request) Successful in 35s
test_destroy_vm_already_stopped left the stop task a bare MagicMock, so
_wait_for_task never saw "stopped" and looped for the full 60 s timeout
with time.sleep patched out. Every call landed in mock_calls: the test
took 60 s and up to 6 GB, and the new CI's 3.12 job was killed for it
(exit 137).

The stop call now raises, as Proxmox does for a VM that is not running --
which is the case the test is named for. The suite drops from 63 s to 4 s.
2026-10-07 08:13:00 +02:00
Christian Manivong 80e9fc3c81 ci: run the tests and build the package on every push and pull request
CI / test (3.10) (push) Successful in 1m39s
CI / test (3.11) (push) Successful in 1m37s
CI / test (3.12) (push) Failing after 1m24s
CI / test (3.10) (pull_request) Successful in 1m39s
CI / test (3.11) (pull_request) Successful in 1m38s
CI / test (3.12) (pull_request) Failing after 1m24s
The same job as napalm-fritzbox and napalm-opnsense: Python 3.10, 3.11 and
3.12, pytest, then wheel and sdist. napalm-device-types comes from
git.netork.io first, because PyPI has an unrelated package of that name.
2026-10-07 07:52:44 +02:00
christianmanivong 171ab8888f Merge pull request 'feat: read the node's listening sockets through napalm-device-types' (#11) from feat/listening-sockets into master 2026-10-06 16:20:13 +00:00
Christian Manivong c644519af6 feat: read the node's listening sockets through napalm-device-types
ProxmoxDriver mixes in ListeningSocketsMixin (napalm-device-types 2.4.0)
and carries its command over the exec path, which runs as root either
way: whether pveproxy, a Ceph manager or anything else on the node listens
on an address reachable from outside it.

For netOrk#658.
2026-10-06 18:19:57 +02:00
christianmanivong 6c9a6e7017 Merge pull request 'feat: declare that upgrading everything must be a full upgrade on Proxmox VE' (#10) from feat/full-upgrade into master 2026-10-06 10:27:21 +00:00
Christian Manivong 66c9be7d25 feat: declare that upgrading everything must be a full upgrade on Proxmox VE
A plain apt-get upgrade holds back what needs new or removed packages and
can leave a node half updated; Proxmox documents apt full-upgrade. netOrk
reads FULL_UPGRADE when it upgrades the host (MVP 5).
2026-10-06 12:27:02 +02:00
christianmanivong 222cd45c66 Merge pull request 'feat: report where an update comes from and whether it is a security fix, refresh the index, read the host status' (#9) from feat/update-origin-host-status into master 2026-10-05 22:20:09 +00:00
35 changed files with 1484 additions and 447 deletions

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
name: CI
on:
push:
branches: [main, master]
tags: ["v*"]
pull_request:
jobs:
driver:
uses: NAPALM/driver-standard/.gitea/workflows/driver.yml@main
with:
standard-ref: main
# Until napalm-device-types 4.22.0 (AptProxyMixin, #53) is on PyPI.
pre-install: "git+https://git.netork.io/NAPALM/napalm-device-types.git"
secrets:
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
+59
View File
@@ -7,7 +7,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.8.0] - 2026-10-23
The first release on PyPI. It collects everything since 0.1.0.
### Changed
- The driver follows the NAPALM driver standard
(https://git.netork.io/NAPALM/driver-standard): CI on Gitea runs ruff, mypy, the
tests on Python 3.10–3.13 and at the lowest declared versions, bandit, pip-audit and
a build check, and a release tag publishes to PyPI. The package now ships `py.typed`.
**Python 3.9 is no longer supported**; napalm-device-types never ran on it.
- Exceptions the driver deliberately ignores (closing the node's SSH session, the
`is_alive()` probe) are now logged at debug level.
- The project links point at git.netork.io.
### Added
- apt's proxy (napalm-device-types' `AptProxyMixin`, NetOrk/netork#1397): `get_package_proxy()`, `get_package_proxy_report()`, `check_package_proxy(url, expect)` and the fix `configure_package_proxy(url)`, as on any Debian host. The node is logged in as root, so the file is written and other proxy lines are commented out over the exec path. Requires napalm-device-types 4.22.0; version 0.7.0.
- `get_service_packages()` (napalm-device-types' `ServicePackagesMixin`, NetOrk/netork#1236): which installed packages each systemd unit of the node is made of — the one shipping its unit file and the one shipping its `ExecStart` binary. Read-only, over the exec path. Requires napalm-device-types 4.20.0; version 0.6.0.
### Security
- The node's SSH session (the fallback for node commands and the provisioning steps) verifies its host key (NetOrk/netork#1167): the key the caller expects for port 22 in `optional_args["ssh_host_keys"]`, else the first one; every key seen goes to `optional_args["ssh_host_key_observer"]`. A changed key is refused before any login: a provisioning step fails, a best-effort read returns nothing. Both paths now share one session (`_node_ssh`). Replaces paramiko's `AutoAddPolicy`. Requires napalm-device-types 4.19.0; version 0.5.0.
### Changed
- **Breaking:** `get_device_warnings` is gone; the node reports facts and the caller
names the warnings (netOrk #1111). lldpd and the pending updates were already facts
(`get_packages`, `get_available_updates`). The subscription is now
`get_licenses()` (napalm-device-types 4.14 `LicenseMixin`): one `pve-subscription`
entry with the API's status as a `LicenseStatus` (`notfound` → `none`, `new` and
`suspended` → `unknown`), its message as `detail`, product name and due date when
subscribed. The old check compared against `NotFound` while the API answers
`notfound`, so it never fired. Requires napalm-device-types 4.14.0; version 0.4.0.
- **Breaking:** `run_device_action` (`fix_snmp`, `install_lldpd`) is gone. The driver
implements napalm-device-types 4.13's fix templates: `configure_snmp_agent(settings)`
with the caller's community and allowed sources (one `rocommunity` line each, ufw only
for those sources, no fixed temp file), `get_management_peer()`, and
`install_package(name)` as the contract says, so `ensure_package` comes from the base
package. Every step reads its exit status. Requires napalm-device-types 4.13.0;
version 0.3.0.
### Added
- `get_system_identity()` (napalm-device-types 4.9 `SystemIdentityMixin`): the node's SMBIOS
system UUID, product serial and board serial over the exec path, vendor placeholders
empty. netOrk matches a server to its BMC on these. Requires napalm-device-types 4.9.0;
version 0.2.0.
- FreeBSD and OpenBSD guests: `create_vm_from_cloud_init(guest_os=...)` with
`GUEST_AGENTS` from napalm-device-types 3.0. A BSD guest gets OS type
`other` and a network-config v2 snippet of its own (`cicustom network=`),
because FreeBSD's nuageinit skips runcmd on the v1 Proxmox generates.
OpenBSD's agent runs over ISA serial behind `serial0`, as the image keeps
its console on com0. An unknown `guest_os` is refused before anything is
created.
- Packed cloud images (FreeBSD's `.qcow2.xz`) are unpacked on the node after
the packed file's checksum is verified; only the unpacked file is cached.
Proxmox's `download-url` unpacks ISOs only, so they always go over SSH.
### Fixed
- `get_vm_status` no longer reports a loopback address as the VM's IP when
the agent lists loopback under another name than `lo` (`lo0` on the BSDs,
listed first by OpenBSD).
- `destroy_vm` reads the VM's cloud-init snippets before deleting the VM, and
removes all of them; it read them afterwards, when the config was gone (#15).
- Cloud images for `create_vm_from_cloud_init` are downloaded by Proxmox itself
when the node has an active storage with content type `import` (Proxmox
8.2+): `download-url` with checksum verification, then `import-from` as the
+3 -2
View File
@@ -12,8 +12,9 @@ It supports all three networking domains of Proxmox VE:
## Requirements
- Python ≥ 3.9
- Python ≥ 3.10
- NAPALM ≥ 5.0.0
- napalm-device-types ≥ 4.22.0
- proxmoxer ≥ 2.0.0
- netaddr ≥ 0.9.0
- requests ≥ 2.31.0
@@ -27,7 +28,7 @@ pip install napalm-proxmox
Or directly from source:
```bash
git clone https://github.com/example/napalm-proxmox.git
git clone https://git.netork.io/NAPALM/napalm-proxmox.git
cd napalm-proxmox
pip install -e .
```
+2 -7
View File
@@ -19,7 +19,6 @@ from __future__ import annotations
import re
from typing import Any
_JsonDict = dict[str, Any]
@@ -44,13 +43,9 @@ class ProxmoxConfigMixin:
configs: _JsonDict = {"running": "", "candidate": "", "startup": ""}
if retrieve in ("running", "all", "startup"):
raw = self._exec_ssh_command(
"cat /etc/network/interfaces 2>/dev/null || true"
)
raw = self._exec_ssh_command("cat /etc/network/interfaces 2>/dev/null || true")
# Append SDN config if available
sdn_raw = self._exec_ssh_command(
"cat /etc/pve/sdn/vnets.cfg 2>/dev/null || true"
)
sdn_raw = self._exec_ssh_command("cat /etc/pve/sdn/vnets.cfg 2>/dev/null || true")
running = raw
if sdn_raw:
running += "\n# === SDN VNets ===\n" + sdn_raw
+75 -54
View File
@@ -32,35 +32,41 @@ from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
from napalm.base.exceptions import ConnectionException
from napalm_device_types import (
AptProxyMixin,
EnsurePackageMixin,
FingerprintRule,
HostStatusMixin,
HypervisorDriver,
KernelFactsMixin,
LicenseMixin,
ListeningSocketsMixin,
PortSpec,
ServicePackagesMixin,
SnmpAgentMixin,
SystemdServicesMixin,
SystemIdentityMixin,
)
from napalm.base.exceptions import ConnectionException
from napalm_device_types.ssh_host_keys import HostKeyCheck, SshHostKeyMismatch
try:
from proxmoxer import ProxmoxAPI
except ImportError as exc:
raise ImportError(
"proxmoxer is required: pip install proxmoxer"
) from exc
raise ImportError("proxmoxer is required: pip install proxmoxer") from exc
from napalm_proxmox.interfaces_mixin import ProxmoxInterfaceMixin
from napalm_proxmox.sdn_mixin import ProxmoxSDNMixin
from napalm_proxmox.lldp_mixin import ProxmoxLLDPMixin
from napalm_proxmox.config_mixin import ProxmoxConfigMixin
from napalm_proxmox.vm_mixin import ProxmoxVMMixin
from napalm_proxmox.vm_contract_mixin import ProxmoxVMContractMixin
from napalm_proxmox.vm_snapshot_mixin import ProxmoxVMSnapshotMixin
from napalm_proxmox.vm_provision_mixin import ProxmoxVMProvisionMixin
from napalm_proxmox.interfaces_mixin import ProxmoxInterfaceMixin
from napalm_proxmox.lldp_mixin import ProxmoxLLDPMixin
from napalm_proxmox.routing_mixin import ProxmoxRoutingMixin
from napalm_proxmox.sdn_mixin import ProxmoxSDNMixin
from napalm_proxmox.system_mixin import ProxmoxSystemMixin
from napalm_proxmox.vm_contract_mixin import ProxmoxVMContractMixin
from napalm_proxmox.vm_mixin import ProxmoxVMMixin
from napalm_proxmox.vm_provision_mixin import ProxmoxVMProvisionMixin
from napalm_proxmox.vm_snapshot_mixin import ProxmoxVMSnapshotMixin
logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------- #
# Type aliases
@@ -84,8 +90,15 @@ class ProxmoxDriver(
ProxmoxRoutingMixin,
ProxmoxSystemMixin,
KernelFactsMixin,
ServicePackagesMixin,
ListeningSocketsMixin,
SystemIdentityMixin,
SystemdServicesMixin,
HostStatusMixin,
LicenseMixin,
SnmpAgentMixin,
EnsurePackageMixin,
AptProxyMixin,
HypervisorDriver,
):
"""NAPALM driver for Proxmox VE nodes."""
@@ -96,6 +109,10 @@ class ProxmoxDriver(
USES_SSH = False
# A PVE node reboots through a full init sequence plus storage checks.
REBOOT_SETTLE_SECONDS = 90
#: "Upgrade everything" must be a full upgrade on Proxmox VE: a plain
#: ``apt-get upgrade`` holds back what needs new or removed packages and can
#: leave the node half updated. netOrk reads this when it upgrades the host.
FULL_UPGRADE = True
PORT_SPECS = [
PortSpec("https", 8006, weight=8.0),
]
@@ -125,7 +142,8 @@ class ProxmoxDriver(
self._port: int = self.optional_args.get("port", 8006)
self._verify_ssl: bool = self.optional_args.get(
"verify_ssl", self.optional_args.get("ssl_verify", self.optional_args.get("verify", True))
"verify_ssl",
self.optional_args.get("ssl_verify", self.optional_args.get("verify", True)),
)
self._realm: str = self.optional_args.get("realm", "pam")
self._token_name: str | None = self.optional_args.get("token_name")
@@ -138,7 +156,8 @@ class ProxmoxDriver(
self._api: ProxmoxAPI | None = None
self._node_name: str = ""
self._ssh_client: "paramiko.SSHClient | None" = None
# paramiko.SSHClient; paramiko is imported lazily (see _node_ssh).
self._ssh_client: Any = None
# Candidate config (merge/replace)
self._candidate_config: str = ""
@@ -192,9 +211,7 @@ class ProxmoxDriver(
self._resolve_node()
except Exception as exc:
raise ConnectionException(
f"Cannot connect to {self.hostname}: {exc}"
) from exc
raise ConnectionException(f"Cannot connect to {self.hostname}: {exc}") from exc
def _resolve_node(self) -> str:
"""Resolve the PVE node this connection talks to.
@@ -212,9 +229,7 @@ class ProxmoxDriver(
self._node_name = self._node
return self._node_name
members = [
s for s in (self._api.cluster.status.get() or []) if s.get("type") == "node"
]
members = [s for s in (self._api.cluster.status.get() or []) if s.get("type") == "node"]
short_host = self.hostname.split(".")[0]
for pick in (
lambda s: s.get("local"),
@@ -243,7 +258,7 @@ class ProxmoxDriver(
try:
self._ssh_client.close()
except Exception:
pass
logger.debug("%s: closing the SSH session: ignored", self.hostname, exc_info=True)
self._ssh_client = None
def is_alive(self) -> _JsonDict:
@@ -260,7 +275,7 @@ class ProxmoxDriver(
self._api.version.get()
alive = True
except Exception:
pass
logger.debug("%s: liveness probe failed", self.hostname, exc_info=True)
return {"is_alive": alive}
# ------------------------------------------------------------------ #
@@ -286,7 +301,6 @@ class ProxmoxDriver(
falls back to a direct paramiko SSH connection.
"""
import base64 as _b64
import time as _time
# Try API exec endpoint
try:
@@ -304,39 +318,51 @@ class ProxmoxDriver(
# Fallback: direct paramiko SSH
try:
import paramiko
if self._ssh_client is None:
ssh_user = self._ssh_username or self.username
ssh_pass = self._ssh_password or self.password
ssh_pkey = None
if self._ssh_key and not ssh_pass:
from io import StringIO as _StringIO
ssh_pkey = paramiko.RSAKey.from_private_key(_StringIO(self._ssh_key))
self._ssh_client = paramiko.SSHClient()
self._ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
connect_kwargs: _JsonDict = {
"hostname": self.hostname,
"port": 22,
"username": ssh_user,
"timeout": self.timeout,
}
if ssh_pkey:
connect_kwargs["pkey"] = ssh_pkey
else:
connect_kwargs["password"] = ssh_pass
self._ssh_client.connect(**connect_kwargs)
_, stdout, stderr = self._ssh_client.exec_command(command, timeout=self.timeout)
_, stdout, stderr = self._node_ssh().exec_command(command, timeout=self.timeout)
err = stderr.read().decode().strip()
out = stdout.read().decode().strip()
return out or err
except ImportError:
logger.warning("paramiko not installed — cannot exec SSH commands")
except SshHostKeyMismatch as exc:
# Refused before any login, and already reported to the caller's store.
logger.warning("%s", exc)
except Exception as exc:
logger.debug("SSH exec command failed: %s", exc)
return ""
def _node_ssh(self) -> Any: # paramiko.SSHClient; paramiko is imported lazily
"""The SSH session to the node on port 22, opened once.
Its host key is the one the caller expects for port 22, or on first use
whichever the node presents; either way it is reported (netork#1167).
"""
import paramiko
if self._ssh_client is None:
ssh_pass = self._ssh_password or self.password
connect_kwargs: _JsonDict = {
"username": self._ssh_username or self.username,
"timeout": self.timeout,
}
if self._ssh_key and not ssh_pass:
from io import StringIO as _StringIO
connect_kwargs["pkey"] = paramiko.RSAKey.from_private_key(_StringIO(self._ssh_key))
else:
connect_kwargs["password"] = ssh_pass
client = paramiko.SSHClient()
try:
HostKeyCheck(self.optional_args, self.hostname, 22).connect(
client, **connect_kwargs
)
except Exception:
client.close()
raise
self._ssh_client = client
return self._ssh_client
# ------------------------------------------------------------------ #
# Node info helpers
# ------------------------------------------------------------------ #
@@ -407,12 +433,9 @@ class ProxmoxDriver(
uptime = float(status.get("uptime", 0))
dns_search = dns.get("search", "")
hostname = self._node_name
fqdn = f"{self._node_name}.{dns_search}" if dns_search else self.hostname
iface_list = sorted(
iface["iface"] for iface in network if iface.get("iface")
)
iface_list = sorted(iface["iface"] for iface in network if iface.get("iface"))
pve_version = version.get("version", "")
release = version.get("release", "")
@@ -436,7 +459,7 @@ class ProxmoxDriver(
"n=$(cat /sys/class/dmi/id/product_name 2>/dev/null); "
"r=$(cat /sys/class/dmi/id/product_version 2>/dev/null); "
"s=$(cat /sys/class/dmi/id/product_serial 2>/dev/null); "
"printf '%s\\n%s\\n%s\\n%s\\n' \"$v\" \"$n\" \"$r\" \"$s\""
'printf \'%s\\n%s\\n%s\\n%s\\n\' "$v" "$n" "$r" "$s"'
)
lines = self._exec_ssh_command(dmi_cmd).splitlines()
if len(lines) >= 4:
@@ -486,5 +509,3 @@ class ProxmoxDriver(
def cli(self, commands: list[str], encoding: str = "text") -> dict[str, str]:
"""Execute a list of shell commands and return their outputs."""
return {cmd: self._exec_ssh_command(cmd) for cmd in commands}
+9 -8
View File
@@ -174,8 +174,13 @@ class ProxmoxInterfaceMixin:
parts = line.split()
if len(parts) < 6:
continue
ip_addr, _, flags, mac, _, iface = (
parts[0], parts[1], parts[2], parts[3], parts[4], parts[5]
ip_addr, _, _flags, mac, _, iface = (
parts[0],
parts[1],
parts[2],
parts[3],
parts[4],
parts[5],
)
if mac in ("00:00:00:00:00:00", ""):
continue
@@ -202,9 +207,7 @@ class ProxmoxInterfaceMixin:
if iface.get("type") in ("bridge",) and iface.get("iface")
]
for bridge in linux_bridges:
raw = self._exec_ssh_command(
f"bridge fdb show br {bridge} 2>/dev/null || true"
)
raw = self._exec_ssh_command(f"bridge fdb show br {bridge} 2>/dev/null || true")
for line in raw.splitlines():
parts = line.split()
if len(parts) < 3:
@@ -240,9 +243,7 @@ class ProxmoxInterfaceMixin:
if iface.get("type") in ("OVSBridge",) and iface.get("iface")
]
for bridge in ovs_bridges:
raw = self._exec_ssh_command(
f"ovs-appctl fdb/show {bridge} 2>/dev/null || true"
)
raw = self._exec_ssh_command(f"ovs-appctl fdb/show {bridge} 2>/dev/null || true")
for line in raw.splitlines():
parts = line.split()
if len(parts) < 4:
+2 -7
View File
@@ -19,7 +19,6 @@ from __future__ import annotations
import re
from typing import Any
_JsonDict = dict[str, Any]
@@ -29,9 +28,7 @@ class ProxmoxLLDPMixin:
def get_lldp_neighbors(self) -> dict[str, list[_JsonDict]]:
"""Return LLDP neighbours (requires lldpd on the Proxmox node)."""
result: dict[str, list[_JsonDict]] = {}
raw = self._exec_ssh_command(
"lldpcli show neighbors summary 2>/dev/null || true"
)
raw = self._exec_ssh_command("lldpcli show neighbors summary 2>/dev/null || true")
current_iface = ""
for line in raw.splitlines():
m_iface = re.match(r"^\s*Interface:\s+(\S+?),?\s", line)
@@ -54,9 +51,7 @@ class ProxmoxLLDPMixin:
def get_lldp_neighbors_detail(self, interface: str = "") -> dict[str, list[_JsonDict]]:
"""Return detailed LLDP neighbour information."""
result: dict[str, list[_JsonDict]] = {}
raw = self._exec_ssh_command(
"lldpcli show neighbors details 2>/dev/null || true"
)
raw = self._exec_ssh_command("lldpcli show neighbors details 2>/dev/null || true")
current_iface = ""
current_entry: _JsonDict = {}
View File
Whitespace-only changes.
+2 -7
View File
@@ -21,7 +21,6 @@ from typing import Any
import napalm.base.constants as C
_JsonDict = dict[str, Any]
@@ -92,12 +91,8 @@ class ProxmoxRoutingMixin:
# Individual probe lines
probes = []
for m_probe in re.finditer(
r"icmp_seq=\d+.*?time=([\d.]+) ms.*?from ([\d.a-fA-F:]+)", raw
):
probes.append(
{"ip_address": m_probe.group(2), "rtt": float(m_probe.group(1))}
)
for m_probe in re.finditer(r"icmp_seq=\d+.*?time=([\d.]+) ms.*?from ([\d.a-fA-F:]+)", raw):
probes.append({"ip_address": m_probe.group(2), "rtt": float(m_probe.group(1))})
return {
"success": {
+5 -12
View File
@@ -153,7 +153,7 @@ class ProxmoxSDNMixin:
tags.setdefault(str(tag), set()).add(bridge)
try:
for vm in (self._node_api().qemu.get() or []):
for vm in self._node_api().qemu.get() or []:
vmid = int(vm.get("vmid", 0))
try:
config = self._node_api().qemu(vmid).config.get() or {}
@@ -164,7 +164,7 @@ class ProxmoxSDNMixin:
logger.warning("_get_vm_vlan_tags: failed to list QEMU VMs: %s", exc)
try:
for ct in (self._node_api().lxc.get() or []):
for ct in self._node_api().lxc.get() or []:
vmid = int(ct.get("vmid", 0))
try:
config = self._node_api().lxc(vmid).config.get() or {}
@@ -238,9 +238,7 @@ class ProxmoxSDNMixin:
2. LLDP basic fallback: first physical port with any LLDP neighbour.
"""
network = {
iface["iface"]: iface
for iface in self._get_node_network()
if iface.get("iface")
iface["iface"]: iface for iface in self._get_node_network() if iface.get("iface")
}
try:
@@ -309,10 +307,7 @@ class ProxmoxSDNMixin:
return True
min_id = min(int(n.get("nodeid", 9999)) for n in online_nodes)
for n in online_nodes:
if (
n.get("name") == self._node_name
and int(n.get("nodeid", 9999)) == min_id
):
if n.get("name") == self._node_name and int(n.get("nodeid", 9999)) == min_id:
return True
return False
except Exception as exc:
@@ -361,9 +356,7 @@ class ProxmoxSDNMixin:
vlan_id: VLAN identifier (1-4094).
config: Dict that may contain ``"name"`` for the VLAN alias.
"""
name: str = (
(config.get("name") or f"VLAN{vlan_id}") if config else f"VLAN{vlan_id}"
)
name: str = (config.get("name") or f"VLAN{vlan_id}") if config else f"VLAN{vlan_id}"
vnet_id = f"vlan{vlan_id:04d}"
uplink = self._find_switch_uplink()
+179 -181
View File
@@ -12,22 +12,62 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""System-level NAPALM getters for Proxmox VE nodes (environment, NTP, SNMP, users, packages, services, updates, disk SMART)."""
"""System-level NAPALM getters for Proxmox VE nodes.
Environment, NTP, SNMP, users, packages, services, updates and disk SMART.
"""
from __future__ import annotations
import base64
import ipaddress
import logging
import re
import shlex
from typing import Any
from napalm_device_types import APT_UPGRADABLE_COMMAND, parse_apt_upgradable
from napalm_proxmox import utils
logger = logging.getLogger(__name__)
_JsonDict = dict[str, Any]
#: The subscription API's status (lower-cased) as a napalm-device-types
#: ``LicenseStatus``; "new" and "suspended" have none of their own.
_SUBSCRIPTION_STATUS = {
"notfound": "none",
"active": "active",
"invalid": "invalid",
"expired": "expired",
}
_PACKAGE_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.+:~-]*$")
#: What may stand in an snmpd.conf value: no whitespace, no quote, no comment.
_SNMP_WORD = re.compile(r'^[^\s"#]+$')
def _net(value: str) -> str:
return str(ipaddress.ip_network(value.strip(), strict=False))
def render_snmpd_conf(settings: dict) -> str:
"""net-snmp's configuration for *settings*: read-only v2c, per allowed source."""
community = settings["community"]
if not _SNMP_WORD.match(community):
raise ValueError("the community must be one word without quotes or #")
listen = settings.get("listen")
lines = [f"agentAddress udp:{listen + ':' if listen else ''}161"]
sources = [_net(s) for s in settings.get("allowed_sources", [])]
lines += [f"rocommunity {community} {s}" for s in sources] or [f"rocommunity {community}"]
for key, directive in (("location", "sysLocation"), ("contact", "sysContact")):
value = settings.get(key, "")
if value:
if "\n" in value:
raise ValueError(f"{key} must be one line")
lines.append(f"{directive} {value}")
return "\n".join(lines) + "\n"
class ProxmoxSystemMixin:
"""Mixin providing system-level NAPALM methods."""
@@ -138,9 +178,7 @@ class ProxmoxSystemMixin:
Proxmox does not expose SNMP configuration via the REST API.
We read /etc/snmp/snmpd.conf via exec if available.
"""
raw = self._exec_ssh_command(
"cat /etc/snmp/snmpd.conf 2>/dev/null || true"
)
raw = self._exec_ssh_command("cat /etc/snmp/snmpd.conf 2>/dev/null || true")
communities: dict[str, _JsonDict] = {}
location = ""
contact = ""
@@ -189,7 +227,6 @@ class ProxmoxSystemMixin:
if not uid:
continue
# Proxmox roles: Administrator -> 15, otherwise 1
groups = user.get("groups", "") or ""
level = 1
try:
roles = self._api.access.users(uid).get() or {} # type: ignore[union-attr]
@@ -199,7 +236,8 @@ class ProxmoxSystemMixin:
logger.debug("Failed to fetch roles for user %s: %s", uid, exc)
result[uid] = {
"level": level,
"password": "",
# NAPALM's get_users shape; the password is never read.
"password": "", # nosec B105
"sshkeys": [],
}
@@ -217,7 +255,8 @@ class ProxmoxSystemMixin:
if uname not in result and uid_int < 1000 or uid_int == 0:
result[uname] = {
"level": 15 if uid_int == 0 else 0,
"password": "",
# NAPALM's get_users shape; the password is never read.
"password": "", # nosec B105
"sshkeys": [],
}
@@ -231,6 +270,24 @@ class ProxmoxSystemMixin:
"""The transport for ``KernelFactsMixin.get_kernel_facts``: the exec path."""
return self._exec_ssh_command(command)
def _run_service_packages_command(self, command: str) -> str:
"""The transport for ``ServicePackagesMixin.get_service_packages``: the exec path."""
return str(self._exec_ssh_command(command))
# ------------------------------------------------------------------ #
# Listening sockets (ListeningSocketsMixin supplies get_listening_sockets)
# ------------------------------------------------------------------ #
def _run_system_identity_command(self, command: str, *, privileged: bool) -> str:
"""The transport for ``SystemIdentityMixin.get_system_identity``: the exec path,
which runs as root either way."""
return str(self._exec_ssh_command(command))
def _run_listening_sockets_command(self, command: str, *, privileged: bool) -> str:
"""The transport for ``ListeningSocketsMixin.get_listening_sockets``:
the exec path, which runs as root either way."""
return str(self._exec_ssh_command(command))
# ------------------------------------------------------------------ #
# Packages (Debian APT)
# ------------------------------------------------------------------ #
@@ -287,7 +344,8 @@ class ProxmoxSystemMixin:
if not name or status != "installed":
continue
size_bytes = int(size_kb) * 1024 if size_kb.isdigit() else 0
result.append({
result.append(
{
"name": name,
"version": version,
"installed": True,
@@ -297,55 +355,34 @@ class ProxmoxSystemMixin:
"source_package": source_package,
"source_version": source_version,
"upgrade_version": upgradable.get(name, ""),
})
}
)
return result
# ------------------------------------------------------------------ #
# Device warnings
# Licenses (napalm-device-types' LicenseMixin)
# ------------------------------------------------------------------ #
def get_device_warnings(self) -> list[_JsonDict]:
"""Return warnings for the Proxmox node.
def get_licenses(self) -> list[_JsonDict]:
"""The node's subscription, as the API reports it.
Currently detects:
- lldpd not installed (LLDP neighbor discovery unavailable)
- Available package updates (via Proxmox APT API)
- Missing / invalid subscription
A fact for the caller: whether running without one is a problem is not
the driver's to say. An unreadable subscription reports nothing.
"""
warnings: list[_JsonDict] = []
# 0. LLDP daemon
try:
lldpd_path = self._exec_ssh_command("command -v lldpd 2>/dev/null").strip()
if not lldpd_path:
warnings.append({"code": "lldpd_not_installed"})
except Exception as exc:
logger.debug("Failed to check for lldpd: %s", exc)
# 1. Available package updates
try:
updates = self.get_available_updates()
if updates:
warnings.append({
"code": "updates_available",
"meta": {
"count": len(updates),
"packages": [u["name"] for u in updates],
},
})
except Exception as exc:
logger.debug("Failed to check available updates: %s", exc)
# 2. Subscription status
try:
sub = self._get_node_subscription()
status = sub.get("status", "")
if status in ("NotFound", "Invalid", "Expired"):
warnings.append({"code": "no_subscription", "meta": {"status": status}})
except Exception as exc:
logger.debug("Failed to check subscription status: %s", exc)
return warnings
raw = str(sub.get("status", ""))
if not raw:
return []
lic: _JsonDict = {
"name": "pve-subscription",
"status": _SUBSCRIPTION_STATUS.get(raw.lower(), "unknown"),
"detail": sub.get("message") or raw,
}
if sub.get("productname"):
lic["edition"] = sub["productname"]
if sub.get("nextduedate"):
lic["expires"] = sub["nextduedate"]
return [lic]
# ------------------------------------------------------------------ #
# Services (systemd, through napalm-device-types' SystemdServicesMixin)
@@ -408,11 +445,12 @@ class ProxmoxSystemMixin:
def apply_updates(self, packages: list[str]) -> _JsonDict:
"""Upgrade the given packages via ``apt-get install`` over SSH."""
for pkg in packages:
if not re.match(r'^[a-zA-Z0-9_\-\+\.]+$', pkg):
if not re.match(r"^[a-zA-Z0-9_\-\+\.]+$", pkg):
raise ValueError(f"Invalid package name: {pkg!r}")
pkg_args = " ".join(packages)
output = self._exec_ssh_command(
f"DEBIAN_FRONTEND=noninteractive apt-get install --only-upgrade -y {pkg_args} 2>&1 || true"
"DEBIAN_FRONTEND=noninteractive apt-get install --only-upgrade -y "
f"{pkg_args} 2>&1 || true"
)
return {"success": True, "output": output}
@@ -442,7 +480,8 @@ class ProxmoxSystemMixin:
port = 161
try:
conf = self._exec_ssh_command(
"grep -E '^[[:space:]]*(ro|rw)?community' /etc/snmp/snmpd.conf 2>/dev/null | head -5"
"grep -E '^[[:space:]]*(ro|rw)?community' /etc/snmp/snmpd.conf "
"2>/dev/null | head -5"
)
for line in conf.splitlines():
parts = line.split()
@@ -460,131 +499,88 @@ class ProxmoxSystemMixin:
return SNMPConfigDict(running=True, community=community, port=port, version="2c")
def run_device_action(self, action: str) -> dict:
"""Execute a named administrative action on the Proxmox node."""
if action == "fix_snmp":
return self._action_fix_snmp()
if action == "install_lldpd":
return self._action_install_lldpd()
raise NotImplementedError(f"Unknown action: {action!r}")
# ------------------------------------------------------------------
# Fixes (napalm-device-types SnmpAgentMixin, EnsurePackageMixin): the
# caller's values. The node's shell runs as root.
# ------------------------------------------------------------------
def _action_install_lldpd(self) -> dict:
"""Install, enable and start lldpd on the Proxmox node.
Proxmox runs Debian/Linux underneath, so this is a plain apt install
followed by enabling the systemd service. lldpd's defaults (listen on
all interfaces) are sufficient to discover the directly-connected
switch via the management bridge (e.g. vmbr0).
"""
lines: list[str] = []
install_out = self._exec_ssh_command(
"DEBIAN_FRONTEND=noninteractive apt-get install -y lldpd 2>&1 | tail -5"
)
lines.append(f"[install] {install_out.strip()[-200:]}")
enable_out = self._exec_ssh_command(
"systemctl enable --now lldpd 2>&1 || service lldpd start 2>&1 || true"
)
lines.append(f"[service] {enable_out.strip()[-200:]}")
verify = self._exec_ssh_command("command -v lldpd 2>/dev/null").strip()
success = bool(verify)
if success:
lines.append("[ok] lldpd installed. Neighbors will appear after a short warm-up period.")
else:
lines.append("[warn] lldpd does not appear to be installed after the attempt.")
return {"success": success, "output": "\n".join(lines)}
def _action_fix_snmp(self) -> dict:
"""Install, configure and start snmpd on the Proxmox node.
Proxmox runs Debian/Linux underneath. _exec_ssh_command runs as root
(either via Proxmox API execute endpoint or SSH with root credentials),
so no sudo is needed.
"""
import base64 as _b64
lines: list[str] = []
# 1. Install snmpd and snmp client tools
install_out = self._exec_ssh_command(
"DEBIAN_FRONTEND=noninteractive apt-get install -y snmpd snmp 2>&1 | tail -5"
)
lines.append(f"[install] {install_out.strip()[-200:]}")
# 2. Detect the IP this connection comes from (for firewall rule)
netork_ip = ""
def _rc(self, command: str) -> tuple[str, int]:
"""Run *command* and return its output and exit status (-1 when unknown)."""
out = self._exec_ssh_command(f"{{ {command}; }} 2>&1; echo __PVE_RC=$?")
text, _, tail = out.rpartition("__PVE_RC=")
try:
raw = self._exec_ssh_command(
"ss -tnp 2>/dev/null | awk '/sshd/{print $5}' | head -1 | cut -d: -f1"
).strip()
if raw and raw not in ("", "0.0.0.0", "::", "127.0.0.1"):
netork_ip = raw
except Exception as exc:
logger.debug("Failed to detect network origin IP: %s", exc)
return text.strip(), int(tail.strip())
except ValueError:
return out.strip(), -1
# 3. Write snmpd.conf via /tmp (no permission issues)
conf_str = (
"agentAddress udp:161\n"
"rocommunity public\n"
"sysLocation Managed by netOrk\n"
"sysContact netork@localhost\n"
)
conf_b64 = _b64.b64encode(conf_str.encode()).decode()
self._exec_ssh_command(f"echo {conf_b64} | base64 -d > /tmp/netork_snmpd.conf")
self._exec_ssh_command(
"mv /tmp/netork_snmpd.conf /etc/snmp/snmpd.conf && "
"chown root:root /etc/snmp/snmpd.conf && chmod 644 /etc/snmp/snmpd.conf"
)
verify = self._exec_ssh_command("cat /etc/snmp/snmpd.conf 2>/dev/null").strip()
if "agentAddress" in verify and "rocommunity" in verify:
lines.append("[config] Wrote /etc/snmp/snmpd.conf - agentAddress udp:161, rocommunity public.")
else:
lines.append(f"[warn] snmpd.conf write may have failed: {verify[:100]}")
def _step(self, command: str, done: str) -> dict:
output, status = self._rc(command)
return {"ok": status == 0, "output": done if status == 0 else output[-300:]}
# 4. Open firewall if ufw is present
if netork_ip:
# apt's proxy (napalm-device-types AptProxyMixin): the transports. The node
# is logged in as root, so every command runs as root.
def _run_apt_command(self, command: str) -> str:
return str(self._exec_ssh_command(command))
def _run_apt_root_command(self, command: str, done: str) -> dict:
return self._step(command, done)
def _write_apt_file(self, path: str, content: str) -> dict:
encoded = base64.b64encode(content.encode()).decode()
# No ``exit`` here: ``_rc`` reads the status after the command's braces.
return self._step(
f'umask 077 && t=$(mktemp) && echo {encoded} | base64 -d > "$t" && '
f'install -m 644 -o root -g root "$t" {shlex.quote(path)}; '
's=$?; rm -f "$t"; [ "$s" -eq 0 ]',
f"wrote {path}",
)
def install_package(self, name: str, version: str = "") -> None:
"""apt-get install *name* (``PackageManagementMixin``); raises when apt fails."""
if not _PACKAGE_NAME.match(name) or (version and not _PACKAGE_NAME.match(version)):
raise ValueError(f"not a package name: {name!r}")
target = f"{name}={version}" if version else name
output, status = self._rc(f"DEBIAN_FRONTEND=noninteractive apt-get install -y {target}")
if status != 0:
raise RuntimeError(f"apt-get install {target} failed: {output[-300:]}")
def get_management_peer(self) -> str | None:
"""The address the node sees the SSH connection come from (``SSH_CONNECTION``)."""
out = self._exec_ssh_command('printf "%s\\n" "$SSH_CONNECTION"').split()
try:
ufw = self._exec_ssh_command("command -v ufw 2>/dev/null").strip()
if ufw:
parts = netork_ip.rsplit(".", 1)
subnet = f"{parts[0]}.0/24" if len(parts) == 2 else netork_ip
fw_out = self._exec_ssh_command(
f"ufw allow from {subnet} to any port 161 proto udp 2>&1"
return str(ipaddress.ip_address(out[0])) if out else None
except ValueError:
return None
def _install_snmp_agent(self) -> dict:
return self._step(
"DEBIAN_FRONTEND=noninteractive apt-get install -y snmpd snmp", "net-snmp installed"
)
lines.append(f"[firewall/ufw] {fw_out.strip()[:200]}")
except Exception as exc:
lines.append(f"[firewall] skipped - {exc}")
# 5. Stop and restart snmpd cleanly (no DBus needed for stop+start)
self._exec_ssh_command(
"service snmpd stop 2>/dev/null; pkill -9 snmpd 2>/dev/null; true"
def _write_snmp_agent_config(self, settings: dict) -> dict:
encoded = base64.b64encode(render_snmpd_conf(settings).encode()).decode()
return self._step(
f'umask 077 && t=$(mktemp) && echo {encoded} | base64 -d > "$t" && '
f'install -m 644 -o root -g root "$t" /etc/snmp/snmpd.conf; '
's=$?; rm -f "$t"; exit $s',
"wrote /etc/snmp/snmpd.conf",
)
import time as _time
_time.sleep(1)
start_out = self._exec_ssh_command(
"service snmpd start 2>&1 || systemctl start snmpd 2>&1 || true"
def _allow_snmp_sources(self, settings: dict) -> dict:
sources = [_net(s) for s in settings.get("allowed_sources", [])]
if not sources:
return {"ok": True, "output": "no sources given; firewall unchanged"}
if self._rc("command -v ufw")[1] != 0:
return {"ok": True, "output": "no ufw; pve-firewall rules are the operator's"}
rules = " && ".join(f"ufw allow from {s} to any port 161 proto udp" for s in sources)
return self._step(rules, f"udp/161 open for {', '.join(sources)}")
def _start_snmp_agent(self) -> dict:
return self._step(
"systemctl enable snmpd && systemctl restart snmpd && systemctl is-active snmpd",
"snmpd enabled and running",
)
lines.append(f"[service] {start_out.strip()[-200:]}")
# 6. Verify via local probe
_time.sleep(2)
probe_out = self._exec_ssh_command(
"snmpget -v2c -cpublic -t2 -r0 -Ov 127.0.0.1 1.3.6.1.2.1.1.1.0 2>&1 || true"
).strip()
_snmp_types = ("STRING:", "INTEGER:", "OID:", "Timeticks:", "Hex-STRING:", "IpAddress:")
success = any(t in probe_out for t in _snmp_types)
if success:
lines.append("[ok] SNMP probe successful - community 'public' is working.")
else:
lines.append(f"[warn] SNMP probe failed - output: {probe_out[:200]}")
return {"success": success, "output": "\n".join(lines)}
# ------------------------------------------------------------------ #
# set_hostname
# ------------------------------------------------------------------ #
def set_hostname(self, new_hostname: str) -> None:
"""Set the system hostname on the Proxmox node.
@@ -622,9 +618,11 @@ class ProxmoxSystemMixin:
fqdn = new_hostname
# Derive old short hostname for substitution in /etc/hosts
old_short = self._exec_ssh_command(
"cat /etc/hostname 2>/dev/null || hostname -s 2>/dev/null"
).strip().split(".")[0]
old_short = (
self._exec_ssh_command("cat /etc/hostname 2>/dev/null || hostname -s 2>/dev/null")
.strip()
.split(".")[0]
)
# ── 1. /etc/hostname ─────────────────────────────────────────────
hostname_b64 = _b64.b64encode(f"{short_hostname}\n".encode()).decode()
@@ -639,6 +637,7 @@ class ProxmoxSystemMixin:
# Both "hostname" and "hostname.domain" forms are covered because
# the new value is the full fqdn when a domain was given.
import re as _re
new_replace = fqdn if domain else short_hostname
hosts_new = _re.sub(
r"\b" + _re.escape(old_short) + r"\b",
@@ -655,16 +654,12 @@ class ProxmoxSystemMixin:
# ── 3. /etc/mailname ─────────────────────────────────────────────
mailname_b64 = _b64.b64encode(f"{fqdn}\n".encode()).decode()
self._exec_ssh_command(
f"[ -f /etc/mailname ]"
f" && echo {mailname_b64} | base64 -d > /etc/mailname"
f" || true"
f"[ -f /etc/mailname ] && echo {mailname_b64} | base64 -d > /etc/mailname || true"
)
# ── 4. Postfix myhostname ─────────────────────────────────────────
self._exec_ssh_command(
f"command -v postconf >/dev/null 2>&1"
f" && postconf -e 'myhostname = {fqdn}'"
f" || true"
f"command -v postconf >/dev/null 2>&1 && postconf -e 'myhostname = {fqdn}' || true"
)
# ── 5. Apply hostname at runtime ─────────────────────────────────
@@ -707,7 +702,9 @@ class ProxmoxSystemMixin:
logger.info(
"Proxmox set_hostname: %s → %s (fqdn: %s). "
"Reboot required for node name to update in web UI / cluster.",
old_short, short_hostname, fqdn,
old_short,
short_hostname,
fqdn,
)
# ------------------------------------------------------------------ #
@@ -818,7 +815,8 @@ class ProxmoxSystemMixin:
"timezone": None,
"ntp_servers": [],
"ssh_port": 22,
"ssh_password_auth": False,
# A flag, not a password.
"ssh_password_auth": False, # nosec B105
}
# Cluster name and node list from /cluster/status
+3 -1
View File
@@ -14,7 +14,6 @@ from typing import Any
from netaddr import EUI, AddrFormatError
# ---------------------------------------------------------------------------
# MAC address helpers
# ---------------------------------------------------------------------------
@@ -47,6 +46,7 @@ def normalize_mac(mac: str) -> str:
# IP / prefix helpers
# ---------------------------------------------------------------------------
def prefix_length(cidr: str) -> int:
"""Return prefix length from a CIDR string such as '192.168.1.0/24' → 24."""
try:
@@ -141,6 +141,7 @@ def speed_mbps(iface_data: dict[str, Any]) -> float:
# ARP / neighbour table helpers
# ---------------------------------------------------------------------------
def parse_arp_output(raw: str) -> list[dict[str, Any]]:
"""Parse Linux ``arp -n`` text output into NAPALM ARP dicts.
@@ -202,6 +203,7 @@ def sdn_zone_to_network_instance(zone: dict[str, Any]) -> dict[str, Any]:
# OVS helpers
# ---------------------------------------------------------------------------
def parse_ovs_vsctl_show(raw: str) -> dict[str, Any]:
"""Very lightweight parser for ``ovs-vsctl show`` output.
+23 -20
View File
@@ -20,8 +20,6 @@ import logging
import re
from typing import Any
from napalm_proxmox import utils
logger = logging.getLogger(__name__)
_JsonDict = dict[str, Any]
@@ -30,9 +28,7 @@ _JsonDict = dict[str, Any]
class ProxmoxVMMixin:
"""Mixin providing VM and container NAPALM methods."""
def get_vm_interfaces(
self, vmid: int, vm_type: str
) -> tuple[dict[str, _JsonDict], bool, bool]:
def get_vm_interfaces(self, vmid: int, vm_type: str) -> tuple[dict[str, _JsonDict], bool, bool]:
"""Return network interfaces for a single VM or LXC container.
Returns a 3-tuple ``(interfaces, agent_running, agent_enabled)``:
@@ -105,7 +101,7 @@ class ProxmoxVMMixin:
logger.debug("get_vm_interfaces: LXC %s config failed: %s", vmid, exc)
try:
for iface in (self._node_api().lxc(vmid).interfaces.get() or []):
for iface in self._node_api().lxc(vmid).interfaces.get() or []:
name = iface.get("name", "")
if not name or name == "lo":
continue
@@ -233,7 +229,7 @@ class ProxmoxVMMixin:
# QEMU VMs
try:
for vm in (self._node_api().qemu.get() or []):
for vm in self._node_api().qemu.get() or []:
vmid = int(vm.get("vmid", 0))
name = vm.get("name", f"vm-{vmid}")
status = vm.get("status", "unknown")
@@ -256,7 +252,8 @@ class ProxmoxVMMixin:
)
disks, onboot = self._get_vm_disk_and_boot(vmid, "qemu")
result.append({
result.append(
{
"vmid": str(vmid),
"name": name,
"type": "vm",
@@ -273,13 +270,14 @@ class ProxmoxVMMixin:
"agent_running": agent_running,
"disks": disks,
"onboot": onboot,
})
}
)
except Exception as exc:
logger.warning("get_vms: failed to list QEMU VMs: %s", exc)
# LXC containers
try:
for ct in (self._node_api().lxc.get() or []):
for ct in self._node_api().lxc.get() or []:
vmid = int(ct.get("vmid", 0))
name = ct.get("name", f"ct-{vmid}")
status = ct.get("status", "unknown")
@@ -300,7 +298,8 @@ class ProxmoxVMMixin:
)
disks, onboot = self._get_vm_disk_and_boot(vmid, "lxc")
result.append({
result.append(
{
"vmid": str(vmid),
"name": name,
"type": "container",
@@ -317,7 +316,8 @@ class ProxmoxVMMixin:
"agent_running": agent_running,
"disks": disks,
"onboot": onboot,
})
}
)
except Exception as exc:
logger.warning("get_vms: failed to list LXC containers: %s", exc)
@@ -328,9 +328,7 @@ class ProxmoxVMMixin:
# Disk-key prefix for LXC: rootfs, mp (mount points)
_DISK_KEYS_CT = re.compile(r"^(rootfs|mp\d+)$")
def _get_vm_disk_and_boot(
self, vmid: int, vm_type: str
) -> tuple[list[dict], bool]:
def _get_vm_disk_and_boot(self, vmid: int, vm_type: str) -> tuple[list[dict], bool]:
"""Return (disks, onboot) for a VM or container.
Fetches config from /nodes/{node}/{qemu|lxc}/{vmid}/config.
@@ -376,8 +374,8 @@ class ProxmoxVMMixin:
logger.debug("Failed to fetch disk/boot config for vmid %s: %s", vmid, exc)
return [], False
_POWER_ACTIONS_VM = {'start', 'stop', 'shutdown', 'reboot', 'reset'}
_POWER_ACTIONS_CT = {'start', 'stop', 'shutdown', 'reboot'}
_POWER_ACTIONS_VM = {"start", "stop", "shutdown", "reboot", "reset"}
_POWER_ACTIONS_CT = {"start", "stop", "shutdown", "reboot"}
def power_vm(self, vmid: int, vm_type: str, action: str) -> dict:
"""Send a power action to a VM or container on this node.
@@ -385,11 +383,16 @@ class ProxmoxVMMixin:
Supported actions for VMs: start, stop, shutdown, reboot, reset
Supported actions for containers: start, stop, shutdown, reboot
"""
allowed = self._POWER_ACTIONS_VM if vm_type == 'vm' else self._POWER_ACTIONS_CT
allowed = self._POWER_ACTIONS_VM if vm_type == "vm" else self._POWER_ACTIONS_CT
if action not in allowed:
return {"success": False, "error": f"Action '{action}' not supported for {vm_type} (allowed: {sorted(allowed)})"}
return {
"success": False,
"error": (
f"Action '{action}' not supported for {vm_type} (allowed: {sorted(allowed)})"
),
}
try:
vm_api = self._node_api().qemu(vmid) if vm_type == 'vm' else self._node_api().lxc(vmid)
vm_api = self._node_api().qemu(vmid) if vm_type == "vm" else self._node_api().lxc(vmid)
task_id = getattr(vm_api.status, action).post()
return {"success": True, "task_id": task_id or ""}
except Exception as exc:
+147 -70
View File
@@ -4,19 +4,26 @@ from __future__ import annotations
import base64
import hashlib
import ipaddress
import logging
import re
import time
import yaml
from typing import TYPE_CHECKING, Any, Dict, List
from typing import TYPE_CHECKING, Any
from urllib.parse import quote
import yaml
from napalm_device_types.models import (
NetworkTargetDict,
StorageTargetDict,
VMProvisionResultDict,
VMStatusDict,
)
from napalm_device_types.provisioning import (
QEMU_GUEST_AGENTS,
GuestAgents,
network_config,
split_compression,
)
if TYPE_CHECKING:
# Type-only: VMCpuTypeDict is newer than the napalm_device_types floor in
@@ -76,6 +83,18 @@ _IMAGE_CACHE_DIR = "/var/lib/vz/template/netork-images"
# raw for a qcow2 would attach the qcow2 container as a raw disk silently.
_IMPORT_EXTENSIONS = {"qcow2": "qcow2", "raw": "raw", "vmdk": "vmdk", "img": "qcow2"}
# The VM shell each guest needs. OpenBSD's qemu-ga cannot use virtio-serial,
# and over ISA serial it answers only as the second port (cua01): the OpenBSD
# cloud image keeps its console on com0, which serial0 takes (netork#793).
_GUEST_VM_SETTINGS: dict[str, dict[str, str]] = {
"linux": {"ostype": "l26", "agent": "1"},
"freebsd": {"ostype": "other", "agent": "1"},
"openbsd": {"ostype": "other", "agent": "1,type=isa", "serial0": "socket"},
}
# A NIC as Proxmox reports it: "virtio=BC:24:11:AA:BB:02,bridge=vmbr0".
_NIC_MAC = re.compile(r"^[a-z0-9]+=([0-9A-Fa-f:]{17})")
# Characters Proxmox keeps in a content file name (PVE::Storage's
# SAFE_CHAR_CLASS_RE); anything else it would rewrite behind our back.
_UNSAFE_FILENAME_CHARS = re.compile(r"[^A-Za-z0-9\-.+=_]")
@@ -111,6 +130,10 @@ def _import_volume_name(image_url: str) -> str | None:
class ProxmoxVMProvisionMixin:
"""Mixin to add VM provisioning to ProxmoxDriver."""
#: Every guest QEMU's agent runs on; see _GUEST_VM_SETTINGS for the VM
#: hardware each one needs.
GUEST_AGENTS: GuestAgents = QEMU_GUEST_AGENTS
def _run_node_command(self, command: str, timeout: int) -> str:
"""
Execute a shell command on the Proxmox node via SSH, raising on failure.
@@ -120,31 +143,7 @@ class ProxmoxVMProvisionMixin:
disk import — where a non-zero exit or a caller-specific timeout must
surface as a hard failure rather than an empty string.
"""
import paramiko
if self._ssh_client is None:
ssh_user = self._ssh_username or self.username
ssh_pass = self._ssh_password or self.password
ssh_pkey = None
if self._ssh_key and not ssh_pass:
from io import StringIO as _StringIO
ssh_pkey = paramiko.RSAKey.from_private_key(_StringIO(self._ssh_key))
self._ssh_client = paramiko.SSHClient()
self._ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
connect_kwargs: Dict[str, Any] = {
"hostname": self.hostname,
"port": 22,
"username": ssh_user,
"timeout": self.timeout,
}
if ssh_pkey:
connect_kwargs["pkey"] = ssh_pkey
else:
connect_kwargs["password"] = ssh_pass
self._ssh_client.connect(**connect_kwargs)
_, stdout, stderr = self._ssh_client.exec_command(command, timeout=timeout)
_, stdout, stderr = self._node_ssh().exec_command(command, timeout=timeout)
exit_status = stdout.channel.recv_exit_status()
out = stdout.read().decode().strip()
err = stderr.read().decode().strip()
@@ -214,6 +213,61 @@ class ProxmoxVMProvisionMixin:
raise AssertionError("unreachable") # loop always returns or raises above
def _cloud_image_on_node(self, image_url: str, image_checksum: str | None, timeout: int) -> str:
"""The node path of the image, downloaded, verified and unpacked.
A packed image (FreeBSD ships .qcow2.xz) is unpacked on the node after
its checksum, which covers the packed file, has been verified; only
the unpacked file is kept. Proxmox's download-url unpacks ISOs only,
so a packed image always comes this way.
"""
filename = image_url.rstrip("/").rsplit("/", 1)[-1]
unpacked_name, unpack = split_compression(filename)
if unpack is None:
return self._download_cloud_image(image_url, image_checksum, timeout=timeout)
unpacked = f"{_IMAGE_CACHE_DIR}/{_url_key(image_url)}-{unpacked_name}"
cached = self._run_node_command(
f"mkdir -p {_IMAGE_CACHE_DIR} && test -f {unpacked} && echo EXISTS || echo MISSING",
timeout=30,
)
if "EXISTS" in cached:
return unpacked
packed = self._download_cloud_image(image_url, image_checksum, timeout=timeout)
_logger.info(f"Unpacking {packed} -> {unpacked}")
self._run_node_command(
f"{unpack} {packed} > {unpacked}.tmp && mv {unpacked}.tmp {unpacked} && rm -f {packed}",
timeout=timeout,
)
return unpacked
def _write_snippet(self, storage_path: str, filename: str, content: str) -> None:
"""Write a cloud-init snippet into *storage_path*/snippets on the node.
Proxmox's /storage/{s}/upload API only accepts content in
{iso, vztmpl, import} — "snippets" is rejected outright ("does not
have a value in the enumeration"). Snippets can only be written
directly to the filesystem, so this goes over SSH.
"""
_logger.debug(f"Writing Cloud-Init snippet {filename} to {storage_path}/snippets")
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
self._run_node_command(
f"mkdir -p {storage_path}/snippets && "
f"echo {encoded} | base64 -d > {storage_path}/snippets/{filename}",
timeout=30,
)
def _nic_macs(self, vmid: int, nics: list[dict[str, Any]]) -> list[tuple[str, bool]]:
"""``(mac, dhcp)`` per NIC, with the MAC Proxmox assigned where none was given."""
config = self._node_api().qemu(vmid).config.get()
macs = []
for i, nic in enumerate(nics):
match = _NIC_MAC.match(config.get(f"net{i}", ""))
if not match:
raise RuntimeError(f"VM {vmid} has no MAC address on net{i}")
macs.append((match.group(1), nic.get("dhcp", i == 0)))
return macs
def _find_import_storage(self) -> str | None:
"""The first storage on this node that accepts content "import".
@@ -276,7 +330,7 @@ class ProxmoxVMProvisionMixin:
The path for nodes without an import storage, or for an image type
Proxmox cannot import itself.
"""
local_path = self._download_cloud_image(image_url, image_checksum, timeout=download_timeout)
local_path = self._cloud_image_on_node(image_url, image_checksum, download_timeout)
_logger.info(f"Importing {local_path} into VM {vmid} on storage {image_storage}")
self._run_node_command(
f"qm importdisk {vmid} {local_path} {image_storage} --format qcow2",
@@ -375,9 +429,9 @@ class ProxmoxVMProvisionMixin:
)
return path
def get_image_storages(self) -> List[StorageTargetDict]:
def get_image_storages(self) -> list[StorageTargetDict]:
"""List node-available storage pools suitable for a new VM's root disk."""
targets: List[StorageTargetDict] = []
targets: list[StorageTargetDict] = []
for storage in self._node_api().storage.get():
content = storage.get("content", "")
if "images" not in content or storage.get("enabled", 1) == 0:
@@ -484,13 +538,14 @@ class ProxmoxVMProvisionMixin:
image_url: str,
cpu: int,
memory: int,
nics: List[Dict[str, Any]],
cloud_init_config: Dict[str, Any],
nics: list[dict[str, Any]],
cloud_init_config: dict[str, Any],
image_checksum: str | None = None,
ssh_public_keys: List[str] | None = None,
ssh_public_keys: list[str] | None = None,
disk_resize_gb: int | None = None,
storage: str | None = None,
cpu_type: str | None = None,
guest_os: str = "linux",
download_timeout: int = 300,
timeout: int = 180,
) -> VMProvisionResultDict:
@@ -524,6 +579,10 @@ class ProxmoxVMProvisionMixin:
storage: storage pool for the root disk (None = auto-detect first
enabled, node-available storage with content='images')
cpu_type: CPU model from get_vm_cpu_types() (None = x86-64-v2-AES)
guest_os: the OS in the image, a key of GUEST_AGENTS. It sets the
OS type and how the agent is attached; a guest other than Linux
also gets a network-config v2 snippet of its own, because
FreeBSD's nuageinit skips runcmd on the v1 Proxmox writes.
download_timeout: max seconds for the image download (skipped if cached)
timeout: max seconds for the remaining provisioning steps
@@ -536,8 +595,13 @@ class ProxmoxVMProvisionMixin:
unknown or that this node's CPU cannot run (raised before
anything is created)
"""
if guest_os not in self.GUEST_AGENTS:
raise ValueError(
f"Cannot provision a {guest_os!r} guest; this driver provisions "
f"{', '.join(sorted(self.GUEST_AGENTS))}"
)
try:
_logger.info(f"Creating VM '{name}' from image {image_url}")
_logger.info(f"Creating {guest_os} VM '{name}' from image {image_url}")
cpu_model = self._resolve_cpu_type(cpu_type)
# Step 1: Get next VMID
@@ -553,12 +617,11 @@ class ProxmoxVMProvisionMixin:
memory=memory,
cores=cpu,
cpu=cpu_model,
ostype="l26",
scsihw="virtio-scsi-pci",
# Without this, Proxmox never attaches the virtio-serial
# channel the QEMU guest agent needs — get_vm_status's
# agent queries (below) would have nothing to talk to.
agent="1",
# Includes agent: without it Proxmox never attaches the
# channel the QEMU guest agent needs — get_vm_status's agent
# queries (below) would have nothing to talk to.
**_GUEST_VM_SETTINGS[guest_os],
)
# Step 3: Download cloud image (cached) and import as root disk
@@ -570,7 +633,7 @@ class ProxmoxVMProvisionMixin:
# Step 4: Configure network interfaces (CPU/memory already set at shell creation)
_logger.info(f"Configuring {len(nics)} NIC(s) for VM {vmid}")
config_args: Dict[str, Any] = {}
config_args: dict[str, Any] = {}
# Build NIC config strings generically
for i, nic in enumerate(nics):
@@ -621,20 +684,20 @@ class ProxmoxVMProvisionMixin:
)
filename = f"{vmid}-user-data.yaml"
_logger.debug(f"Writing Cloud-Init snippet {filename} to {snippet_storage}")
# Proxmox's /storage/{s}/upload API only accepts content in
# {iso, vztmpl, import} — "snippets" is rejected outright
# ("does not have a value in the enumeration"). Snippets can only
# be written directly to the filesystem, so resolve the storage's
# backing path and write the file over SSH instead.
storage_path = self._get_storage_path(snippet_storage)
encoded = base64.b64encode(user_data_yaml.encode("utf-8")).decode("ascii")
self._run_node_command(
f"mkdir -p {storage_path}/snippets && "
f"echo {encoded} | base64 -d > {storage_path}/snippets/{filename}",
timeout=30,
self._write_snippet(storage_path, filename, user_data_yaml)
cicustom = f"user={snippet_storage}:snippets/{filename}"
# Proxmox writes network-config v1, and FreeBSD's nuageinit fails
# on it and then skips runcmd, which starts the guest agent. A
# guest other than Linux gets the v2 every cloud-init reads.
network = network_config(self._nic_macs(vmid, nics)) if guest_os != "linux" else None
if network:
network_filename = f"{vmid}-network-config.yaml"
self._write_snippet(
storage_path, network_filename, yaml.safe_dump(network, sort_keys=False)
)
cicustom += f",network={snippet_storage}:snippets/{network_filename}"
# Step 7: Configure Cloud-Init references and SSH keys
_logger.info(f"Setting Cloud-Init config for VM {vmid}")
@@ -648,7 +711,7 @@ class ProxmoxVMProvisionMixin:
# this points at a snippets-only storage.
"ide2": f"{image_storage}:cloudinit",
"citype": "nocloud",
"cicustom": f"user={snippet_storage}:snippets/{filename}",
"cicustom": cicustom,
}
# Configure DHCP for NICs where enabled (default True for index 0, False otherwise)
@@ -736,6 +799,10 @@ class ProxmoxVMProvisionMixin:
except Exception as e:
_logger.debug(f"VM {vmid} stop failed (may already be stopped): {e}")
# The snippets have to be known before the delete: afterwards the
# VM's config is gone (napalm-proxmox#15).
snippets = self._cicustom_snippets(vmid_int)
# Step 2: Delete VM
# Proxmox's API parameter is hyphenated (destroy-unreferenced-disks),
# not a valid Python identifier — proxmoxer forwards kwargs to the
@@ -750,21 +817,12 @@ class ProxmoxVMProvisionMixin:
# Step 3: Clean up Cloud-Init snippets
# (This is best-effort; snippet files may be unreachable if storage is unavailable)
try:
config = self._node_api().qemu(vmid_int).config.get()
cicustom = config.get("cicustom", "")
if "snippets/" in cicustom:
parts = cicustom.split("=")
if len(parts) >= 2:
snippet_ref = parts[1] # e.g. "snippets:snippets/101-user-data.yaml"
storage, filepath = snippet_ref.split(":", 1)
for storage, filepath in snippets:
_logger.debug(f"Deleting snippet {filepath} from {storage}")
try:
self._node_api().storage(storage).content(filepath).delete()
except Exception as e:
_logger.warning(f"Failed to delete snippet {filepath}: {e}")
except Exception as e:
_logger.debug(f"Could not clean up snippets for VM {vmid}: {e}")
_logger.info(f"VM {vmid} destroyed successfully")
@@ -772,6 +830,24 @@ class ProxmoxVMProvisionMixin:
_logger.exception(f"Failed to destroy VM {vmid}: {e}")
raise
def _cicustom_snippets(self, vmid: int) -> list[tuple[str, str]]:
"""``(storage, path)`` of every snippet the VM's cicustom names.
cicustom reads "user=local:snippets/101-user-data.yaml,network=...".
Best-effort: a VM whose config cannot be read has none to clean up.
"""
try:
cicustom = self._node_api().qemu(vmid).config.get().get("cicustom", "")
except Exception as e:
_logger.debug(f"Could not read the snippets of VM {vmid}: {e}")
return []
snippets = []
for entry in cicustom.split(","):
storage, _, path = entry.partition("=")[2].partition(":")
if path.startswith("snippets/"):
snippets.append((storage, path))
return snippets
def get_vm_status(
self,
vmid: str,
@@ -829,17 +905,18 @@ class ProxmoxVMProvisionMixin:
interfaces = (agent_info or {}).get("result", [])
# The guest agent does not report interfaces in a fixed order —
# "lo" commonly comes first. Skip it and take the first real
# NIC that has an IPv4 address.
# loopback commonly comes first, named "lo" on Linux and
# "lo0" on the BSDs. Skip loopback addresses, whatever the
# interface is called, and take the first IPv4 address.
for iface in interfaces:
name = iface.get("name", "")
if not name or name == "lo":
if not name:
continue
for addr in iface.get("ip-addresses", []):
if addr.get("ip-address-type") != "ipv4":
continue
ip_addr = addr.get("ip-address", "")
if ip_addr:
if ip_addr and not ipaddress.ip_address(ip_addr).is_loopback:
_logger.info(f"VM {vmid} acquired IP {ip_addr}")
return {
"status": "running",
@@ -862,16 +939,16 @@ class ProxmoxVMProvisionMixin:
raise
except Exception as e:
_logger.exception(f"Error getting status for VM {vmid}: {e}")
raise RuntimeError(f"Failed to get VM {vmid} status: {e}")
raise RuntimeError(f"Failed to get VM {vmid} status: {e}") from e
def get_network_targets(self) -> List[NetworkTargetDict]:
def get_network_targets(self) -> list[NetworkTargetDict]:
"""
List selectable network targets (bridges + SDN vnets) for a new VM's NIC.
Excludes physical NICs, bonds, and other non-bridge interface types —
those are never valid ``NICConfigDict.bridge`` values on Proxmox.
"""
targets: List[NetworkTargetDict] = []
targets: list[NetworkTargetDict] = []
for iface in self._get_node_network():
iface_type = iface.get("type")
+52 -22
View File
@@ -1,31 +1,29 @@
[build-system]
requires = ["setuptools>=61", "wheel"]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "napalm-proxmox"
version = "0.1.0"
version = "0.8.0"
description = "NAPALM driver for Proxmox VE (classic Linux networking, SDN, OVS)"
readme = "README.md"
license = { text = "Apache-2.0" }
license = "Apache-2.0"
authors = [{ name = "Christian Manivong" }]
keywords = ["napalm", "proxmox", "network", "sdn", "ovs", "automation"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: System :: Networking",
]
requires-python = ">=3.9"
requires-python = ">=3.10"
dependencies = [
"napalm>=5.0.0",
"napalm_device_types>=2.3.0",
"napalm_device_types>=4.22.0",
"paramiko>=5.0.0", # CVE-2026-44405; imported directly for SSH fallback (driver.py)
"proxmoxer>=2.0.0",
"netaddr>=0.9.0",
@@ -33,34 +31,66 @@ dependencies = [
]
[project.optional-dependencies]
dev = [
"pytest>=7.4",
"pytest-cov>=4.1",
"pytest-mock>=3.12",
"responses>=0.24",
"ruff>=0.1",
"mypy>=1.7",
]
dev = ["pytest>=8", "pytest-cov>=5", "mypy>=1.11", "ruff>=0.6"]
[project.entry-points."napalm.drivers"]
proxmox = "napalm_proxmox:ProxmoxDriver"
[project.urls]
Repository = "https://git.netork.io/NAPALM/napalm-proxmox"
Issues = "https://git.netork.io/NAPALM/napalm-proxmox/issues"
Changelog = "https://git.netork.io/NAPALM/napalm-proxmox/src/branch/main/CHANGELOG.md"
[tool.setuptools.packages.find]
where = ["."]
include = ["napalm_proxmox*"]
[tool.setuptools.package-data]
napalm_proxmox = ["py.typed"]
[tool.ruff]
line-length = 100
target-version = "py39"
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP"]
select = ["E", "F", "I", "UP", "B"]
[tool.ruff.lint.per-file-ignores]
# Recorded device output keeps its lines.
"tests/**" = ["E501"]
[tool.mypy]
python_version = "3.10"
strict = true
# napalm itself is untyped, so every driver subclasses Any.
disallow_subclassing_any = false
[[tool.mypy.overrides]]
module = ["napalm.*", "proxmoxer.*", "paramiko.*", "netaddr.*", "yaml.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
# Not yet under strict mypy (quality baseline); may only shrink.
module = [
"napalm_proxmox.config_mixin",
"napalm_proxmox.driver",
"napalm_proxmox.interfaces_mixin",
"napalm_proxmox.lldp_mixin",
"napalm_proxmox.routing_mixin",
"napalm_proxmox.sdn_mixin",
"napalm_proxmox.system_mixin",
"napalm_proxmox.utils",
"napalm_proxmox.vm_contract_mixin",
"napalm_proxmox.vm_mixin",
"napalm_proxmox.vm_provision_mixin",
]
ignore_errors = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--cov=napalm_proxmox --cov-report=term-missing"
[tool.mypy]
python_version = "3.9"
warn_return_any = true
warn_unused_configs = true
[tool.coverage.run]
source = ["napalm_proxmox"]
[tool.coverage.report]
fail_under = 68
+3 -2
View File
@@ -2,11 +2,11 @@
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, patch
from napalm_proxmox.driver import ProxmoxDriver
import pytest
from napalm_proxmox.driver import ProxmoxDriver
# ---------------------------------------------------------------------------
# Sample API payloads
@@ -124,6 +124,7 @@ PVE_USERS = [
# Driver fixture
# ---------------------------------------------------------------------------
def _build_mock_api(
nodes=None,
node_status=None,
+65
View File
@@ -0,0 +1,65 @@
{
"coverage_floor": 68.0,
"function_complexity": {
"napalm_proxmox/driver.py::ProxmoxDriver._resolve_node": 13,
"napalm_proxmox/driver.py::ProxmoxDriver.get_facts": 16,
"napalm_proxmox/interfaces_mixin.py::ProxmoxInterfaceMixin.get_interfaces_counters": 14,
"napalm_proxmox/interfaces_mixin.py::ProxmoxInterfaceMixin.get_mac_address_table": 22,
"napalm_proxmox/routing_mixin.py::ProxmoxRoutingMixin.get_route_to": 13,
"napalm_proxmox/routing_mixin.py::ProxmoxRoutingMixin.ping": 12,
"napalm_proxmox/sdn_mixin.py::ProxmoxSDNMixin._find_switch_uplink": 13,
"napalm_proxmox/sdn_mixin.py::ProxmoxSDNMixin._get_ovs_bridge_for_port": 13,
"napalm_proxmox/sdn_mixin.py::ProxmoxSDNMixin._get_vm_vlan_tags": 11,
"napalm_proxmox/sdn_mixin.py::ProxmoxSDNMixin._is_cluster_master": 13,
"napalm_proxmox/sdn_mixin.py::ProxmoxSDNMixin.get_network_instances": 13,
"napalm_proxmox/sdn_mixin.py::ProxmoxSDNMixin.get_vlans": 16,
"napalm_proxmox/sdn_mixin.py::ProxmoxSDNMixin.set_vlan": 13,
"napalm_proxmox/system_mixin.py::ProxmoxSystemMixin.get_disk_smart": 22,
"napalm_proxmox/system_mixin.py::ProxmoxSystemMixin.get_packages": 16,
"napalm_proxmox/system_mixin.py::ProxmoxSystemMixin.get_users": 15,
"napalm_proxmox/utils.py::addresses_from_node_network": 15,
"napalm_proxmox/vm_contract_mixin.py::parse_vm_config": 16,
"napalm_proxmox/vm_mixin.py::ProxmoxVMMixin._get_vm_disk_and_boot": 16,
"napalm_proxmox/vm_mixin.py::ProxmoxVMMixin.get_vm_interfaces": 37,
"napalm_proxmox/vm_mixin.py::ProxmoxVMMixin.get_vms": 21,
"napalm_proxmox/vm_provision_mixin.py::ProxmoxVMProvisionMixin.create_vm_from_cloud_init": 26,
"napalm_proxmox/vm_provision_mixin.py::ProxmoxVMProvisionMixin.get_vm_status": 16
},
"function_lines": {
"napalm_proxmox/driver.py::ProxmoxDriver.get_facts": 82,
"napalm_proxmox/interfaces_mixin.py::ProxmoxInterfaceMixin.get_mac_address_table": 71,
"napalm_proxmox/lldp_mixin.py::ProxmoxLLDPMixin.get_lldp_neighbors_detail": 52,
"napalm_proxmox/routing_mixin.py::ProxmoxRoutingMixin.ping": 78,
"napalm_proxmox/sdn_mixin.py::ProxmoxSDNMixin._get_vm_vlan_tags": 54,
"napalm_proxmox/sdn_mixin.py::ProxmoxSDNMixin.get_vlans": 66,
"napalm_proxmox/sdn_mixin.py::ProxmoxSDNMixin.set_vlan": 70,
"napalm_proxmox/system_mixin.py::ProxmoxSystemMixin.get_disk_smart": 74,
"napalm_proxmox/system_mixin.py::ProxmoxSystemMixin.get_packages": 66,
"napalm_proxmox/system_mixin.py::ProxmoxSystemMixin.get_users": 51,
"napalm_proxmox/system_mixin.py::ProxmoxSystemMixin.set_hostname": 124,
"napalm_proxmox/vm_mixin.py::ProxmoxVMMixin.get_vm_interfaces": 179,
"napalm_proxmox/vm_mixin.py::ProxmoxVMMixin.get_vms": 114,
"napalm_proxmox/vm_provision_mixin.py::ProxmoxVMProvisionMixin._download_cloud_image": 61,
"napalm_proxmox/vm_provision_mixin.py::ProxmoxVMProvisionMixin.create_vm_from_cloud_init": 232,
"napalm_proxmox/vm_provision_mixin.py::ProxmoxVMProvisionMixin.destroy_vm": 65,
"napalm_proxmox/vm_provision_mixin.py::ProxmoxVMProvisionMixin.get_vm_status": 92
},
"mypy_relaxed": [
"napalm_proxmox.config_mixin",
"napalm_proxmox.driver",
"napalm_proxmox.interfaces_mixin",
"napalm_proxmox.lldp_mixin",
"napalm_proxmox.routing_mixin",
"napalm_proxmox.sdn_mixin",
"napalm_proxmox.system_mixin",
"napalm_proxmox.utils",
"napalm_proxmox.vm_contract_mixin",
"napalm_proxmox.vm_mixin",
"napalm_proxmox.vm_provision_mixin"
],
"python_file_lines": {},
"untyped_public": [
"napalm_proxmox/sdn_mixin.py::ProxmoxSDNMixin.set_vlan",
"napalm_proxmox/system_mixin.py::ProxmoxSystemMixin.get_snmp_config"
]
}
+6 -9
View File
@@ -2,12 +2,13 @@
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from napalm.base.exceptions import ConnectionException
from napalm_proxmox.driver import ProxmoxDriver
from tests.conftest import _build_mock_api, NODES_LIST
from tests.conftest import _build_mock_api
class TestOpen:
@@ -101,9 +102,7 @@ def _open_with(hostname, **api_kwargs):
class TestClusterNodeResolution:
def test_local_node_wins_over_first_listed(self):
drv = _open_with(
"172.22.8.120", nodes=CLUSTER_NODES_LIST, cluster_status=CLUSTER_STATUS
)
drv = _open_with("172.22.8.120", nodes=CLUSTER_NODES_LIST, cluster_status=CLUSTER_STATUS)
assert drv._node_name == "pve-dual"
def test_matches_by_ip_without_local_flag(self):
@@ -118,9 +117,7 @@ class TestClusterNodeResolution:
def test_matches_short_name_of_fqdn(self):
status = [{**n, "local": 0} for n in CLUSTER_STATUS if n["type"] == "node"]
drv = _open_with(
"pve-01.mgmt.example.com", nodes=CLUSTER_NODES_LIST, cluster_status=status
)
drv = _open_with("pve-01.mgmt.example.com", nodes=CLUSTER_NODES_LIST, cluster_status=status)
assert drv._node_name == "pve-01"
def test_single_node_without_cluster_status(self):
+3 -8
View File
@@ -2,11 +2,12 @@
from __future__ import annotations
import pytest
from tests.conftest import _build_mock_api, NODE_STATUS
from unittest.mock import patch
import pytest
from napalm_proxmox.driver import ProxmoxDriver
from tests.conftest import _build_mock_api
class TestGetEnvironment:
@@ -25,9 +26,6 @@ class TestGetEnvironment:
assert isinstance(result["fans"], dict)
def test_temperature_with_sensors(self):
from tests.conftest import _build_mock_api, NODE_STATUS
from unittest.mock import patch
from napalm_proxmox.driver import ProxmoxDriver
sensors_data = [
{"name": "CPU Package", "value": "55.0"},
@@ -49,9 +47,6 @@ class TestGetEnvironment:
assert result["temperature"]["GPU Core"]["is_critical"] is False
def test_temperature_critical(self):
from tests.conftest import _build_mock_api
from unittest.mock import patch
from napalm_proxmox.driver import ProxmoxDriver
sensors_data = [{"name": "NVMe", "value": "98.0"}]
mock_api = _build_mock_api(sensors=sensors_data)
+192
View File
@@ -0,0 +1,192 @@
"""The fixes as napalm-device-types templates, with the caller's values (netOrk #1105).
``fix_snmp`` wrote an application's snmpd.conf (community "public", "Managed by
netOrk") and opened ufw for the caller's /24 guessed from ``ss``;
``install_lldpd`` was an action of its own. Now ``configure_snmp_agent(settings)``
takes every value from the caller, and ``ensure_package`` installs through the
contract's ``install_package``. Every step reads its exit status.
"""
from __future__ import annotations
import re
import pytest
from napalm_proxmox import ProxmoxDriver
SETTINGS = {"community": "c0mm", "allowed_sources": ["10.7.224.20"], "location": "rack 1"}
class _Node:
"""The node's shell: answers by command fragment, with an exit status."""
def __init__(self, **status):
self.status = status # fragment -> exit status
self.ran: list[str] = []
def __call__(self, command: str) -> str:
self.ran.append(command)
if "SSH_CONNECTION" in command:
return "10.7.224.20 51234 10.0.0.9 22\n"
if command.startswith("dpkg-query"):
return "curl\tinstalled\n"
rc = next((code for frag, code in self.status.items() if frag in command), 0)
out = "ok" if rc == 0 else "E: failed"
return f"{out}\n__PVE_RC={rc}" if "__PVE_RC" in command else out
@pytest.fixture
def node():
return _Node()
@pytest.fixture
def driver(node):
d = ProxmoxDriver("pve", "root@pam", "pw")
d._exec_ssh_command = node
return d
class TestSnmpAgent:
def test_install_write_open_start_with_the_callers_values(self, driver, node):
result = driver.configure_snmp_agent(SETTINGS)
assert result["success"] is True
assert [s["step"] for s in result["steps"]] == ["install", "configure", "firewall", "start"]
assert any("apt-get install -y snmpd snmp" in c for c in node.ran)
write = next(c for c in node.ran if "/etc/snmp/snmpd.conf" in c)
assert "install -m 644" in write
b64 = re.search(r"echo ([A-Za-z0-9+/=]+) \| base64 -d", write).group(1)
import base64
conf = base64.b64decode(b64).decode()
assert "rocommunity c0mm 10.7.224.20/32" in conf
assert "sysLocation rack 1" in conf
assert "public" not in conf and "netork" not in conf.lower()
assert any("systemctl restart snmpd" in c for c in node.ran)
def test_no_temp_file_with_an_application_name(self, driver, node):
driver.configure_snmp_agent(SETTINGS)
assert not any("netork" in c for c in node.ran)
def test_ufw_opens_only_for_the_allowed_sources(self, driver, node):
node.status["command -v ufw"] = 0
driver.configure_snmp_agent(SETTINGS)
assert any("ufw allow from 10.7.224.20/32 to any port 161 proto udp" in c for c in node.ran)
assert not any(".0/24" in c for c in node.ran)
def test_a_failed_install_stops_the_fix(self, driver, node):
node.status["apt-get install -y snmpd"] = 100
result = driver.configure_snmp_agent(SETTINGS)
assert result["success"] is False
assert not any("/etc/snmp/snmpd.conf" in c for c in node.ran)
def test_the_management_peer(self, driver):
assert driver.get_management_peer() == "10.7.224.20"
class TestPackages:
def test_ensure_package_installs_what_is_missing(self, driver, node):
result = driver.ensure_package("lldpd")
assert result["success"] is True
assert any("apt-get install -y lldpd" in c for c in node.ran)
def test_ensure_package_leaves_alone_what_is_there(self, driver, node):
assert driver.ensure_package("curl")["success"] is True
assert not any("apt-get install" in c for c in node.ran)
def test_install_package_raises_when_apt_fails(self, driver, node):
node.status["apt-get install -y lldpd"] = 100
with pytest.raises(RuntimeError, match="lldpd"):
driver.install_package("lldpd")
def test_a_name_that_is_no_package_name_is_refused(self, driver):
with pytest.raises(ValueError):
driver.install_package("lldpd; reboot")
def test_the_old_actions_are_gone():
for name in ("run_device_action", "_action_fix_snmp", "_action_install_lldpd"):
assert not hasattr(ProxmoxDriver, name), name
_APT_REPORT = (
"APROXY_BEGIN\n[dump]\n{dump}[parts]\n{parts}[main]\n[sources]\n"
"http://download.proxmox.com/debian/pve/dists/trixie/\nAPROXY_END\n"
)
class _AptNode(_Node):
"""A node that answers apt's proxy report and a fetch."""
def __init__(self, proxy="", parts=""):
super().__init__()
self.proxy = proxy
self.parts = parts
def __call__(self, command: str) -> str:
if "apt-config dump" in command:
self.ran.append(command)
dump = f'Acquire::http::Proxy "{self.proxy}";\n' if self.proxy else ""
return _APT_REPORT.format(dump=dump, parts=self.parts)
if "apt-helper" in command:
self.ran.append(command)
return "APROXY_BEGIN\n[fetch]\nGet:1 x\n[rc]\n0\n[match]\n2\nAPROXY_END\n"
return super().__call__(command)
class TestPackageProxy:
"""Proxmox VE is Debian: apt's proxy is set and read as on any Debian host (netOrk #1397)."""
def test_it_takes_the_fix(self):
assert callable(getattr(ProxmoxDriver, "configure_package_proxy", None))
def test_the_fact(self):
d = ProxmoxDriver("pve", "root@pam", "pw")
d._exec_ssh_command = _AptNode(proxy="http://c:3142")
assert d.get_package_proxy() == "http://c:3142"
def test_written_as_root_read_back_and_fetched_through(self):
node = _AptNode(proxy="http://c:3142")
d = ProxmoxDriver("pve", "root@pam", "pw")
d._exec_ssh_command = node
result = d.configure_package_proxy("http://c:3142")
assert result["success"] is True, result["output"]
write = next(c for c in node.ran if "/etc/apt/apt.conf.d/00proxy" in c)
assert "install -m 644" in write
b64 = re.search(r"echo ([A-Za-z0-9+/=]+) \| base64 -d", write).group(1)
import base64
assert base64.b64decode(b64).decode() == 'Acquire::http::Proxy "http://c:3142";\n'
assert any("dists/trixie/InRelease" in c for c in node.ran)
def test_another_proxy_is_commented_out(self):
node = _AptNode(
proxy="http://c:3142",
parts='/etc/apt/apt.conf.d/90curtin-aptproxy:1:Acquire::http::Proxy "http://old:3128";\n',
)
d = ProxmoxDriver("pve", "root@pam", "pw")
d._exec_ssh_command = node
d.configure_package_proxy("http://c:3142")
assert any(c.startswith("{ sed -i -e '1{") for c in node.ran)
def test_the_proxy_answers(self):
d = ProxmoxDriver("pve", "root@pam", "pw")
d._exec_ssh_command = _AptNode()
result = d.check_package_proxy("http://c:3142/acng-report.html", expect="Cache")
assert result == {"reachable": True, "expected": True, "error": ""}
+11
View File
@@ -0,0 +1,11 @@
"""Proxmox VE says that "upgrade everything" must be a full upgrade on it.
A plain ``apt-get upgrade`` can leave a node half updated; Proxmox documents
``apt full-upgrade``. netOrk reads ``FULL_UPGRADE`` to choose.
"""
from napalm_proxmox.driver import ProxmoxDriver
def test_a_full_upgrade_is_declared():
assert ProxmoxDriver.FULL_UPGRADE is True
+1 -3
View File
@@ -3,10 +3,8 @@
from __future__ import annotations
import pytest
from tests.conftest import _build_mock_api, NODE_NETWORK
from unittest.mock import patch
from napalm_proxmox.driver import ProxmoxDriver
from tests.conftest import NODE_NETWORK
@pytest.fixture
+13 -9
View File
@@ -2,11 +2,7 @@
from __future__ import annotations
import pytest
from tests.conftest import _build_mock_api, NODE_NETWORK
from unittest.mock import patch
from napalm_proxmox.driver import ProxmoxDriver
from tests.conftest import NODE_NETWORK
class TestGetInterfaces:
@@ -89,10 +85,18 @@ class TestGetInterfacesCounters:
result = driver.get_interfaces_counters()
eth0 = result["eth0"]
for field in (
"tx_errors", "rx_errors", "tx_discards", "rx_discards",
"tx_octets", "rx_octets", "tx_unicast_packets", "rx_unicast_packets",
"tx_multicast_packets", "rx_multicast_packets",
"tx_broadcast_packets", "rx_broadcast_packets",
"tx_errors",
"rx_errors",
"tx_discards",
"rx_discards",
"tx_octets",
"rx_octets",
"tx_unicast_packets",
"rx_unicast_packets",
"tx_multicast_packets",
"rx_multicast_packets",
"tx_broadcast_packets",
"rx_broadcast_packets",
):
assert field in eth0, f"Missing field: {field}"
+1 -1
View File
@@ -12,9 +12,9 @@ import gzip
from unittest.mock import patch
import pytest
from napalm_device_types import KernelFactsMixin
from napalm_device_types.kernel import KERNEL_FACTS_COMMAND
from napalm_proxmox.driver import ProxmoxDriver
REPORT = (
+65
View File
@@ -0,0 +1,65 @@
"""The node's subscription as a fact (netOrk #1111).
The driver named ``no_subscription`` itself, comparing against "NotFound" while
the API answers "notfound" -- so it never fired. It reports the subscription
now; whether a missing one is a warning is the caller's decision.
"""
from __future__ import annotations
import pytest
from napalm_proxmox import ProxmoxDriver
def _driver(subscription: dict) -> ProxmoxDriver:
d = ProxmoxDriver("pve", "root@pam", "pw")
d._get_node_subscription = lambda: subscription
return d
@pytest.mark.parametrize(
"raw, status",
[
("notfound", "none"),
("NotFound", "none"),
("active", "active"),
("invalid", "invalid"),
("expired", "expired"),
("new", "unknown"),
("suspended", "unknown"),
],
)
def test_the_api_status_as_a_license_status(raw, status):
[lic] = _driver({"status": raw, "message": "There is no subscription key"}).get_licenses()
assert lic["name"] == "pve-subscription"
assert lic["status"] == status
assert lic["detail"] == "There is no subscription key"
def test_level_and_due_date_when_subscribed():
sub = {
"status": "active",
"productname": "Proxmox VE Community Subscription 1 CPU/year",
"nextduedate": "2027-03-01",
}
[lic] = _driver(sub).get_licenses()
assert lic["edition"] == "Proxmox VE Community Subscription 1 CPU/year"
assert lic["expires"] == "2027-03-01"
def test_an_unreadable_subscription_reports_nothing():
"""``_get_node_subscription`` gives {} when the API call fails: no fact, not "none"."""
assert _driver({}).get_licenses() == []
def test_no_warnings_of_its_own():
"""lldpd, the updates and the subscription are facts the caller reads
(get_packages, get_available_updates, get_licenses)."""
assert not any(
"get_device_warnings" in vars(c)
for c in ProxmoxDriver.__mro__[:-1]
if c.__module__.startswith("napalm_proxmox")
)
+38
View File
@@ -0,0 +1,38 @@
"""`get_listening_sockets`: what listens on the node, and which service it is.
Whether pveproxy, a Ceph manager or a guest-facing service is reachable from
outside the node is decided by the address it listens on. The command and its
parse are napalm-device-types'; the driver only carries the command over its
exec path, which already runs as root.
"""
from __future__ import annotations
from unittest.mock import patch
from napalm_device_types import ListeningSocketsMixin
from napalm_proxmox.driver import ProxmoxDriver
WIRE = (
"SOCK_BEGIN\n[ss]\n"
'tcp LISTEN 0 4096 *:8006 *:* users:(("pveproxy worker",pid=2101,fd=6))\n'
"__SS_RC=0\n[cgroups]\n"
"2101 0::/system.slice/pveproxy.service\n"
"SOCK_END\n"
)
def test_the_driver_declares_the_contract():
assert issubclass(ProxmoxDriver, ListeningSocketsMixin)
def test_it_reads_as_root_over_the_exec_path(driver):
with patch.object(driver, "_exec_ssh_command", return_value=WIRE) as exec_:
reading = driver.get_listening_sockets()
[command] = [c.args[0] for c in exec_.call_args_list]
assert command.startswith("sh -c '")
assert reading["attributed"] is True
[socket] = reading["sockets"]
assert (socket["address"], socket["port"], socket["unit"]) == ("*", 8006, "pveproxy")
+5 -4
View File
@@ -2,8 +2,9 @@
from __future__ import annotations
from unittest.mock import patch
import pytest
from unittest.mock import MagicMock, patch
class TestGetNTPServers:
@@ -85,7 +86,7 @@ class TestGetUsers:
def test_user_has_required_keys(self, driver):
driver._node_api().execute.post.return_value = {"data": ""}
result = driver.get_users()
for user, data in result.items():
for _user, data in result.items():
assert "level" in data
assert "password" in data
assert "sshkeys" in data
@@ -248,7 +249,7 @@ class TestGetRouteTo:
def test_route_fields(self, driver):
driver._node_api().execute.post.return_value = {"data": self.ROUTE_OUTPUT}
result = driver.get_route_to()
for prefix, routes in result.items():
for _prefix, routes in result.items():
for route in routes:
assert "protocol" in route
assert "next_hop" in route
@@ -257,7 +258,7 @@ class TestGetRouteTo:
def test_protocol_filter(self, driver):
driver._node_api().execute.post.return_value = {"data": self.ROUTE_OUTPUT}
result = driver.get_route_to(protocol="dhcp")
for prefix, routes in result.items():
for _prefix, routes in result.items():
for route in routes:
assert "dhcp" in route["protocol"].lower()
+1 -6
View File
@@ -2,12 +2,10 @@
from __future__ import annotations
import pytest
from unittest.mock import patch
from napalm_proxmox import utils
PROC_NET_ARP = """\
IP address HW type Flags HW address Mask Device
10.0.0.254 0x1 0x2 aa:bb:cc:dd:ee:ff * eth0
@@ -137,10 +135,7 @@ class TestGetMACAddressTable:
"cc:dd:ee:ff:00:11 dev eth0 vlan 20 master vmbr0\n"
)
OVS_APPCTL = (
" LOCAL 0 aa:bb:cc:dd:ee:03 0\n"
" 1 10 11:22:33:44:55:66 100\n"
)
OVS_APPCTL = " LOCAL 0 aa:bb:cc:dd:ee:03 0\n 1 10 11:22:33:44:55:66 100\n"
def test_linux_bridge_entries(self, driver):
def _exec(cmd):
+2 -3
View File
@@ -2,8 +2,7 @@
from __future__ import annotations
import pytest
from tests.conftest import _build_mock_api, SDN_ZONES, SDN_VNETS
from tests.conftest import _build_mock_api
class TestGetVlans:
@@ -52,8 +51,8 @@ class TestGetVlans:
def test_empty_sdn_returns_dict(self):
from unittest.mock import patch
from napalm_proxmox.driver import ProxmoxDriver
from tests.conftest import _build_mock_api
mock_api = _build_mock_api(sdn_vnets=[], sdn_zones=[])
drv = ProxmoxDriver("pve1", "root", "secret", optional_args={"verify_ssl": False})
+43
View File
@@ -0,0 +1,43 @@
"""`get_service_packages`: which installed packages each systemd unit of the node is.
The node's own services -- pveproxy, corosync, ceph -- are where a decision
about a service lands on a hypervisor (NetOrk/netork#1236). The command and its
parse are napalm-device-types'; the driver only carries the command over its
exec path.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from napalm_device_types import ServicePackagesMixin
from napalm_device_types.service_packages import SERVICE_PACKAGES_COMMAND
from napalm_proxmox.driver import ProxmoxDriver
WIRE = (
"SVCPKG_BEGIN\n[units]\nId=pveproxy.service\n"
"FragmentPath=/lib/systemd/system/pveproxy.service\n"
"ExecStart={ path=/usr/bin/pveproxy ; argv[]=/usr/bin/pveproxy start }\n\n"
"[dpkg]\npve-manager: /lib/systemd/system/pveproxy.service\n"
"pve-manager: /usr/bin/pveproxy\nSVCPKG_END"
)
def test_the_driver_declares_the_contract():
assert issubclass(ProxmoxDriver, ServicePackagesMixin)
def test_it_runs_the_shared_command(driver):
with patch.object(driver, "_exec_ssh_command", return_value=WIRE) as exec_:
found = driver.get_service_packages()
exec_.assert_called_once_with(SERVICE_PACKAGES_COMMAND)
assert found == {"pveproxy": ["pve-manager"]}
def test_output_without_a_report_raises(driver):
with patch.object(driver, "_exec_ssh_command", return_value=""):
with pytest.raises(ValueError):
driver.get_service_packages()
+133
View File
@@ -0,0 +1,133 @@
"""The node's SSH session verifies its host key, and reports the one it sees (NetOrk/netork#1167).
Both ways onto the node's shell — the best-effort ``_exec_ssh_command`` and the
provisioning steps' ``_run_node_command`` — share one session, opened on port 22.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import paramiko
import pytest
from napalm_device_types.ssh_host_keys import (
KNOWN_KEYS_ARG,
OBSERVER_ARG,
SshHostKey,
SshHostKeyMismatch,
)
from napalm_proxmox.driver import ProxmoxDriver
SERVER_KEY = paramiko.ECDSAKey.generate()
OTHER_KEY = paramiko.ECDSAKey.generate()
class _Stream:
def __init__(self, data: bytes) -> None:
self._data = data
self.channel = MagicMock()
self.channel.recv_exit_status.return_value = 0
def read(self) -> bytes:
return self._data
class _Client:
"""paramiko.SSHClient as far as the driver uses it; refuses like paramiko would."""
def __init__(self) -> None:
self.lines: list[str] = []
self.policy = None
self.connected_with: dict | None = None
self.commands: list[str] = []
def set_missing_host_key_policy(self, policy) -> None:
self.policy = policy
def get_host_keys(self):
client = self
class _Keys:
def load(self, path: str) -> None:
with open(path) as f:
client.lines += f.read().splitlines()
return _Keys()
def connect(self, hostname, **kwargs) -> None:
if self.lines and SshHostKey.of(SERVER_KEY).key not in self.lines[0]:
raise paramiko.BadHostKeyException(hostname, SERVER_KEY, OTHER_KEY)
if not self.lines:
self.policy.missing_host_key(self, hostname, SERVER_KEY)
self.connected_with = {"hostname": hostname, **kwargs}
def get_transport(self):
transport = MagicMock()
transport.get_remote_server_key.return_value = SERVER_KEY
return transport
def exec_command(self, command, timeout=None):
self.commands.append(command)
return None, _Stream(b"ok"), _Stream(b"")
def close(self) -> None:
pass
@pytest.fixture
def client(monkeypatch) -> _Client:
fake = _Client()
monkeypatch.setattr(paramiko, "SSHClient", lambda: fake)
return fake
def _driver(seen: list, known: dict | None = None) -> ProxmoxDriver:
args: dict = {"port": 8006, OBSERVER_ARG: seen.append}
if known is not None:
args[KNOWN_KEYS_ARG] = known
driver = ProxmoxDriver("10.0.0.9", "root", "s3cret", optional_args=args)
driver._node_api = MagicMock(side_effect=RuntimeError("no exec endpoint")) # type: ignore[method-assign]
return driver
def test_first_use_runs_the_command_and_reports_the_key(client):
seen: list = []
assert _driver(seen)._run_node_command("true", timeout=5) == "ok"
assert client.connected_with["port"] == 22
assert seen[0]["accepted"] is True
assert (seen[0]["host"], seen[0]["port"]) == ("10.0.0.9", 22)
def test_the_key_for_the_api_port_is_not_the_ssh_one(client):
seen: list = []
other = SshHostKey.of(OTHER_KEY).as_dict()
_driver(seen, {"8006": other})._run_node_command("true", timeout=5)
assert seen[0]["expected"] is None
def test_a_changed_key_stops_a_provisioning_step(client):
seen: list = []
driver = _driver(seen, {"22": SshHostKey.of(OTHER_KEY).as_dict()})
with pytest.raises(SshHostKeyMismatch):
driver._run_node_command("qm importdisk …", timeout=5)
assert client.commands == []
assert client.connected_with is None
assert seen[0]["accepted"] is False
def test_a_changed_key_leaves_the_best_effort_read_empty(client):
seen: list = []
driver = _driver(seen, {"22": SshHostKey.of(OTHER_KEY).as_dict()})
assert driver._exec_ssh_command("cat /etc/os-release") == ""
assert client.commands == []
assert seen[0]["accepted"] is False
def test_the_known_key_reuses_one_session(client):
seen: list = []
driver = _driver(seen, {"22": SshHostKey.of(SERVER_KEY).as_dict()})
driver._exec_ssh_command("uptime")
driver._run_node_command("true", timeout=5)
assert client.commands == ["uptime", "true"]
assert len(seen) == 1 and seen[0]["accepted"] is True
+32
View File
@@ -0,0 +1,32 @@
"""`get_system_identity`: the node's SMBIOS identity, what ties it to its BMC.
The command, its parse and the placeholder filter are napalm-device-types'; the driver
only carries the command over its exec path, which runs as root.
"""
from __future__ import annotations
from unittest.mock import patch
from napalm_device_types import SYSTEM_IDENTITY_COMMAND, SystemIdentityMixin
from napalm_proxmox.driver import ProxmoxDriver
# pve-dual, a Supermicro X10DRL-i: the product serial is the board vendor's default.
WIRE = "IDBEGIN\n31303043-534D-C40C-7A72-6F1300000000\n0123456789\nZM155S026022\nIDEND\n"
def test_the_driver_declares_the_contract():
assert issubclass(ProxmoxDriver, SystemIdentityMixin)
def test_it_runs_the_shared_command(driver):
with patch.object(driver, "_exec_ssh_command", return_value=WIRE) as exec_:
identity = driver.get_system_identity()
exec_.assert_called_once_with(SYSTEM_IDENTITY_COMMAND)
assert identity == {
"system_uuid": "31303043-534d-c40c-7a72-6f1300000000",
"product_serial": "",
"board_serial": "ZM155S026022",
}
-1
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import pytest
from napalm_proxmox import utils
+276
View File
@@ -0,0 +1,276 @@
"""Provisioning guests other than Linux: FreeBSD and OpenBSD (NetOrk/netork#794).
What each guest needs was found on FreeBSD 15.1 and OpenBSD 7.9 cloud images
(NetOrk/netork#793).
"""
from __future__ import annotations
import base64
from unittest.mock import MagicMock, patch
import pytest
import yaml
from napalm_device_types.provisioning import QEMU_GUEST_AGENTS
from napalm_proxmox.driver import ProxmoxDriver
from napalm_proxmox.vm_provision_mixin import ProxmoxVMProvisionMixin
_FREEBSD_URL = (
"https://download.freebsd.org/releases/VM-IMAGES/15.1-RELEASE/amd64/Latest/"
"FreeBSD-15.1-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz"
)
_DEBIAN_URL = (
"https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2"
)
def _mixin(net0: str = "virtio=BC:24:11:AA:BB:02,bridge=vmbr0") -> tuple:
"""A mixin whose node answers like Proxmox; the VM config carries *net0*."""
mixin = ProxmoxVMProvisionMixin()
mixin._node_name = "pve1"
api = MagicMock()
api.cluster.nextid.get.return_value = 101
api.storage.return_value.get.return_value = {"path": "/var/lib/vz"}
node = MagicMock()
node.storage.get.return_value = [
{"storage": "local-lvm", "type": "lvmthin", "content": "images,rootdir", "enabled": 1},
{"storage": "local", "type": "dir", "content": "snippets,iso", "enabled": 1},
]
vm = MagicMock()
node.qemu.return_value = vm
vm.config.get.return_value = {"unused0": "local-lvm:vm-101-disk-0", "net0": net0}
vm.status.start.post.return_value = "UPID:pve1:start"
node.tasks.return_value.status.get.return_value = {"status": "stopped", "exitstatus": "OK"}
mixin._api = api
mixin._node_api = MagicMock(return_value=node)
mixin._download_cloud_image = MagicMock(
return_value="/var/lib/vz/template/netork-images/x.qcow2"
)
mixin._run_node_command = MagicMock(return_value="")
return mixin, node, vm
def _create(mixin, guest_os: str, image_url: str = _DEBIAN_URL, **kwargs):
with patch("time.sleep"):
return mixin.create_vm_from_cloud_init(
name="bsd-vm",
image_url=image_url,
cpu=2,
memory=2048,
nics=[{"bridge": "vmbr0"}],
cloud_init_config={"hostname": "bsd-vm"},
guest_os=guest_os,
**kwargs,
)
def _all_config_posts(vm) -> dict:
merged: dict = {}
for call in vm.config.post.call_args_list:
merged.update(call.kwargs)
return merged
def _written_snippet(mixin, name: str) -> str | None:
"""The content of the snippet *name* the driver wrote over SSH, if any."""
for call in mixin._run_node_command.call_args_list:
command = call.args[0]
if f"snippets/{name}" in command and "base64 -d" in command:
encoded = command.split("echo ", 1)[1].split(" |", 1)[0]
return base64.b64decode(encoded).decode()
return None
class TestWhichGuestsProxmoxProvisions:
def test_all_qemu_guests(self):
assert ProxmoxDriver.GUEST_AGENTS == QEMU_GUEST_AGENTS
def test_an_unknown_guest_is_refused_before_anything_is_created(self):
mixin, node, _ = _mixin()
with pytest.raises(ValueError, match="windows"):
_create(mixin, "windows")
mixin._api.cluster.nextid.get.assert_not_called()
node.qemu.post.assert_not_called()
class TestVmShellPerGuest:
@pytest.mark.parametrize(
("guest_os", "ostype"), [("linux", "l26"), ("freebsd", "other"), ("openbsd", "other")]
)
def test_ostype(self, guest_os, ostype):
mixin, node, _ = _mixin()
_create(mixin, guest_os)
assert node.qemu.post.call_args.kwargs["ostype"] == ostype
@pytest.mark.parametrize("guest_os", ["linux", "freebsd"])
def test_agent_over_virtio_serial(self, guest_os):
mixin, node, _ = _mixin()
_create(mixin, guest_os)
shell = node.qemu.post.call_args.kwargs
assert shell["agent"] == "1"
assert "serial0" not in shell
def test_openbsd_agent_on_the_second_isa_serial_port(self):
"""OpenBSD's qemu-ga cannot use virtio-serial, and the image keeps its
console on com0: the agent only answers as cua01, behind serial0."""
mixin, node, _ = _mixin()
_create(mixin, "openbsd")
shell = node.qemu.post.call_args.kwargs
assert shell["agent"] == "1,type=isa"
assert shell["serial0"] == "socket"
class TestNetworkConfigPerGuest:
def test_linux_keeps_proxmoxs_own_network_config(self):
mixin, _, vm = _mixin()
_create(mixin, "linux")
config = _all_config_posts(vm)
assert config["cicustom"] == "user=local:snippets/101-user-data.yaml"
assert config["ipconfig0"] == "ip=dhcp"
assert _written_snippet(mixin, "101-network-config.yaml") is None
@pytest.mark.parametrize("guest_os", ["freebsd", "openbsd"])
def test_a_bsd_guest_gets_a_v2_network_config_matched_by_its_mac(self, guest_os):
"""FreeBSD's nuageinit fails on the v1 Proxmox writes and then skips
runcmd, which is what starts the guest agent."""
mixin, _, vm = _mixin(net0="virtio=BC:24:11:AA:BB:02,bridge=vmbr0")
_create(mixin, guest_os)
config = _all_config_posts(vm)
assert config["cicustom"] == (
"user=local:snippets/101-user-data.yaml,network=local:snippets/101-network-config.yaml"
)
assert yaml.safe_load(_written_snippet(mixin, "101-network-config.yaml")) == {
"version": 2,
"ethernets": {"nic0": {"match": {"macaddress": "bc:24:11:aa:bb:02"}, "dhcp4": True}},
}
class TestPackedImages:
"""Proxmox's download-url unpacks ISOs only, so a packed image goes over SSH."""
def test_an_xz_image_is_unpacked_on_the_node_and_the_unpacked_file_imported(self):
mixin, _, _ = _mixin()
mixin._run_node_command = MagicMock(
side_effect=lambda cmd, timeout: "MISSING" if "test -f" in cmd else ""
)
mixin._download_cloud_image = MagicMock(
return_value="/var/lib/vz/template/netork-images/k-FreeBSD-15.1-ufs.qcow2.xz"
)
_create(mixin, "freebsd", image_url=_FREEBSD_URL, image_checksum="sha256:abc")
mixin._download_cloud_image.assert_called_once()
assert mixin._download_cloud_image.call_args.args[1] == "sha256:abc" # the .xz is verified
commands = [c.args[0] for c in mixin._run_node_command.call_args_list]
unpack = next(c for c in commands if c.startswith("xz -dc "))
assert "k-FreeBSD-15.1-ufs.qcow2.xz" in unpack
importdisk = next(c for c in commands if c.startswith("qm importdisk"))
assert ".qcow2.xz" not in importdisk
assert ".qcow2 " in importdisk
def test_an_unpacked_image_already_on_the_node_is_not_downloaded_again(self):
mixin, _, _ = _mixin()
mixin._run_node_command = MagicMock(
side_effect=lambda cmd, timeout: "EXISTS" if "test -f" in cmd else ""
)
_create(mixin, "freebsd", image_url=_FREEBSD_URL)
mixin._download_cloud_image.assert_not_called()
commands = [c.args[0] for c in mixin._run_node_command.call_args_list]
assert not any(c.startswith("xz -dc ") for c in commands)
def test_a_packed_image_never_goes_through_an_import_storage(self):
mixin, node, _ = _mixin()
node.storage.get.return_value = [
{"storage": "local-lvm", "content": "images,rootdir", "enabled": 1},
{"storage": "local", "content": "snippets,import", "enabled": 1},
]
mixin._run_node_command = MagicMock(
side_effect=lambda cmd, timeout: "MISSING" if "test -f" in cmd else ""
)
_create(mixin, "freebsd", image_url=_FREEBSD_URL)
# storage(name)("download-url").post(...) is the import-storage download.
node.storage.return_value.return_value.post.assert_not_called()
mixin._download_cloud_image.assert_called_once()
class TestLoopbackIsNeverTheVmsAddress:
"""OpenBSD's agent lists lo0 first (#793); Linux names it lo."""
def _status(self, interfaces: list) -> dict:
mixin = ProxmoxVMProvisionMixin()
node = MagicMock()
mixin._node_api = MagicMock(return_value=node)
node.qemu.return_value.config.get.return_value = {"net0": "virtio,bridge=vmbr0"}
node.qemu.return_value.agent.return_value.get.return_value = {"result": interfaces}
with patch("time.sleep"):
return mixin.get_vm_status("101", wait_for_ip=True, timeout=30, poll_interval=1)
def test_openbsd_answer(self):
"""The raw answer of OpenBSD 7.9's qemu-ga, from the spike."""
result = self._status(
[
{
"name": "lo0",
"ip-addresses": [
{"ip-address-type": "ipv6", "ip-address": "::1", "prefix": 128},
{"ip-address-type": "ipv6", "ip-address": "fe80:3::1", "prefix": 64},
{"ip-address-type": "ipv4", "ip-address": "127.0.0.1", "prefix": 8},
],
"hardware-address": "00:00:00:00:00:00",
},
{
"name": "vio0",
"ip-addresses": [
{"ip-address-type": "ipv4", "ip-address": "10.0.2.15", "prefix": 24}
],
"hardware-address": "bc:24:11:aa:bb:04",
},
{"name": "enc0", "hardware-address": "00:00:00:00:00:00"},
{"name": "pflog0", "hardware-address": "00:00:00:00:00:00"},
]
)
assert result["ip_address"] == "10.0.2.15"
assert result["mac_address"] == "bc:24:11:aa:bb:04"
def test_any_loopback_address_is_skipped_whatever_the_interface_is_called(self):
result = self._status(
[
{
"name": "lo1",
"ip-addresses": [{"ip-address-type": "ipv4", "ip-address": "127.0.0.2"}],
},
{
"name": "vtnet0",
"ip-addresses": [{"ip-address-type": "ipv4", "ip-address": "10.0.0.5"}],
},
]
)
assert result["ip_address"] == "10.0.0.5"
class TestDestroyRemovesEverySnippet:
def test_user_and_network_snippets_are_read_before_the_vm_is_deleted(self):
"""After the delete the config is gone, and the snippets stayed behind
(napalm-proxmox#15)."""
mixin = ProxmoxVMProvisionMixin()
node = MagicMock()
mixin._node_api = MagicMock(return_value=node)
vm = node.qemu.return_value
order: list[str] = []
vm.config.get.side_effect = lambda: (
order.append("config")
or {
"cicustom": "user=local:snippets/101-user-data.yaml,"
"network=local:snippets/101-network-config.yaml"
}
)
vm.delete.side_effect = lambda **kw: order.append("delete")
node.tasks.return_value.status.get.return_value = {"status": "stopped", "exitstatus": "OK"}
with patch("time.sleep"):
mixin.destroy_vm("101")
assert order.index("config") < order.index("delete")
deleted = [c.args[0] for c in node.storage.return_value.content.call_args_list]
assert deleted == ["snippets/101-user-data.yaml", "snippets/101-network-config.yaml"]
+16 -10
View File
@@ -3,12 +3,11 @@
from __future__ import annotations
import base64
from unittest.mock import MagicMock, patch
import pytest
from unittest.mock import MagicMock, patch
from napalm_proxmox.driver import ProxmoxDriver
from napalm_proxmox.vm_provision_mixin import ProxmoxVMProvisionMixin
from napalm_proxmox.vm_provision_mixin import ProxmoxVMProvisionMixin
# ---------------------------------------------------------------------------
# Tests
@@ -459,7 +458,13 @@ def test_destroy_vm_success():
def test_destroy_vm_already_stopped():
"""destroy_vm succeeds even if VM already stopped."""
"""destroy_vm succeeds even if VM already stopped.
Proxmox refuses to stop a VM that is not running, so the stop call raises.
(A bare MagicMock as the stop task never reports "stopped": _wait_for_task
then spun for the full timeout with sleep patched out, and every recorded
mock call made the test take 60 s and several GB, enough for CI to kill it.)
"""
mixin = ProxmoxVMProvisionMixin()
mock_node = MagicMock()
@@ -468,6 +473,7 @@ def test_destroy_vm_already_stopped():
# Mock VM operations
mock_vm = MagicMock()
mock_node.qemu.return_value = mock_vm
mock_vm.status.stop.post.side_effect = Exception("VM 101 not running")
mock_vm.config.get.return_value = {}
mock_vm.delete.return_value = None
@@ -609,8 +615,9 @@ def _mock_ssh_exec(exit_status: int, stdout_text: str = "", stderr_text: str = "
def test_run_node_command_success_returns_stdout():
mixin = ProxmoxVMProvisionMixin()
mixin._ssh_client = MagicMock()
mixin._ssh_client.exec_command.return_value = _mock_ssh_exec(0, stdout_text="hello\n")
ssh = MagicMock()
ssh.exec_command.return_value = _mock_ssh_exec(0, stdout_text="hello\n")
mixin._node_ssh = MagicMock(return_value=ssh) # the driver's verified session
result = mixin._run_node_command("echo hello", timeout=10)
@@ -619,10 +626,9 @@ def test_run_node_command_success_returns_stdout():
def test_run_node_command_nonzero_exit_raises():
mixin = ProxmoxVMProvisionMixin()
mixin._ssh_client = MagicMock()
mixin._ssh_client.exec_command.return_value = _mock_ssh_exec(
1, stderr_text="No such file or directory"
)
ssh = MagicMock()
ssh.exec_command.return_value = _mock_ssh_exec(1, stderr_text="No such file or directory")
mixin._node_ssh = MagicMock(return_value=ssh)
with pytest.raises(RuntimeError, match="No such file or directory"):
mixin._run_node_command("cat /nonexistent", timeout=10)