fix(vm_provision_mixin): cloud-init drive (ide2) needs images storage, not snippets

Proxmox's cloud-init drive is a disk image and requires a storage with
content='images' — the same requirement as the root disk — not the
snippets storage. These are commonly different storages (e.g. 'local'
with content=snippets-only, 'local-zfs' with content=images), and real
Proxmox now creates the VM fine but fails at *start* time with "storage
'X' does not support content-type 'images'" once it tries to generate
the cloud-init ISO.

Found live: a real deployment created the VM successfully, and only
failed when the user started it manually on the Proxmox side.
This commit is contained in:
Christian Manivong
2026-07-08 11:07:06 +02:00
parent 18fd3c8958
commit bcadd77420
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}") _logger.info(f"Setting Cloud-Init config for VM {vmid}")
cloud_init_args = { 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", "citype": "nocloud",
"cicustom": f"user={snippet_storage}:snippets/{filename}", "cicustom": f"user={snippet_storage}:snippets/{filename}",
} }
@@ -546,9 +552,7 @@ class ProxmoxVMProvisionMixin:
while True: while True:
elapsed = time.time() - start_time elapsed = time.time() - start_time
if wait_for_ip and elapsed > timeout: if wait_for_ip and elapsed > timeout:
raise RuntimeError( raise RuntimeError(f"VM {vmid} failed to acquire IP within {timeout}s")
f"VM {vmid} failed to acquire IP within {timeout}s"
)
try: try:
# Query guest-agent network interfaces # 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._api = mock_api
mixin._node_api = MagicMock(return_value=mock_node) 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="") mixin._run_node_command = MagicMock(return_value="")
# Mock VM operations # Mock VM operations
@@ -122,9 +124,7 @@ def test_create_vm_from_cloud_init_single_nic():
mixin._download_cloud_image.assert_called_once() mixin._download_cloud_image.assert_called_once()
# Verify NIC config was set correctly: net0 with tag=10, DHCP enabled # Verify NIC config was set correctly: net0 with tag=10, DHCP enabled
net_call_args = next( net_call_args = next(c for c in mock_vm.config.post.call_args_list if "net0" in c[1])
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 "tag=10" in net_call_args[1]["net0"]
assert "vmbr0" 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 # test_create_vm_writes_snippet_via_ssh_with_correct_content for the
# dedicated check). # dedicated check).
write_cmd = next( 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 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._api = mock_api
mixin._node_api = MagicMock(return_value=mock_node) 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="") mixin._run_node_command = MagicMock(return_value="")
# Mock VM operations # Mock VM operations
@@ -183,7 +187,11 @@ def test_create_vm_from_cloud_init_dual_nic_trunk():
memory=4096, memory=4096,
nics=[ nics=[
{"bridge": "vmbr0", "vlan_tag": 10}, # net0: mgmt with DHCP {"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"]}, cloud_init_config={"hostname": "sat-1", "runcmd": ["custom cmd"]},
timeout=120, timeout=120,
@@ -193,9 +201,7 @@ def test_create_vm_from_cloud_init_dual_nic_trunk():
assert result["name"] == "wireshark-sat-1" assert result["name"] == "wireshark-sat-1"
# Verify both NICs configured # Verify both NICs configured
net_call_args = next( net_call_args = next(c for c in mock_vm.config.post.call_args_list if "net0" in c[1])
c for c in mock_vm.config.post.call_args_list if "net0" in c[1]
)
assert "net1" in net_call_args[1] assert "net1" in net_call_args[1]
assert "tag=10" in net_call_args[1]["net0"] assert "tag=10" in net_call_args[1]["net0"]
assert "trunks=20;30" in net_call_args[1]["net1"] 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} {"storage": "local-lvm", "type": "lvmthin", "content": "images,rootdir", "enabled": 1}
] ]
mixin._node_api = MagicMock(return_value=mock_node) 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="") mixin._run_node_command = MagicMock(return_value="")
mock_vm = MagicMock() mock_vm = MagicMock()
mock_node.qemu.return_value = mock_vm mock_node.qemu.return_value = mock_vm
@@ -267,7 +275,9 @@ def test_create_vm_with_disk_resize():
] ]
mixin._api = mock_api mixin._api = mock_api
mixin._node_api = MagicMock(return_value=mock_node) 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="") mixin._run_node_command = MagicMock(return_value="")
# Mock VM operations # 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(): def test_get_network_targets_ovs_bridge_always_vlan_aware():
"""An OVS bridge is always vlan_aware, regardless of bridge_vlan_aware.""" """An OVS bridge is always vlan_aware, regardless of bridge_vlan_aware."""
mixin = ProxmoxVMProvisionMixin() mixin = ProxmoxVMProvisionMixin()
mixin._get_node_network = MagicMock( mixin._get_node_network = MagicMock(return_value=[{"iface": "vmbr1", "type": "OVSBridge"}])
return_value=[{"iface": "vmbr1", "type": "OVSBridge"}]
)
mixin._get_sdn_vnets = MagicMock(return_value=[]) mixin._get_sdn_vnets = MagicMock(return_value=[])
targets = mixin.get_network_targets() 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.""" """A vnet with no tag (e.g. VXLAN/EVPN zone) reports fixed_vlan_tag=None."""
mixin = ProxmoxVMProvisionMixin() mixin = ProxmoxVMProvisionMixin()
mixin._get_node_network = MagicMock(return_value=[]) mixin._get_node_network = MagicMock(return_value=[])
mixin._get_sdn_vnets = MagicMock( mixin._get_sdn_vnets = MagicMock(return_value=[{"vnet": "vnet2", "zone": "zone-vxlan"}])
return_value=[{"vnet": "vnet2", "zone": "zone-vxlan"}]
)
targets = mixin.get_network_targets() 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._api = mock_api
mixin._node_api = MagicMock(return_value=mock_node) 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="") mixin._run_node_command = MagicMock(return_value="")
mock_vm = MagicMock() mock_vm = MagicMock()
@@ -818,8 +826,12 @@ def test_create_vm_from_cloud_init_explicit_storage_skips_auto_detect():
] ]
mixin._api = mock_api mixin._api = mock_api
mixin._node_api = MagicMock(return_value=mock_node) 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(
mixin._find_default_image_storage = MagicMock(side_effect=AssertionError("should not be called")) 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="") mixin._run_node_command = MagicMock(return_value="")
mock_vm = MagicMock() mock_vm = MagicMock()
@@ -944,7 +956,9 @@ def test_create_vm_writes_snippet_via_ssh_with_correct_content():
] ]
mixin._api = mock_api mixin._api = mock_api
mixin._node_api = MagicMock(return_value=mock_node) 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="") mixin._run_node_command = MagicMock(return_value="")
mock_vm = MagicMock() mock_vm = MagicMock()
@@ -1012,7 +1026,9 @@ def test_create_vm_from_cloud_init_with_explicit_mac():
] ]
mixin._api = mock_api mixin._api = mock_api
mixin._node_api = MagicMock(return_value=mock_node) 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="") mixin._run_node_command = MagicMock(return_value="")
mock_vm = MagicMock() mock_vm = MagicMock()
@@ -1060,7 +1076,9 @@ def test_create_vm_from_cloud_init_without_mac_uses_bare_virtio():
] ]
mixin._api = mock_api mixin._api = mock_api
mixin._node_api = MagicMock(return_value=mock_node) 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="") mixin._run_node_command = MagicMock(return_value="")
mock_vm = MagicMock() 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]) 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" 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/")