From 77e65ea7bda5037b3ca336ed36f264adc772423d Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Sun, 4 Oct 2026 12:02:48 +0200 Subject: [PATCH] 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. --- napalm_proxmox/vm_provision_mixin.py | 105 +++++++++++++++++- tests/test_vm_cpu_types.py | 153 +++++++++++++++++++++++++++ 2 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 tests/test_vm_cpu_types.py diff --git a/napalm_proxmox/vm_provision_mixin.py b/napalm_proxmox/vm_provision_mixin.py index 8967b80..d62889c 100644 --- a/napalm_proxmox/vm_provision_mixin.py +++ b/napalm_proxmox/vm_provision_mixin.py @@ -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 diff --git a/tests/test_vm_cpu_types.py b/tests/test_vm_cpu_types.py new file mode 100644 index 0000000..eaa498a --- /dev/null +++ b/tests/test_vm_cpu_types.py @@ -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() -- 2.54.0