Author SHA1 Message Date
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
6 changed files with 105 additions and 2 deletions
+48
View File
@@ -0,0 +1,48 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- name: Install package with dev extras
run: |
python -m pip install --upgrade pip
# napalm-device-types lives in git.netork.io/NAPALM, not on PyPI: without this
# pip looks there, finds an unrelated 0.1.0 and the job dies before any test.
python -m pip install "napalm-device-types @ git+https://git.netork.io/NAPALM/napalm-device-types.git"
python -m pip install -e ".[dev]"
- name: Run unit tests
run: |
python -m pytest -q --tb=short
- name: Build wheel and sdist
run: |
python -m pip install build
python -m build
- name: Upload dist artifacts
# v4 refuses to run on Gitea ("not currently supported on GHES").
uses: actions/upload-artifact@v3
with:
name: dist-${{ matrix.python-version }}
path: dist/*
+2
View File
@@ -39,6 +39,7 @@ from napalm_device_types import (
HostStatusMixin, HostStatusMixin,
HypervisorDriver, HypervisorDriver,
KernelFactsMixin, KernelFactsMixin,
ListeningSocketsMixin,
PortSpec, PortSpec,
SystemdServicesMixin, SystemdServicesMixin,
) )
@@ -84,6 +85,7 @@ class ProxmoxDriver(
ProxmoxRoutingMixin, ProxmoxRoutingMixin,
ProxmoxSystemMixin, ProxmoxSystemMixin,
KernelFactsMixin, KernelFactsMixin,
ListeningSocketsMixin,
SystemdServicesMixin, SystemdServicesMixin,
HostStatusMixin, HostStatusMixin,
HypervisorDriver, HypervisorDriver,
+9
View File
@@ -231,6 +231,15 @@ class ProxmoxSystemMixin:
"""The transport for ``KernelFactsMixin.get_kernel_facts``: the exec path.""" """The transport for ``KernelFactsMixin.get_kernel_facts``: the exec path."""
return self._exec_ssh_command(command) return self._exec_ssh_command(command)
# ------------------------------------------------------------------ #
# Listening sockets (ListeningSocketsMixin supplies get_listening_sockets)
# ------------------------------------------------------------------ #
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) # Packages (Debian APT)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
+1 -1
View File
@@ -25,7 +25,7 @@ classifiers = [
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"napalm>=5.0.0", "napalm>=5.0.0",
"napalm_device_types>=2.3.0", "napalm_device_types>=2.4.0",
"paramiko>=5.0.0", # CVE-2026-44405; imported directly for SSH fallback (driver.py) "paramiko>=5.0.0", # CVE-2026-44405; imported directly for SSH fallback (driver.py)
"proxmoxer>=2.0.0", "proxmoxer>=2.0.0",
"netaddr>=0.9.0", "netaddr>=0.9.0",
+37
View File
@@ -0,0 +1,37 @@
"""`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")
+8 -1
View File
@@ -459,7 +459,13 @@ def test_destroy_vm_success():
def test_destroy_vm_already_stopped(): 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() mixin = ProxmoxVMProvisionMixin()
mock_node = MagicMock() mock_node = MagicMock()
@@ -468,6 +474,7 @@ def test_destroy_vm_already_stopped():
# Mock VM operations # Mock VM operations
mock_vm = MagicMock() mock_vm = MagicMock()
mock_node.qemu.return_value = mock_vm 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.config.get.return_value = {}
mock_vm.delete.return_value = None mock_vm.delete.return_value = None