diff --git a/napalm_proxmox/vm_provision_mixin.py b/napalm_proxmox/vm_provision_mixin.py index c7ce580..1962d45 100644 --- a/napalm_proxmox/vm_provision_mixin.py +++ b/napalm_proxmox/vm_provision_mixin.py @@ -313,8 +313,9 @@ class ProxmoxVMProvisionMixin: if not bridge: raise ValueError(f"NIC {i}: bridge is required") - # Build base config: model + bridge - net_config = f"virtio,bridge={bridge}" + # Build base config: model[=mac] + bridge + mac = nic.get("mac") + net_config = f"virtio={mac},bridge={bridge}" if mac else f"virtio,bridge={bridge}" # Add VLAN configuration (access vs trunk) if "trunk_vlan_tags" in nic and nic["trunk_vlan_tags"]: diff --git a/tests/test_vm_provision_mixin.py b/tests/test_vm_provision_mixin.py index a9d08d1..db942f2 100644 --- a/tests/test_vm_provision_mixin.py +++ b/tests/test_vm_provision_mixin.py @@ -987,3 +987,105 @@ def test_create_vm_writes_snippet_via_ssh_with_correct_content(): content = base64.b64decode(encoded).decode("utf-8") assert content.startswith("#cloud-config\n") assert "hostname: write-shape-vm" in content + + +# --------------------------------------------------------------------------- +# Explicit NIC MAC address +# --------------------------------------------------------------------------- + + +def test_create_vm_from_cloud_init_with_explicit_mac(): + """When nics[i]['mac'] is set, it's pinned via virtio=,bridge=... + (needed so a caller can create a matching DHCP reservation before the + VM even boots).""" + mixin = ProxmoxVMProvisionMixin() + mixin._node_name = "pve1" + + mock_api = MagicMock() + mock_api.cluster.nextid.get.return_value = 107 + mock_api.storage.return_value.get.return_value = {"path": "/var/lib/vz"} + + mock_node = MagicMock() + mock_node.storage.get.return_value = [ + {"storage": "local-lvm", "type": "lvmthin", "content": "images,rootdir", "enabled": 1}, + {"storage": "snippets", "type": "dir", "content": "snippets", "enabled": 1}, + ] + mixin._api = mock_api + mixin._node_api = MagicMock(return_value=mock_node) + mixin._download_cloud_image = MagicMock(return_value="/var/lib/vz/template/netork-images/debian-12.qcow2") + mixin._run_node_command = MagicMock(return_value="") + + mock_vm = MagicMock() + mock_node.qemu.return_value = mock_vm + mock_node.qemu.post.return_value = None + mock_vm.config.post.return_value = None + mock_vm.config.get.return_value = { + "unused0": "local-lvm:vm-107-disk-0", + "scsi0": "local-lvm:vm-107-disk-0", + } + mock_vm.status.start.post.return_value = "UPID:pve1:133:start" + + mock_task = MagicMock() + mock_task.status.get.return_value = {"status": "stopped", "exitstatus": "OK"} + mock_node.tasks.return_value = mock_task + + with patch("time.sleep"): + mixin.create_vm_from_cloud_init( + name="pinned-mac-vm", + image_url="https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2", + cpu=2, + memory=2048, + nics=[{"bridge": "vmbr0", "vlan_tag": 10, "mac": "02:aa:bb:cc:dd:ee"}], + cloud_init_config={"hostname": "pinned-mac-vm"}, + ) + + net_call_args = next(c for c in mock_vm.config.post.call_args_list if "net0" in c[1]) + assert net_call_args[1]["net0"] == "virtio=02:aa:bb:cc:dd:ee,bridge=vmbr0,tag=10" + + +def test_create_vm_from_cloud_init_without_mac_uses_bare_virtio(): + """Regression: omitting nics[i]['mac'] must still produce the original + bare 'virtio,bridge=...' string (auto-generated MAC), not 'virtio=None,...'.""" + mixin = ProxmoxVMProvisionMixin() + mixin._node_name = "pve1" + + mock_api = MagicMock() + mock_api.cluster.nextid.get.return_value = 108 + mock_api.storage.return_value.get.return_value = {"path": "/var/lib/vz"} + + mock_node = MagicMock() + mock_node.storage.get.return_value = [ + {"storage": "local-lvm", "type": "lvmthin", "content": "images,rootdir", "enabled": 1}, + {"storage": "snippets", "type": "dir", "content": "snippets", "enabled": 1}, + ] + mixin._api = mock_api + mixin._node_api = MagicMock(return_value=mock_node) + mixin._download_cloud_image = MagicMock(return_value="/var/lib/vz/template/netork-images/debian-12.qcow2") + mixin._run_node_command = MagicMock(return_value="") + + mock_vm = MagicMock() + mock_node.qemu.return_value = mock_vm + mock_node.qemu.post.return_value = None + mock_vm.config.post.return_value = None + mock_vm.config.get.return_value = { + "unused0": "local-lvm:vm-108-disk-0", + "scsi0": "local-lvm:vm-108-disk-0", + } + mock_vm.status.start.post.return_value = "UPID:pve1:134:start" + + mock_task = MagicMock() + mock_task.status.get.return_value = {"status": "stopped", "exitstatus": "OK"} + mock_node.tasks.return_value = mock_task + + with patch("time.sleep"): + mixin.create_vm_from_cloud_init( + name="auto-mac-vm", + image_url="https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2", + cpu=2, + memory=2048, + nics=[{"bridge": "vmbr0"}], + cloud_init_config={"hostname": "auto-mac-vm"}, + ) + + net_call_args = next(c for c in mock_vm.config.post.call_args_list if "net0" in c[1]) + assert net_call_args[1]["net0"] == "virtio,bridge=vmbr0"