Author SHA1 Message Date
Christian Manivong 02a441ff09 feat: report where an update comes from and whether it is a security fix, refresh the index, read the host status
For netOrk MVP 5, on napalm-device-types 2.3.0:

- get_available_updates reads `apt list --upgradable` over the exec path
  (APT_UPGRADABLE_COMMAND), so each update carries its suite and security
  status; the APT API, which names only "Debian"/"Proxmox", is the fallback
  with security unknown. It raises when neither answers instead of returning
  [] -- it used to swallow every error.
- refresh_available_updates(): POST nodes/{n}/apt/update.
- HostStatusMixin over the exec path (reboot required, self-patching).
2026-10-06 00:20:08 +02:00
christianmanivong 1881ee7330 Merge pull request 'fix: report which services are enabled, and whether an action worked' (#8) from feat/systemd-services-mixin into master 2026-10-05 11:12:15 +00:00
Christian Manivong 6bf1736619 fix: report which services are enabled, and whether an action worked
get_services() read list-unit-files' second column, which since systemd 245
is followed by a preset column -- "enabled  enabled" never equalled
"enabled", so every service on every node was reported disabled (#6).
manage_service() ended in "|| true" and returned success whatever happened.

Both now come from napalm-device-types' SystemdServicesMixin (2.2.0): the
enabled state is UnitFileState from systemctl show, MainPID comes along in
the same round trip, and an action reports systemctl's exit status. The
driver keeps only _run_service_command(), its exec path as root. The exit
status marker keeps the output non-empty, so _exec_ssh_command's SSH fallback
on an empty API answer can no longer run an action twice.

The listing no longer includes templates, static unit files and aliases that
are not loaded (on a PVE 9 node: 280 entries -> 172).

Closes #6
2026-10-05 13:12:14 +02:00
christianmanivong ea74e84387 Merge pull request 'feat: report the node kernel's modules and build configuration' (#7) from feat/kernel-facts into master 2026-10-05 04:36:48 +00:00
Christian Manivong 52074620ef feat: report the node kernel's modules and build configuration
A Proxmox node runs its own kernel under every guest, which makes it the host
where a kernel CVE's preconditions matter most. ProxmoxDriver mixes in
KernelFactsMixin from napalm-device-types and supplies only the transport,
the existing exec path.

Requires napalm-device-types 2.1.0.
2026-10-05 06:17:30 +02:00
christianmanivong ccb9585f4e Merge pull request 'feat(vm-provision): name a CPU model for every new VM, and list the choices' (#5) from feat/vm-cpu-type into master 2026-10-04 15:42:16 +00:00
Christian Manivong 77e65ea7bd feat(vm-provision): name a CPU model for every new VM, and list the choices
A VM created without a cpu argument gets Proxmox's API default, kvm64: no
AES-NI and no AVX. MongoDB 5.0 and later exit with "Illegal instruction" on
it, which is how a Graylog provisioned through netOrk failed (netork#494).
Every VM built by hand in the same cluster uses host or x86-64-v2-AES; only
the ones this driver created were left on kvm64.

create_vm_from_cloud_init now always passes cpu=, defaulting to x86-64-v2-AES
as the Proxmox GUI does since PVE 8, and takes cpu_type for a different one.

get_vm_cpu_types() lists x86-64-v2-AES (default), x86-64-v3 and host. The
named models carry the cpuinfo flags they add over qemu64, following
Proxmox's own definitions (CPUConfig.pm), and are available when the node's
CPU has all of them, read from /nodes/{node}/status. host lists the node's
own flags, so on a CPU without AVX it does not pretend to offer any.

A model asked for by name is checked against the node before a VMID is
allocated: one the CPU cannot run would only fail at VM start, after the disk
import, leaving a half-built VM behind. The default is not checked, so a
plain create costs no extra API call. VMCpuTypeDict is imported for type
checking only, so this works with an older napalm_device_types.
2026-10-04 12:02:48 +02:00
8 changed files with 556 additions and 86 deletions
+11 -1
View File
@@ -34,7 +34,14 @@ from typing import Any
logger = logging.getLogger(__name__)
from napalm_device_types import FingerprintRule, HypervisorDriver, PortSpec
from napalm_device_types import (
FingerprintRule,
HostStatusMixin,
HypervisorDriver,
KernelFactsMixin,
PortSpec,
SystemdServicesMixin,
)
from napalm.base.exceptions import ConnectionException
try:
@@ -76,6 +83,9 @@ class ProxmoxDriver(
ProxmoxVMProvisionMixin,
ProxmoxRoutingMixin,
ProxmoxSystemMixin,
KernelFactsMixin,
SystemdServicesMixin,
HostStatusMixin,
HypervisorDriver,
):
"""NAPALM driver for Proxmox VE nodes."""
+54 -82
View File
@@ -20,6 +20,8 @@ import logging
import re
from typing import Any
from napalm_device_types import APT_UPGRADABLE_COMMAND, parse_apt_upgradable
from napalm_proxmox import utils
logger = logging.getLogger(__name__)
@@ -221,6 +223,14 @@ class ProxmoxSystemMixin:
return result
# ------------------------------------------------------------------ #
# Kernel facts (KernelFactsMixin supplies get_kernel_facts)
# ------------------------------------------------------------------ #
def _run_kernel_facts_command(self, command: str) -> str:
"""The transport for ``KernelFactsMixin.get_kernel_facts``: the exec path."""
return self._exec_ssh_command(command)
# ------------------------------------------------------------------ #
# Packages (Debian APT)
# ------------------------------------------------------------------ #
@@ -338,101 +348,63 @@ class ProxmoxSystemMixin:
return warnings
# ------------------------------------------------------------------ #
# Services (systemd)
# Services (systemd, through napalm-device-types' SystemdServicesMixin)
# ------------------------------------------------------------------ #
def get_services(self) -> list[_JsonDict]:
"""Return systemd services with running and enabled state.
def _run_service_command(self, command: str, *, privileged: bool, timeout: int) -> str:
"""The transport for ``SystemdServicesMixin``: the exec path, as root.
Uses two ``systemctl`` invocations combined in a single SSH command:
- ``list-unit-files`` for the static enabled/disabled state
- ``list-units`` for the live running state
*privileged* needs nothing more on a node the driver reaches as root, and
the exec path keeps its own timeout.
"""
raw = self._exec_ssh_command(
"{ systemctl list-unit-files --type=service --no-pager --no-legend --full 2>/dev/null;"
" echo '---UNITS---';"
" systemctl list-units --type=service --all --no-pager --no-legend --full 2>/dev/null;"
" } || true"
)
# Parse enabled state from list-unit-files
enabled_map: dict[str, bool] = {}
section = "files"
for line in raw.splitlines():
if line.strip() == "---UNITS---":
section = "units"
continue
parts = line.strip().split(None, 1)
if len(parts) < 1:
continue
unit = parts[0].lstrip("\u25cf").strip()
if not unit.endswith(".service"):
continue
name = unit[: -len(".service")]
if section == "files":
state = parts[1].strip() if len(parts) > 1 else ""
enabled_map[name] = state in ("enabled", "enabled-runtime", "static")
# Parse running state from list-units
running_map: dict[str, bool] = {}
section = "files"
for line in raw.splitlines():
if line.strip() == "---UNITS---":
section = "units"
continue
if section != "units":
continue
parts = line.strip().lstrip("\u25cf").strip().split(None, 4)
if len(parts) < 4:
continue
unit = parts[0]
if not unit.endswith(".service"):
continue
name = unit[: -len(".service")]
sub_state = parts[3]
running_map[name] = sub_state == "running"
all_names = sorted(set(enabled_map) | set(running_map))
return [
{
"name": name,
"running": running_map.get(name, False),
"enabled": enabled_map.get(name, False),
"pid": 0,
}
for name in all_names
]
def manage_service(self, name: str, action: str) -> _JsonDict:
"""Start / stop / restart / enable / disable a systemd service."""
if not re.match(r'^[a-zA-Z0-9_\-\.@]+$', name):
raise ValueError(f"Invalid service name: {name!r}")
if action not in ('start', 'stop', 'restart', 'enable', 'disable'):
raise ValueError(f"Invalid action: {action!r}")
output = self._exec_ssh_command(f"systemctl {action} {name}.service 2>&1 || true")
return {"success": True, "output": output}
return str(self._exec_ssh_command(command))
# ------------------------------------------------------------------ #
# Available updates
# ------------------------------------------------------------------ #
def get_available_updates(self) -> list[_JsonDict]:
"""Return list of upgradable packages from the Proxmox APT API."""
updates: list[_JsonDict] = []
"""Return the node's upgradable packages, with origin and security status.
``apt list --upgradable`` over the exec path names each candidate's suite
(``trixie-security``); the APT API names only an Origin ("Debian",
"Proxmox") and is the fallback, with the security status unknown.
:raises Exception: when neither answers -- never an empty list for
"could not read".
"""
try:
for upd in self._api.nodes(self._node_name).apt.update.get():
pkg = upd.get("Package", "")
if not pkg:
continue
updates.append({
"name": pkg,
"current_version": upd.get("OldVersion", ""),
"new_version": upd.get("Version", ""),
})
except Exception as exc:
logger.debug("Failed to fetch available updates: %s", exc)
updates = parse_apt_upgradable(self._exec_ssh_command(APT_UPGRADABLE_COMMAND) or "")
except ValueError as exc:
logger.debug("apt list over the exec path failed, using the API: %s", exc)
updates = self._updates_from_api()
return sorted(updates, key=lambda u: u["name"])
def _updates_from_api(self) -> list[_JsonDict]:
return [
{
"name": upd["Package"],
"current_version": upd.get("OldVersion", ""),
"new_version": upd.get("Version", ""),
"origin": upd.get("Origin"),
"security": None,
}
for upd in self._api.nodes(self._node_name).apt.update.get() # type: ignore[union-attr]
if upd.get("Package")
]
def refresh_available_updates(self) -> _JsonDict:
"""Resynchronise the node's package index (``POST nodes/{n}/apt/update``)."""
try:
task = self._api.nodes(self._node_name).apt.update.post() # type: ignore[union-attr]
except Exception as exc:
return {"success": False, "output": str(exc)}
return {"success": True, "output": f"Package index refresh started ({task})"}
def _run_host_status_command(self, command: str) -> str:
"""The transport for ``HostStatusMixin.get_host_status``: the exec path."""
return str(self._exec_ssh_command(command))
def apply_updates(self, packages: list[str]) -> _JsonDict:
"""Upgrade the given packages via ``apt-get install`` over SSH."""
for pkg in packages:
+103 -2
View File
@@ -8,7 +8,7 @@ import logging
import re
import time
import yaml
from typing import Any, Dict, List
from typing import TYPE_CHECKING, Any, Dict, List
from urllib.parse import quote
from napalm_device_types.models import (
@@ -18,8 +18,53 @@ from napalm_device_types.models import (
VMStatusDict,
)
if TYPE_CHECKING:
# Type-only: VMCpuTypeDict is newer than the napalm_device_types floor in
# pyproject.toml, and nothing here needs it at runtime.
from napalm_device_types.models import VMCpuTypeDict
_logger = logging.getLogger(__name__)
# The CPU models a new VM may be given. Each lists the /proc/cpuinfo flags it
# adds on top of QEMU's qemu64 baseline: what the node's CPU must have for the
# model to start at all, and what a guest can count on. The sets follow
# Proxmox's own x86-64-v* definitions (qemu-server, PVE/QemuServer/CPUConfig.pm).
#
# Leaving the model out of qemu.post is not neutral: Proxmox then falls back to
# kvm64, which lacks even AES-NI, let alone the AVX MongoDB 5.0+ needs
# (netOrk#494). The default below is what the Proxmox GUI picks since PVE 8.
_X86_64_V2_AES_FLAGS = ("aes", "popcnt", "pni", "sse4_1", "sse4_2", "ssse3")
_X86_64_V3_FLAGS = _X86_64_V2_AES_FLAGS + (
"avx",
"avx2",
"bmi1",
"bmi2",
"f16c",
"fma",
"abm",
"movbe",
"xsave",
)
_DEFAULT_CPU_TYPE = "x86-64-v2-AES"
_CPU_MODELS = (
(
"x86-64-v2-AES",
_X86_64_V2_AES_FLAGS,
"Proxmox's own default: runs on practically any x86-64 server CPU and "
"can live-migrate between different ones. No AVX.",
),
(
"x86-64-v3",
_X86_64_V3_FLAGS,
"Adds AVX and AVX2 (which MongoDB 5.0 and later need). Every node the VM "
"may run on needs an Intel Haswell or AMD Excavator CPU (2013) or newer.",
),
)
_HOST_CPU_DESCRIPTION = (
"This node's CPU, passed through unchanged: fastest, with every feature it "
"has, but the VM can only live-migrate to nodes with the same CPU."
)
# Downloaded cloud images are cached here on the hypervisor node, keyed by
# filename, so provisioning multiple VMs from the same image only pays the
# download cost once.
@@ -351,6 +396,56 @@ class ProxmoxVMProvisionMixin:
)
return targets
def get_vm_cpu_types(self) -> list[VMCpuTypeDict]:
"""List the CPU models a new VM may be given, judged against this node's CPU."""
return self._cpu_types_for(self._node_cpu_flags())
def _node_cpu_flags(self) -> set[str]:
status = self._node_api().status.get() or {}
return set(str((status.get("cpuinfo") or {}).get("flags", "")).split())
@staticmethod
def _cpu_types_for(node_flags: set[str]) -> list[VMCpuTypeDict]:
types: list[VMCpuTypeDict] = [
{
"name": name,
"description": description,
"features": list(flags),
"available": set(flags) <= node_flags,
"default": name == _DEFAULT_CPU_TYPE,
}
for name, flags, description in _CPU_MODELS
]
types.append(
{
"name": "host",
"description": _HOST_CPU_DESCRIPTION,
"features": sorted(node_flags),
"available": True,
"default": False,
}
)
return types
def _resolve_cpu_type(self, cpu_type: str | None) -> str:
"""The model to create the VM with. A model asked for by name is checked
against this node's CPU first: one it cannot run would fail only at VM
start, after the disk import, leaving a half-built VM behind."""
if cpu_type is None:
return _DEFAULT_CPU_TYPE
node_flags = self._node_cpu_flags()
offered = {t["name"]: t for t in self._cpu_types_for(node_flags)}
entry = offered.get(cpu_type)
if entry is None:
raise ValueError(f"Unknown CPU type {cpu_type!r}; choose one of {', '.join(offered)}")
if not entry["available"]:
missing = sorted(set(entry["features"]) - node_flags)
raise ValueError(
f"CPU type {cpu_type!r} needs {', '.join(missing)}, which the CPU of "
f"node {self._node_name} does not have"
)
return cpu_type
def _wait_for_task(self, upid: str, timeout: int = 120) -> None:
"""
Poll a Proxmox task until completion.
@@ -395,6 +490,7 @@ class ProxmoxVMProvisionMixin:
ssh_public_keys: List[str] | None = None,
disk_resize_gb: int | None = None,
storage: str | None = None,
cpu_type: str | None = None,
download_timeout: int = 300,
timeout: int = 180,
) -> VMProvisionResultDict:
@@ -427,6 +523,7 @@ class ProxmoxVMProvisionMixin:
disk_resize_gb: resize root disk to this size (None = no resize)
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)
download_timeout: max seconds for the image download (skipped if cached)
timeout: max seconds for the remaining provisioning steps
@@ -435,10 +532,13 @@ class ProxmoxVMProvisionMixin:
Raises:
RuntimeError: provisioning failure (download, import, config, timeout, etc.)
ValueError: invalid storage or configuration
ValueError: invalid storage or configuration, or a cpu_type that is
unknown or that this node's CPU cannot run (raised before
anything is created)
"""
try:
_logger.info(f"Creating VM '{name}' from image {image_url}")
cpu_model = self._resolve_cpu_type(cpu_type)
# Step 1: Get next VMID
next_vmid = self._api.cluster.nextid.get()
@@ -452,6 +552,7 @@ class ProxmoxVMProvisionMixin:
name=name,
memory=memory,
cores=cpu,
cpu=cpu_model,
ostype="l26",
scsihw="virtio-scsi-pci",
# Without this, Proxmox never attaches the virtio-serial
+1 -1
View File
@@ -25,7 +25,7 @@ classifiers = [
requires-python = ">=3.9"
dependencies = [
"napalm>=5.0.0",
"napalm_device_types>=2.0.0",
"napalm_device_types>=2.3.0",
"paramiko>=5.0.0", # CVE-2026-44405; imported directly for SSH fallback (driver.py)
"proxmoxer>=2.0.0",
"netaddr>=0.9.0",
+46
View File
@@ -0,0 +1,46 @@
"""`get_kernel_facts`: what the node's kernel has built and loaded.
A Proxmox node runs its own kernel under every guest, which makes it the host
where a kernel CVE's preconditions matter most. The command and its parse are
napalm-device-types'; the driver only carries the command over its exec path.
"""
from __future__ import annotations
import base64
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 = (
"[release]\n6.8.12-4-pve\n[loaded]\nkvm_intel\n[builtin]\nkernel/net/ipv4/tcp_cubic.ko\n"
"[available]\nkernel/net/tipc/tipc.ko\n[config]\nCONFIG_TIPC=m\n"
)
WIRE = "KFACTS_BEGIN\n" + base64.encodebytes(gzip.compress(REPORT.encode())).decode() + "KFACTS_END"
def test_the_driver_declares_the_contract():
assert issubclass(ProxmoxDriver, KernelFactsMixin)
def test_it_runs_the_shared_command(driver):
with patch.object(driver, "_exec_ssh_command", return_value=WIRE) as exec_:
facts = driver.get_kernel_facts()
exec_.assert_called_once_with(KERNEL_FACTS_COMMAND)
assert facts["release"] == "6.8.12-4-pve"
assert facts["loaded"] == ["kvm_intel"]
assert facts["builtin"] == ["tcp_cubic"]
assert facts["available"] == ["tipc"]
assert facts["config"] == {"CONFIG_TIPC": "m"}
def test_output_without_a_report_raises(driver):
with patch.object(driver, "_exec_ssh_command", return_value=""):
with pytest.raises(ValueError):
driver.get_kernel_facts()
+68
View File
@@ -0,0 +1,68 @@
"""Services on a Proxmox node: systemd, through napalm-device-types' mixin.
The listing used to read ``list-unit-files``' second column, which since
systemd 245 is followed by a preset column -- so ``enabled`` was false for
every service on every node (#6). An action ended in ``|| true`` and reported
success whatever happened. Both now come from the shared mixin; the driver
only carries the command over its exec path, as root.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from napalm_device_types import SystemdServicesMixin
from napalm_device_types.systemd import SYSTEMD_SERVICES_COMMAND, service_action_command
from napalm_proxmox.driver import ProxmoxDriver
REPORT = (
"SVC_BEGIN\n[files]\npveproxy.service enabled enabled\n[units]\n"
"MainPID=1234\nId=pveproxy.service\nNames=pveproxy.service\nLoadState=loaded\n"
"ActiveState=active\nSubState=running\nUnitFileState=enabled\n"
"[generated]\nSVC_END\n"
)
def test_the_driver_uses_the_shared_mixin():
assert issubclass(ProxmoxDriver, SystemdServicesMixin)
assert ProxmoxDriver.get_services is SystemdServicesMixin.get_services
assert ProxmoxDriver.manage_service is SystemdServicesMixin.manage_service
def test_listing_runs_the_shared_command(driver):
with patch.object(driver, "_exec_ssh_command", return_value=REPORT) as exec_:
services = driver.get_services()
exec_.assert_called_once_with(SYSTEMD_SERVICES_COMMAND)
assert services == [{"name": "pveproxy", "running": True, "enabled": True, "pid": 1234}]
def test_an_unreadable_listing_raises_instead_of_reporting_no_services(driver):
with patch.object(driver, "_exec_ssh_command", return_value=""):
with pytest.raises(ValueError):
driver.get_services()
def test_an_action_reports_its_real_outcome(driver):
failed = "Failed to restart nope.service: Unit nope.service not found.\n__SVC_RC=5"
with patch.object(driver, "_exec_ssh_command", return_value=failed) as exec_:
result = driver.manage_service("nope", "restart")
exec_.assert_called_once_with(service_action_command("nope", "restart"))
assert result["success"] is False
assert "not found" in result["output"]
def test_a_successful_action(driver):
with patch.object(driver, "_exec_ssh_command", return_value="__SVC_RC=0"):
assert driver.manage_service("pveproxy", "restart") == {"success": True, "output": ""}
def test_an_invalid_name_never_reaches_the_node(driver):
with patch.object(driver, "_exec_ssh_command") as exec_:
with pytest.raises(ValueError):
driver.manage_service("pveproxy; reboot", "stop")
exec_.assert_not_called()
+120
View File
@@ -0,0 +1,120 @@
"""Pending updates on a Proxmox node: from where, whether they are security fixes,
and whether the node needs a reboot.
The node's APT API names only an Origin ("Debian", "Proxmox"), the same for the
main and the security archive. ``apt list --upgradable`` over the exec path
names the suite (``trixie-security``), so that is read first; the API remains
the fallback, with the security status left unknown. A reader that cannot read
raises: an empty list would tell netOrk that nothing is pending.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from napalm_device_types import HostStatusMixin
from napalm_device_types.host_status import HOST_STATUS_COMMAND
from napalm_device_types.package_updates import APT_UPGRADABLE_COMMAND
from napalm_proxmox.driver import ProxmoxDriver
APT = (
"libssl3t64/stable-security 3.5.1-1+deb13u2 amd64 [upgradable from: 3.5.1-1+deb13u1]\n"
"ceph-common/stable 20.2.4-pve5 amd64 [upgradable from: 20.2.4-pve4]\n"
)
API_ENTRY = {
"Package": "librados2",
"OldVersion": "20.2.4-pve4",
"Version": "20.2.4-pve5",
"Origin": "Proxmox",
}
def _api_updates(driver, entries=None, error=None):
api = MagicMock()
getter = api.nodes.return_value.apt.update.get
if error:
getter.side_effect = error
else:
getter.return_value = entries or []
driver._api = api
return api
class TestAvailableUpdates:
def test_apt_over_the_exec_path_names_the_suite(self, driver):
with patch.object(driver, "_exec_ssh_command", return_value=APT + "__APT_RC=0\n") as exec_:
updates = {u["name"]: u for u in driver.get_available_updates()}
exec_.assert_called_once_with(APT_UPGRADABLE_COMMAND)
assert updates["libssl3t64"]["security"] is True
assert updates["ceph-common"]["security"] is False
assert updates["ceph-common"]["origin"] == "stable"
def test_the_api_is_the_fallback_with_security_unknown(self, driver):
_api_updates(driver, [API_ENTRY])
with patch.object(driver, "_exec_ssh_command", return_value=""):
updates = driver.get_available_updates()
assert updates == [
{
"name": "librados2",
"current_version": "20.2.4-pve4",
"new_version": "20.2.4-pve5",
"origin": "Proxmox",
"security": None,
}
]
def test_a_failed_apt_falls_back_too(self, driver):
_api_updates(driver, [API_ENTRY])
with patch.object(driver, "_exec_ssh_command", return_value="E: lock\n__APT_RC=100\n"):
assert [u["name"] for u in driver.get_available_updates()] == ["librados2"]
def test_nothing_readable_raises_instead_of_reporting_nothing(self, driver):
_api_updates(driver, error=RuntimeError("API timeout"))
with patch.object(driver, "_exec_ssh_command", return_value=""):
with pytest.raises(RuntimeError):
driver.get_available_updates()
def test_nothing_pending_is_an_empty_list(self, driver):
with patch.object(driver, "_exec_ssh_command", return_value="__APT_RC=0\n"):
assert driver.get_available_updates() == []
class TestRefresh:
def test_the_node_refreshes_its_index_through_the_api(self, driver):
api = _api_updates(driver)
api.nodes.return_value.apt.update.post.return_value = "UPID:pve1:0001"
result = driver.refresh_available_updates()
assert result["success"] is True
api.nodes.return_value.apt.update.post.assert_called_once()
def test_a_refused_refresh_says_why(self, driver):
api = _api_updates(driver)
api.nodes.return_value.apt.update.post.side_effect = RuntimeError(
"403 Permission check failed"
)
result = driver.refresh_available_updates()
assert result == {"success": False, "output": "403 Permission check failed"}
class TestHostStatus:
def test_the_node_is_read_over_the_exec_path(self, driver):
report = (
"HSTAT_BEGIN\n[kernel]\n7.0.14-19-pve\n[modules]\n7.0.14-19-pve\n7.0.2-6-pve\n"
"[timers]\napt-daily-upgrade.timer enabled\nHSTAT_END\n"
)
with patch.object(driver, "_exec_ssh_command", return_value=report) as exec_:
status = driver.get_host_status()
exec_.assert_called_once_with(HOST_STATUS_COMMAND)
assert status["reboot_required"] is False
def test_the_driver_declares_the_contract(self):
assert issubclass(ProxmoxDriver, HostStatusMixin)
+153
View File
@@ -0,0 +1,153 @@
"""Which virtual CPU model a new VM gets.
Created without a ``cpu`` argument, a Proxmox VM falls back to ``kvm64``:
no AVX, no AES-NI. MongoDB 5.0 and later will not even start on it, which is
how a Graylog provisioned through netOrk failed (netOrk#494). The driver now
always names a model, defaulting to ``x86-64-v2-AES`` as the Proxmox GUI does,
and lists the alternatives with what each needs from the node's CPU.
The flag sets below are trimmed from real ``/nodes/{node}/status`` answers in a
mixed cluster: a Celeron J3455 (no AVX at all) and an i7-7700 (AVX2).
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from napalm_proxmox.vm_provision_mixin import ProxmoxVMProvisionMixin
CELERON_J3455 = (
"fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush "
"mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc pni pclmulqdq "
"ssse3 cx16 sse4_1 sse4_2 x2apic movbe popcnt aes rdrand lahf_lm 3dnowprefetch "
"erms mpx rdseed smap clflushopt sha_ni xsaveopt xsavec xgetbv1"
)
CORE_I7_7700 = (
"fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush "
"mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc pni pclmulqdq "
"ssse3 fma cx16 sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand "
"lahf_lm abm 3dnowprefetch fsgsbase bmi1 hle avx2 smep bmi2 erms invpcid rtm mpx "
"rdseed adx smap clflushopt xsaveopt xsavec xgetbv1 xsaves"
)
def _mixin_on(flags: str) -> tuple[ProxmoxVMProvisionMixin, MagicMock, MagicMock]:
"""A mixin whose node reports `flags` and that can run a full create."""
mixin = ProxmoxVMProvisionMixin()
mixin._node_name = "pve1"
api = MagicMock()
api.cluster.nextid.get.return_value = 120
api.storage.return_value.get.return_value = {"path": "/var/lib/vz"}
node = MagicMock()
node.status.get.return_value = {"cpuinfo": {"model": "test", "flags": flags}}
node.storage.get.return_value = [
{"storage": "local-lvm", "type": "lvmthin", "content": "images,rootdir", "enabled": 1},
{"storage": "snippets", "type": "dir", "content": "snippets", "enabled": 1},
]
vm = MagicMock()
vm.config.get.return_value = {
"unused0": "local-lvm:vm-120-disk-0",
"scsi0": "local-lvm:vm-120-disk-0",
}
vm.status.start.post.return_value = "UPID:pve1:1:start"
node.qemu.return_value = vm
task = MagicMock()
task.status.get.return_value = {"status": "stopped", "exitstatus": "OK"}
node.tasks.return_value = task
mixin._api = api
mixin._node_api = MagicMock(return_value=node)
mixin._download_cloud_image = MagicMock(return_value="/var/lib/vz/template/x.qcow2")
mixin._run_node_command = MagicMock(return_value="")
return mixin, api, node
def _create(mixin: ProxmoxVMProvisionMixin, **kwargs):
with patch("time.sleep"):
return mixin.create_vm_from_cloud_init(
name="graylog-01",
image_url="https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
cpu=2,
memory=4096,
nics=[{"bridge": "vmbr0"}],
cloud_init_config={"hostname": "graylog-01"},
**kwargs,
)
def _by_name(types):
return {t["name"]: t for t in types}
class TestListing:
def test_offers_the_three_models_in_order(self):
mixin, _, _ = _mixin_on(CORE_I7_7700)
assert [t["name"] for t in mixin.get_vm_cpu_types()] == [
"x86-64-v2-AES",
"x86-64-v3",
"host",
]
def test_exactly_one_default_and_it_is_what_the_gui_uses(self):
mixin, _, _ = _mixin_on(CORE_I7_7700)
defaults = [t["name"] for t in mixin.get_vm_cpu_types() if t["default"]]
assert defaults == ["x86-64-v2-AES"]
def test_v3_gives_avx_and_runs_on_a_core_i7(self):
mixin, _, _ = _mixin_on(CORE_I7_7700)
v3 = _by_name(mixin.get_vm_cpu_types())["x86-64-v3"]
assert v3["available"] is True
assert {"avx", "avx2"} <= set(v3["features"])
def test_v3_is_unavailable_on_a_celeron_without_avx(self):
mixin, _, _ = _mixin_on(CELERON_J3455)
types = _by_name(mixin.get_vm_cpu_types())
assert types["x86-64-v3"]["available"] is False
assert types["x86-64-v2-AES"]["available"] is True
def test_v2_aes_has_no_avx(self):
mixin, _, _ = _mixin_on(CORE_I7_7700)
assert "avx" not in _by_name(mixin.get_vm_cpu_types())["x86-64-v2-AES"]["features"]
def test_host_passes_the_nodes_own_flags_through(self):
"""On a CPU without AVX, `host` gives none either -- a role that needs
AVX must not be told `host` would help there."""
mixin, _, _ = _mixin_on(CELERON_J3455)
host = _by_name(mixin.get_vm_cpu_types())["host"]
assert host["available"] is True
assert "avx" not in host["features"]
assert "aes" in host["features"]
def test_every_entry_explains_itself(self):
mixin, _, _ = _mixin_on(CORE_I7_7700)
assert all(t["description"] for t in mixin.get_vm_cpu_types())
class TestCreate:
def test_names_the_default_model_instead_of_leaving_kvm64(self):
mixin, _, node = _mixin_on(CORE_I7_7700)
_create(mixin)
assert node.qemu.post.call_args[1]["cpu"] == "x86-64-v2-AES"
def test_passes_a_chosen_model_through(self):
mixin, _, node = _mixin_on(CORE_I7_7700)
_create(mixin, cpu_type="host")
assert node.qemu.post.call_args[1]["cpu"] == "host"
def test_refuses_a_model_the_node_cannot_run_before_creating_anything(self):
mixin, api, node = _mixin_on(CELERON_J3455)
with pytest.raises(ValueError, match="avx"):
_create(mixin, cpu_type="x86-64-v3")
api.cluster.nextid.get.assert_not_called()
node.qemu.post.assert_not_called()
def test_refuses_an_unknown_model_before_creating_anything(self):
mixin, api, node = _mixin_on(CORE_I7_7700)
with pytest.raises(ValueError, match="kvm64"):
_create(mixin, cpu_type="kvm64")
api.cluster.nextid.get.assert_not_called()
node.qemu.post.assert_not_called()