diff --git a/napalm_proxmox/vm_provision_mixin.py b/napalm_proxmox/vm_provision_mixin.py index 1962d45..21b9b22 100644 --- a/napalm_proxmox/vm_provision_mixin.py +++ b/napalm_proxmox/vm_provision_mixin.py @@ -375,7 +375,13 @@ class ProxmoxVMProvisionMixin: _logger.info(f"Setting Cloud-Init config for VM {vmid}") cloud_init_args = { - "ide2": f"{snippet_storage}:cloudinit", + # The cloud-init drive is a disk image — it needs a storage + # with content='images' (same requirement as the root disk), + # NOT the snippet storage (content='snippets'). These are + # often different storages; Proxmox fails at VM start with + # "storage 'X' does not support content-type 'images'" if + # this points at a snippets-only storage. + "ide2": f"{image_storage}:cloudinit", "citype": "nocloud", "cicustom": f"user={snippet_storage}:snippets/{filename}", } @@ -546,9 +552,7 @@ class ProxmoxVMProvisionMixin: while True: elapsed = time.time() - start_time if wait_for_ip and elapsed > timeout: - raise RuntimeError( - f"VM {vmid} failed to acquire IP within {timeout}s" - ) + raise RuntimeError(f"VM {vmid} failed to acquire IP within {timeout}s") try: # Query guest-agent network interfaces diff --git a/tests/test_vm_provision_mixin.py b/tests/test_vm_provision_mixin.py index db942f2..fe995f4 100644 --- a/tests/test_vm_provision_mixin.py +++ b/tests/test_vm_provision_mixin.py @@ -85,7 +85,9 @@ def test_create_vm_from_cloud_init_single_nic(): ] 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._download_cloud_image = MagicMock( + return_value="/var/lib/vz/template/netork-images/debian-12.qcow2" + ) mixin._run_node_command = MagicMock(return_value="") # Mock VM operations @@ -122,9 +124,7 @@ def test_create_vm_from_cloud_init_single_nic(): mixin._download_cloud_image.assert_called_once() # Verify NIC config was set correctly: net0 with tag=10, DHCP enabled - net_call_args = next( - c for c in mock_vm.config.post.call_args_list if "net0" in c[1] - ) + net_call_args = next(c for c in mock_vm.config.post.call_args_list if "net0" in c[1]) assert "tag=10" in net_call_args[1]["net0"] assert "vmbr0" in net_call_args[1]["net0"] @@ -134,7 +134,9 @@ def test_create_vm_from_cloud_init_single_nic(): # test_create_vm_writes_snippet_via_ssh_with_correct_content for the # dedicated check). write_cmd = next( - c[0][0] for c in mixin._run_node_command.call_args_list if "snippets/101-user-data.yaml" in c[0][0] + c[0][0] + for c in mixin._run_node_command.call_args_list + if "snippets/101-user-data.yaml" in c[0][0] ) assert "/var/lib/vz/snippets" in write_cmd @@ -156,7 +158,9 @@ def test_create_vm_from_cloud_init_dual_nic_trunk(): ] 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._download_cloud_image = MagicMock( + return_value="/var/lib/vz/template/netork-images/debian-12.qcow2" + ) mixin._run_node_command = MagicMock(return_value="") # Mock VM operations @@ -183,7 +187,11 @@ def test_create_vm_from_cloud_init_dual_nic_trunk(): memory=4096, nics=[ {"bridge": "vmbr0", "vlan_tag": 10}, # net0: mgmt with DHCP - {"bridge": "vmbr1", "trunk_vlan_tags": [20, 30], "dhcp": False}, # net1: trunk, no DHCP + { + "bridge": "vmbr1", + "trunk_vlan_tags": [20, 30], + "dhcp": False, + }, # net1: trunk, no DHCP ], cloud_init_config={"hostname": "sat-1", "runcmd": ["custom cmd"]}, timeout=120, @@ -193,9 +201,7 @@ def test_create_vm_from_cloud_init_dual_nic_trunk(): assert result["name"] == "wireshark-sat-1" # Verify both NICs configured - net_call_args = next( - c for c in mock_vm.config.post.call_args_list if "net0" in c[1] - ) + net_call_args = next(c for c in mock_vm.config.post.call_args_list if "net0" in c[1]) assert "net1" in net_call_args[1] assert "tag=10" in net_call_args[1]["net0"] assert "trunks=20;30" in net_call_args[1]["net1"] @@ -225,7 +231,9 @@ def test_create_vm_missing_snippet_storage(): {"storage": "local-lvm", "type": "lvmthin", "content": "images,rootdir", "enabled": 1} ] 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._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 @@ -267,7 +275,9 @@ def test_create_vm_with_disk_resize(): ] 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._download_cloud_image = MagicMock( + return_value="/var/lib/vz/template/netork-images/debian-12.qcow2" + ) mixin._run_node_command = MagicMock(return_value="") # Mock VM operations @@ -469,9 +479,7 @@ def test_get_network_targets_linux_bridge_vlan_aware_flag(): def test_get_network_targets_ovs_bridge_always_vlan_aware(): """An OVS bridge is always vlan_aware, regardless of bridge_vlan_aware.""" mixin = ProxmoxVMProvisionMixin() - mixin._get_node_network = MagicMock( - return_value=[{"iface": "vmbr1", "type": "OVSBridge"}] - ) + mixin._get_node_network = MagicMock(return_value=[{"iface": "vmbr1", "type": "OVSBridge"}]) mixin._get_sdn_vnets = MagicMock(return_value=[]) targets = mixin.get_network_targets() @@ -515,9 +523,7 @@ def test_get_network_targets_vnet_without_tag_has_none_fixed_vlan_tag(): """A vnet with no tag (e.g. VXLAN/EVPN zone) reports fixed_vlan_tag=None.""" mixin = ProxmoxVMProvisionMixin() mixin._get_node_network = MagicMock(return_value=[]) - mixin._get_sdn_vnets = MagicMock( - return_value=[{"vnet": "vnet2", "zone": "zone-vxlan"}] - ) + mixin._get_sdn_vnets = MagicMock(return_value=[{"vnet": "vnet2", "zone": "zone-vxlan"}]) targets = mixin.get_network_targets() @@ -769,7 +775,9 @@ def test_create_vm_from_cloud_init_with_real_world_storage_shape(): ] 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._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() @@ -818,8 +826,12 @@ def test_create_vm_from_cloud_init_explicit_storage_skips_auto_detect(): ] 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._find_default_image_storage = MagicMock(side_effect=AssertionError("should not be called")) + mixin._download_cloud_image = MagicMock( + return_value="/var/lib/vz/template/netork-images/debian-12.qcow2" + ) + mixin._find_default_image_storage = MagicMock( + side_effect=AssertionError("should not be called") + ) mixin._run_node_command = MagicMock(return_value="") mock_vm = MagicMock() @@ -944,7 +956,9 @@ def test_create_vm_writes_snippet_via_ssh_with_correct_content(): ] 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._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() @@ -1012,7 +1026,9 @@ def test_create_vm_from_cloud_init_with_explicit_mac(): ] 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._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() @@ -1060,7 +1076,9 @@ def test_create_vm_from_cloud_init_without_mac_uses_bare_virtio(): ] 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._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() @@ -1089,3 +1107,59 @@ def test_create_vm_from_cloud_init_without_mac_uses_bare_virtio(): 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" + + +def test_create_vm_cloudinit_drive_uses_image_storage_not_snippet_storage(): + """Regression: real Proxmox fails at VM *start* time with "storage 'X' + does not support content-type 'images'" if ide2 (the cloud-init drive) + points at the snippets storage — a cloud-init drive is a disk image and + needs content='images', same as the root disk. Only cicustom (the + snippet file reference) should use the snippets storage. Found live: a + real deployment had 'local' (snippets-only) and 'local-zfs' (images) as + two distinct storages, and ide2 was wrongly set to the former.""" + mixin = ProxmoxVMProvisionMixin() + mixin._node_name = "pve1" + + mock_api = MagicMock() + mock_api.cluster.nextid.get.return_value = 109 + mock_api.storage.return_value.get.return_value = {"path": "/var/lib/vz"} + + mock_node = MagicMock() + mock_node.storage.get.return_value = [ + {"storage": "local-zfs", "type": "zfspool", "content": "images,rootdir", "enabled": 1}, + {"storage": "local", "type": "dir", "content": "iso,snippets,backup,vztmpl", "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-zfs:vm-109-disk-0", + "scsi0": "local-zfs:vm-109-disk-0", + } + mock_vm.status.start.post.return_value = "UPID:pve1:135: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="cloudinit-storage-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": "cloudinit-storage-vm"}, + ) + + cloud_init_call = next(c for c in mock_vm.config.post.call_args_list if "ide2" in c[1]) + assert cloud_init_call[1]["ide2"] == "local-zfs:cloudinit" + assert cloud_init_call[1]["cicustom"].startswith("user=local:snippets/")