Merge fix/cloudinit-drive-image-storage: ide2 needs images storage

This commit is contained in:
Christian Manivong
2026-07-08 11:07:09 +02:00
2 changed files with 106 additions and 28 deletions
+8 -4
View File
@@ -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
+98 -24
View File
@@ -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/")