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:
@@ -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
|
||||||
|
|||||||
@@ -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/")
|
||||||
|
|||||||
Reference in New Issue
Block a user