feat(vm_provision_mixin): create_vm_from_cloud_init downloads cloud images directly
Replaces the template-clone flow with: create empty VM shell, download the cloud image on the node (cached by filename, optional checksum verification), qm importdisk, attach as scsi0. NIC config, snippet upload, ssh keys, disk resize, and start remain unchanged (already generic). New helpers: _run_node_command (strict SSH exec with custom timeout and non-zero-exit detection, unlike the best-effort _exec_ssh_command), _download_cloud_image (idempotent download + checksum check), _find_default_image_storage (content=images discovery, mirrors the existing snippet-storage discovery). 24 tests pass (10 new: _run_node_command x2, _download_cloud_image x4, plus rewrites of the 4 existing create_vm_from_cloud_init tests for the new flow).
This commit is contained in:
@@ -75,19 +75,25 @@ def test_create_vm_from_cloud_init_single_nic():
|
||||
mock_api = MagicMock()
|
||||
mock_api.cluster.nextid.get.return_value = 101
|
||||
mock_api.storage.get.return_value = [
|
||||
{"storage": "snippets", "type": "dir", "content": "snippets", "enabled": 1}
|
||||
{"storage": "local-lvm", "type": "lvmthin", "content": "images,rootdir", "enabled": 1},
|
||||
{"storage": "snippets", "type": "dir", "content": "snippets", "enabled": 1},
|
||||
]
|
||||
|
||||
mock_node = MagicMock()
|
||||
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 operations
|
||||
mock_vm = MagicMock()
|
||||
mock_node.qemu.return_value = mock_vm
|
||||
mock_vm.clone.post.return_value = "UPID:pve1:123:clone"
|
||||
mock_node.qemu.post.return_value = None
|
||||
mock_vm.config.post.return_value = None
|
||||
mock_vm.config.get.return_value = {"scsi0": "local:100/vm-101-disk-0.raw"}
|
||||
mock_vm.config.get.return_value = {
|
||||
"unused0": "local-lvm:vm-101-disk-0",
|
||||
"scsi0": "local-lvm:vm-101-disk-0",
|
||||
}
|
||||
mock_vm.status.start.post.return_value = "UPID:pve1:124:start"
|
||||
|
||||
# Mock task completion
|
||||
@@ -103,7 +109,7 @@ def test_create_vm_from_cloud_init_single_nic():
|
||||
with patch("time.sleep"):
|
||||
result = mixin.create_vm_from_cloud_init(
|
||||
name="test-vm",
|
||||
template="100",
|
||||
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}],
|
||||
@@ -114,13 +120,15 @@ def test_create_vm_from_cloud_init_single_nic():
|
||||
assert result["vmid"] == "101"
|
||||
assert result["name"] == "test-vm"
|
||||
assert result["node"] == "pve1"
|
||||
assert mock_vm.clone.post.called
|
||||
assert mock_node.qemu.post.called
|
||||
mixin._download_cloud_image.assert_called_once()
|
||||
|
||||
# Verify NIC config was set correctly: net0 with tag=10, DHCP enabled
|
||||
config_call_args = mock_vm.config.post.call_args_list[0] # First call (cores/memory/net0)
|
||||
assert "net0" in config_call_args[1]
|
||||
assert "tag=10" in config_call_args[1]["net0"]
|
||||
assert "vmbr0" in config_call_args[1]["net0"]
|
||||
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"]
|
||||
|
||||
|
||||
def test_create_vm_from_cloud_init_dual_nic_trunk():
|
||||
@@ -132,19 +140,25 @@ def test_create_vm_from_cloud_init_dual_nic_trunk():
|
||||
mock_api = MagicMock()
|
||||
mock_api.cluster.nextid.get.return_value = 102
|
||||
mock_api.storage.get.return_value = [
|
||||
{"storage": "snippets", "type": "dir", "content": "snippets", "enabled": 1}
|
||||
{"storage": "local-lvm", "type": "lvmthin", "content": "images,rootdir", "enabled": 1},
|
||||
{"storage": "snippets", "type": "dir", "content": "snippets", "enabled": 1},
|
||||
]
|
||||
|
||||
mock_node = MagicMock()
|
||||
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 operations
|
||||
mock_vm = MagicMock()
|
||||
mock_node.qemu.return_value = mock_vm
|
||||
mock_vm.clone.post.return_value = "UPID:pve1:125:clone"
|
||||
mock_node.qemu.post.return_value = None
|
||||
mock_vm.config.post.return_value = None
|
||||
mock_vm.config.get.return_value = {"scsi0": "local:100/vm-102-disk-0.raw"}
|
||||
mock_vm.config.get.return_value = {
|
||||
"unused0": "local-lvm:vm-102-disk-0",
|
||||
"scsi0": "local-lvm:vm-102-disk-0",
|
||||
}
|
||||
mock_vm.status.start.post.return_value = "UPID:pve1:126:start"
|
||||
|
||||
# Mock task completion
|
||||
@@ -160,7 +174,7 @@ def test_create_vm_from_cloud_init_dual_nic_trunk():
|
||||
with patch("time.sleep"):
|
||||
result = mixin.create_vm_from_cloud_init(
|
||||
name="wireshark-sat-1",
|
||||
template="100",
|
||||
image_url="https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
|
||||
cpu=4,
|
||||
memory=4096,
|
||||
nics=[
|
||||
@@ -175,16 +189,18 @@ def test_create_vm_from_cloud_init_dual_nic_trunk():
|
||||
assert result["name"] == "wireshark-sat-1"
|
||||
|
||||
# Verify both NICs configured
|
||||
config_call_args = mock_vm.config.post.call_args_list[0]
|
||||
assert "net0" in config_call_args[1]
|
||||
assert "net1" in config_call_args[1]
|
||||
assert "tag=10" in config_call_args[1]["net0"]
|
||||
assert "trunks=20;30" in config_call_args[1]["net1"]
|
||||
assert "vmbr1" in config_call_args[1]["net1"]
|
||||
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"]
|
||||
assert "vmbr1" in net_call_args[1]["net1"]
|
||||
|
||||
# Verify DHCP config: ipconfig0 yes, ipconfig1 no
|
||||
cloud_init_call_args = mock_vm.config.post.call_args_list[1] # Second call (ipconfig)
|
||||
assert "ipconfig0" in cloud_init_call_args[1]
|
||||
cloud_init_call_args = next(
|
||||
c for c in mock_vm.config.post.call_args_list if "ipconfig0" in c[1]
|
||||
)
|
||||
assert cloud_init_call_args[1]["ipconfig0"] == "ip=dhcp"
|
||||
assert "ipconfig1" not in cloud_init_call_args[1] # net1 has no DHCP
|
||||
|
||||
@@ -194,7 +210,7 @@ def test_create_vm_missing_snippet_storage():
|
||||
mixin = ProxmoxVMProvisionMixin()
|
||||
mixin._node_name = "pve1"
|
||||
|
||||
# Mock API with no snippet storage
|
||||
# Mock API with images storage but no snippet storage
|
||||
mock_api = MagicMock()
|
||||
mock_api.cluster.nextid.get.return_value = 101
|
||||
mock_api.storage.get.return_value = [
|
||||
@@ -203,13 +219,15 @@ def test_create_vm_missing_snippet_storage():
|
||||
|
||||
mixin._api = mock_api
|
||||
|
||||
# Mock node for clone operation (so we get to the storage check)
|
||||
mock_node = MagicMock()
|
||||
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_vm.clone.post.return_value = "UPID:pve1:123:clone"
|
||||
mock_node.qemu.post.return_value = None
|
||||
mock_vm.config.post.return_value = None
|
||||
mock_vm.config.get.return_value = {"unused0": "local-lvm:vm-101-disk-0"}
|
||||
|
||||
# Mock task to allow clone to complete
|
||||
mock_task = MagicMock()
|
||||
@@ -220,7 +238,7 @@ def test_create_vm_missing_snippet_storage():
|
||||
with patch("napalm_proxmox.vm_provision_mixin.time.sleep"):
|
||||
mixin.create_vm_from_cloud_init(
|
||||
name="test-vm",
|
||||
template="100",
|
||||
image_url="https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
|
||||
cpu=2,
|
||||
memory=2048,
|
||||
nics=[{"bridge": "vmbr0"}],
|
||||
@@ -237,19 +255,25 @@ def test_create_vm_with_disk_resize():
|
||||
mock_api = MagicMock()
|
||||
mock_api.cluster.nextid.get.return_value = 103
|
||||
mock_api.storage.get.return_value = [
|
||||
{"storage": "snippets", "type": "dir", "content": "snippets", "enabled": 1}
|
||||
{"storage": "local-lvm", "type": "lvmthin", "content": "images,rootdir", "enabled": 1},
|
||||
{"storage": "snippets", "type": "dir", "content": "snippets", "enabled": 1},
|
||||
]
|
||||
|
||||
mock_node = MagicMock()
|
||||
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 operations
|
||||
mock_vm = MagicMock()
|
||||
mock_node.qemu.return_value = mock_vm
|
||||
mock_vm.clone.post.return_value = "UPID:pve1:127:clone"
|
||||
mock_node.qemu.post.return_value = None
|
||||
mock_vm.config.post.return_value = None
|
||||
mock_vm.config.get.return_value = {"scsi0": "local:100/vm-103-disk-0.raw"}
|
||||
mock_vm.config.get.return_value = {
|
||||
"unused0": "local-lvm:vm-103-disk-0",
|
||||
"scsi0": "local-lvm:vm-103-disk-0",
|
||||
}
|
||||
mock_vm.resize.put.return_value = None
|
||||
mock_vm.status.start.post.return_value = "UPID:pve1:128:start"
|
||||
|
||||
@@ -266,7 +290,7 @@ def test_create_vm_with_disk_resize():
|
||||
with patch("time.sleep"):
|
||||
result = mixin.create_vm_from_cloud_init(
|
||||
name="big-vm",
|
||||
template="100",
|
||||
image_url="https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
|
||||
cpu=2,
|
||||
memory=2048,
|
||||
nics=[{"bridge": "vmbr0"}],
|
||||
@@ -511,3 +535,107 @@ def test_get_network_targets_bridge_has_no_fixed_vlan_tag():
|
||||
targets = mixin.get_network_targets()
|
||||
|
||||
assert "fixed_vlan_tag" not in targets[0] or targets[0]["fixed_vlan_tag"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_node_command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_ssh_exec(exit_status: int, stdout_text: str = "", stderr_text: str = ""):
|
||||
"""Build a mock (stdin, stdout, stderr) tuple as returned by exec_command."""
|
||||
mock_stdin = MagicMock()
|
||||
mock_stdout = MagicMock()
|
||||
mock_stderr = MagicMock()
|
||||
mock_stdout.channel.recv_exit_status.return_value = exit_status
|
||||
mock_stdout.read.return_value = stdout_text.encode()
|
||||
mock_stderr.read.return_value = stderr_text.encode()
|
||||
return mock_stdin, mock_stdout, mock_stderr
|
||||
|
||||
|
||||
def test_run_node_command_success_returns_stdout():
|
||||
mixin = ProxmoxVMProvisionMixin()
|
||||
mixin._ssh_client = MagicMock()
|
||||
mixin._ssh_client.exec_command.return_value = _mock_ssh_exec(0, stdout_text="hello\n")
|
||||
|
||||
result = mixin._run_node_command("echo hello", timeout=10)
|
||||
|
||||
assert result == "hello"
|
||||
|
||||
|
||||
def test_run_node_command_nonzero_exit_raises():
|
||||
mixin = ProxmoxVMProvisionMixin()
|
||||
mixin._ssh_client = MagicMock()
|
||||
mixin._ssh_client.exec_command.return_value = _mock_ssh_exec(
|
||||
1, stderr_text="No such file or directory"
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="No such file or directory"):
|
||||
mixin._run_node_command("cat /nonexistent", timeout=10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _download_cloud_image
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_download_cloud_image_skips_download_when_cached():
|
||||
mixin = ProxmoxVMProvisionMixin()
|
||||
mixin._run_node_command = MagicMock(return_value="EXISTS")
|
||||
|
||||
path = mixin._download_cloud_image(
|
||||
"https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
|
||||
None,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
assert path.endswith("debian-12-genericcloud-amd64.qcow2")
|
||||
# Only the existence check ran — no wget call
|
||||
assert mixin._run_node_command.call_count == 1
|
||||
assert "wget" not in mixin._run_node_command.call_args_list[0][0][0]
|
||||
|
||||
|
||||
def test_download_cloud_image_downloads_when_missing():
|
||||
mixin = ProxmoxVMProvisionMixin()
|
||||
mixin._run_node_command = MagicMock(return_value="MISSING")
|
||||
|
||||
mixin._download_cloud_image(
|
||||
"https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
|
||||
None,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
commands = [c[0][0] for c in mixin._run_node_command.call_args_list]
|
||||
assert any("wget" in cmd for cmd in commands)
|
||||
|
||||
|
||||
def test_download_cloud_image_verifies_matching_checksum():
|
||||
mixin = ProxmoxVMProvisionMixin()
|
||||
mixin._run_node_command = MagicMock(
|
||||
side_effect=["EXISTS", "abc123"] # existence check, then checksum
|
||||
)
|
||||
|
||||
path = mixin._download_cloud_image(
|
||||
"https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
|
||||
"sha256:abc123",
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
assert path.endswith("debian-12-genericcloud-amd64.qcow2")
|
||||
|
||||
|
||||
def test_download_cloud_image_checksum_mismatch_raises_and_removes_file():
|
||||
mixin = ProxmoxVMProvisionMixin()
|
||||
mixin._run_node_command = MagicMock(
|
||||
side_effect=["EXISTS", "wrong-checksum", ""] # existence, checksum, rm
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Checksum mismatch"):
|
||||
mixin._download_cloud_image(
|
||||
"https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
|
||||
"sha256:abc123",
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
commands = [c[0][0] for c in mixin._run_node_command.call_args_list]
|
||||
assert any(cmd.startswith("rm -f") for cmd in commands)
|
||||
|
||||
Reference in New Issue
Block a user