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.
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user