Skip to content

Commit 3f57453

Browse files
committed
tests: functional: Add hotplugging tests
Add integration tests for block, pmem and net hotplugging. The tests require a manual PCI bus rescan at the moment since no hotplug notification mechanism is implemented at the moment. Signed-off-by: Ilias Stamatis <ilstam@amazon.com>
1 parent 053d10a commit 3f57453

1 file changed

Lines changed: 387 additions & 0 deletions

File tree

Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
# Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Tests for PCI device hotplug"""
4+
5+
import os
6+
7+
import pytest
8+
9+
import host_tools.drive as drive_tools
10+
import host_tools.network as net_tools
11+
12+
VIRTIO_PCI_VENDOR_ID = 0x1AF4
13+
VIRTIO_PCI_DEVICE_ID_NET = 0x1041
14+
VIRTIO_PCI_DEVICE_ID_BLOCK = 0x1042
15+
VIRTIO_PCI_DEVICE_ID_PMEM = 0x105B
16+
17+
18+
def test_hotplug_block(uvm_any_with_pci):
19+
"""
20+
Test hotplugging a block device after VM start.
21+
Test that the device appears in lspci and is usable.
22+
Test that invalid hotplug request are rejected.
23+
"""
24+
vm = uvm_any_with_pci
25+
26+
# Snapshot lspci output before hotplug
27+
_, lspci_before, _ = vm.ssh.check_output("lspci -n")
28+
29+
# Hotplug a block device
30+
host_file = drive_tools.FilesystemFile(os.path.join(vm.fsfiles, "block0"), size=4)
31+
vm.api.drive.put(
32+
drive_id="block0",
33+
path_on_host=vm.create_jailed_resource(host_file.path),
34+
is_root_device=False,
35+
is_read_only=False,
36+
rate_limiter={
37+
"ops": {"size": 100, "refill_time": 100},
38+
},
39+
)
40+
41+
# Rescan PCI bus since no hotplug notification mechanism exists yet
42+
vm.ssh.check_output("echo 1 > /sys/bus/pci/rescan")
43+
44+
# Verify a new virtio-block device entry appeared in lspci
45+
_, lspci_after, _ = vm.ssh.check_output("lspci -n")
46+
new_entries = set(lspci_after.splitlines()) - set(lspci_before.splitlines())
47+
assert len(new_entries) == 1
48+
entry = new_entries.pop()
49+
assert f"{VIRTIO_PCI_VENDOR_ID:04x}:{VIRTIO_PCI_DEVICE_ID_BLOCK:04x}" in entry
50+
51+
# Discover the block device node from the PCI BDF via sysfs
52+
bdf = entry.split()[0]
53+
_, dev_name, _ = vm.ssh.check_output(
54+
f"ls /sys/bus/pci/devices/0000:{bdf}/virtio*/block/"
55+
)
56+
dev_path = f"/dev/{dev_name.strip()}"
57+
58+
# Ensure the device is usable by writing a file to it and reading it back
59+
vm.ssh.check_output("mkdir -p /tmp/block0_mnt")
60+
vm.ssh.check_output(f"mount {dev_path} /tmp/block0_mnt")
61+
vm.ssh.check_output("echo hotplug_test > /tmp/block0_mnt/test")
62+
_, stdout, _ = vm.ssh.check_output("cat /tmp/block0_mnt/test")
63+
assert stdout.strip() == "hotplug_test"
64+
65+
# Hotplugging a device with a duplicate ID must be rejected
66+
with pytest.raises(RuntimeError, match="Device ID in use"):
67+
vm.api.drive.put(
68+
drive_id="block0",
69+
path_on_host=vm.create_jailed_resource(host_file.path),
70+
is_root_device=False,
71+
is_read_only=False,
72+
)
73+
74+
# Hotplugging a root device must be rejected
75+
with pytest.raises(RuntimeError, match="A root block device already exists"):
76+
vm.api.drive.put(
77+
drive_id="block_root",
78+
path_on_host=vm.create_jailed_resource(host_file.path),
79+
is_root_device=True,
80+
is_read_only=False,
81+
)
82+
83+
# Hotplugging with a non-existent backing file must be rejected
84+
with pytest.raises(RuntimeError, match="No such file or directory"):
85+
vm.api.drive.put(
86+
drive_id="block_bad",
87+
path_on_host="/nonexistent",
88+
is_root_device=False,
89+
is_read_only=False,
90+
)
91+
92+
# Verify no further devices appeared after the rejected requests
93+
vm.ssh.check_output("echo 1 > /sys/bus/pci/rescan")
94+
_, lspci_final, _ = vm.ssh.check_output("lspci -n")
95+
assert lspci_final == lspci_after
96+
97+
98+
def test_hotplug_pmem(uvm_any_with_pci):
99+
"""
100+
Test hotplugging a pmem device after VM start.
101+
Test that the device appears in lspci and is usable.
102+
Test that invalid hotplug request are rejected.
103+
"""
104+
vm = uvm_any_with_pci
105+
106+
# Snapshot lspci output before hotplug
107+
_, lspci_before, _ = vm.ssh.check_output("lspci -n")
108+
109+
# Hotplug a pmem device
110+
host_file = drive_tools.FilesystemFile(os.path.join(vm.fsfiles, "pmem0"), size=4)
111+
vm.api.pmem.put(
112+
id="pmem0",
113+
path_on_host=vm.create_jailed_resource(host_file.path),
114+
root_device=False,
115+
read_only=False,
116+
)
117+
118+
# Rescan PCI bus since no hotplug notification mechanism exists yet
119+
vm.ssh.check_output("echo 1 > /sys/bus/pci/rescan")
120+
121+
# Verify a new virtio-pmem device entry appeared in lspci
122+
_, lspci_after, _ = vm.ssh.check_output("lspci -n")
123+
new_entries = set(lspci_after.splitlines()) - set(lspci_before.splitlines())
124+
assert len(new_entries) == 1
125+
entry = new_entries.pop()
126+
assert f"{VIRTIO_PCI_VENDOR_ID:04x}:{VIRTIO_PCI_DEVICE_ID_PMEM:04x}" in entry
127+
128+
# Discover the pmem device node from the PCI BDF via sysfs.
129+
# The NVDIMM subsystem in the guest creates the ndbus/region/namespace/block
130+
# hierarchy asynchronously after driver probe, so we need to wait for it.
131+
vm.ssh.check_output("sleep 1")
132+
bdf = entry.split()[0]
133+
_, dev_name, _ = vm.ssh.check_output(
134+
f"ls /sys/bus/pci/devices/0000:{bdf}/virtio*/ndbus*/region*/namespace*/block/"
135+
)
136+
dev_path = f"/dev/{dev_name.strip()}"
137+
138+
# Ensure the device is usable by writing a file to it and reading it back
139+
vm.ssh.check_output("mkdir -p /tmp/pmem0_mnt")
140+
vm.ssh.check_output(f"mount {dev_path} /tmp/pmem0_mnt")
141+
vm.ssh.check_output("echo hotplug_test > /tmp/pmem0_mnt/test")
142+
_, stdout, _ = vm.ssh.check_output("cat /tmp/pmem0_mnt/test")
143+
assert stdout.strip() == "hotplug_test"
144+
145+
# Hotplugging a root pmem device must be rejected
146+
with pytest.raises(RuntimeError, match="Attempt to add pmem as a root device"):
147+
vm.api.pmem.put(
148+
id="pmem_root",
149+
path_on_host=vm.create_jailed_resource(host_file.path),
150+
root_device=True,
151+
read_only=False,
152+
)
153+
154+
# Hotplugging a device with a duplicate ID must be rejected
155+
with pytest.raises(RuntimeError, match="Device ID in use"):
156+
vm.api.pmem.put(
157+
id="pmem0",
158+
path_on_host=vm.create_jailed_resource(host_file.path),
159+
root_device=False,
160+
read_only=False,
161+
)
162+
163+
# Hotplugging with a non-existent backing file must be rejected
164+
with pytest.raises(RuntimeError, match="No such file or directory"):
165+
vm.api.pmem.put(
166+
id="pmem_bad",
167+
path_on_host="/nonexistent",
168+
root_device=False,
169+
read_only=False,
170+
)
171+
172+
# Verify no further devices appeared after the rejected requests
173+
vm.ssh.check_output("echo 1 > /sys/bus/pci/rescan")
174+
_, lspci_final, _ = vm.ssh.check_output("lspci -n")
175+
assert lspci_final == lspci_after
176+
177+
178+
def test_hotplug_net(uvm_any_with_pci):
179+
"""
180+
Test hotplugging a net device after VM start.
181+
Test that the device appears in lspci and is usable.
182+
Test that invalid hotplug request are rejected.
183+
"""
184+
vm = uvm_any_with_pci
185+
186+
# Snapshot lspci output before hotplug
187+
_, lspci_before, _ = vm.ssh.check_output("lspci -n")
188+
189+
# Hotplug a network device
190+
iface1 = net_tools.NetIfaceConfig.with_id(1)
191+
vm.netns.add_tap(iface1.tap_name, ip=f"{iface1.host_ip}/{iface1.netmask_len}")
192+
vm.api.network.put(
193+
iface_id=iface1.dev_name,
194+
host_dev_name=iface1.tap_name,
195+
guest_mac=iface1.guest_mac,
196+
)
197+
198+
# Rescan PCI bus since no hotplug notification mechanism exists yet
199+
vm.ssh.check_output("echo 1 > /sys/bus/pci/rescan")
200+
201+
# Verify a new net device entry appeared in lspci
202+
_, lspci_after, _ = vm.ssh.check_output("lspci -n")
203+
new_entries = set(lspci_after.splitlines()) - set(lspci_before.splitlines())
204+
assert len(new_entries) == 1
205+
entry = new_entries.pop()
206+
assert f"{VIRTIO_PCI_VENDOR_ID:04x}:{VIRTIO_PCI_DEVICE_ID_NET:04x}" in entry
207+
208+
# Discover the net interface name from the PCI BDF via sysfs
209+
bdf = entry.split()[0]
210+
_, iface_name, _ = vm.ssh.check_output(
211+
f"ls /sys/bus/pci/devices/0000:{bdf}/virtio*/net/"
212+
)
213+
iface_name = iface_name.strip()
214+
215+
# Verify the hotplugged interface is usable
216+
vm.ssh.check_output(f"ip link show {iface_name}")
217+
vm.ssh.check_output(
218+
f"ip addr add {iface1.guest_ip}/{iface1.netmask_len} dev {iface_name}"
219+
)
220+
vm.ssh.check_output(f"ip link set {iface_name} up")
221+
222+
# Ping the host from the guest through the hotplugged interface
223+
_, stdout, _ = vm.ssh.check_output(f"ping -c 3 -W 3 {iface1.host_ip}")
224+
assert "3 packets transmitted, 3 received" in stdout
225+
226+
# Hotplugging a device with a duplicate ID must be rejected
227+
iface2 = net_tools.NetIfaceConfig.with_id(2)
228+
with pytest.raises(RuntimeError, match="Device ID in use"):
229+
vm.api.network.put(
230+
iface_id=iface1.dev_name,
231+
host_dev_name=iface2.tap_name,
232+
guest_mac=iface2.guest_mac,
233+
)
234+
235+
# Hotplugging a device with a duplicate MAC must be rejected
236+
with pytest.raises(RuntimeError, match="The MAC address is already in use"):
237+
vm.api.network.put(
238+
iface_id=iface2.dev_name,
239+
host_dev_name=iface2.tap_name,
240+
guest_mac=iface1.guest_mac,
241+
)
242+
243+
# Hotplugging a device that reuses the same TAP must be rejected
244+
with pytest.raises(RuntimeError, match="Resource busy"):
245+
vm.api.network.put(
246+
iface_id=iface2.dev_name,
247+
host_dev_name=iface1.tap_name,
248+
guest_mac=iface2.guest_mac,
249+
)
250+
251+
# Hotplugging with a non-existent tap device must be rejected
252+
with pytest.raises(RuntimeError, match="Open tap device failed"):
253+
vm.api.network.put(
254+
iface_id="eth_bad",
255+
host_dev_name="nonexistent_tap",
256+
)
257+
258+
# Verify no further devices appeared after the rejected requests
259+
vm.ssh.check_output("echo 1 > /sys/bus/pci/rescan")
260+
_, lspci_final, _ = vm.ssh.check_output("lspci -n")
261+
assert lspci_final == lspci_after
262+
263+
264+
def test_hotplug_no_pci(uvm_any_without_pci):
265+
"""
266+
Hotplugging any device type must be rejected when PCI is not enabled.
267+
"""
268+
vm = uvm_any_without_pci
269+
270+
host_file = drive_tools.FilesystemFile(os.path.join(vm.fsfiles, "disk"), size=4)
271+
272+
with pytest.raises(RuntimeError, match="PCI is not enabled"):
273+
vm.api.drive.put(
274+
drive_id="block0",
275+
path_on_host=vm.create_jailed_resource(host_file.path),
276+
is_root_device=False,
277+
is_read_only=False,
278+
)
279+
280+
with pytest.raises(RuntimeError, match="PCI is not enabled"):
281+
vm.api.pmem.put(
282+
id="pmem0",
283+
path_on_host=vm.create_jailed_resource(host_file.path),
284+
root_device=False,
285+
read_only=False,
286+
)
287+
288+
iface1 = net_tools.NetIfaceConfig.with_id(1)
289+
vm.netns.add_tap(iface1.tap_name, ip=f"{iface1.host_ip}/{iface1.netmask_len}")
290+
with pytest.raises(RuntimeError, match="PCI is not enabled"):
291+
vm.api.network.put(
292+
iface_id=iface1.dev_name,
293+
host_dev_name=iface1.tap_name,
294+
guest_mac=iface1.guest_mac,
295+
)
296+
297+
298+
def test_hotplug_preserved_after_snapshot(uvm_any_with_pci, microvm_factory):
299+
"""
300+
Test that a hotplugged device survives a full snapshot/restore cycle.
301+
"""
302+
vm = uvm_any_with_pci
303+
304+
# Snapshot lspci output before hotplug
305+
_, lspci_before, _ = vm.ssh.check_output("lspci -n")
306+
307+
# Hotplug a block device
308+
host_file = drive_tools.FilesystemFile(os.path.join(vm.fsfiles, "block0"), size=4)
309+
vm.api.drive.put(
310+
drive_id="block0",
311+
path_on_host=vm.create_jailed_resource(host_file.path),
312+
is_root_device=False,
313+
is_read_only=False,
314+
)
315+
vm.disks["block0"] = host_file.path
316+
317+
# Take a full snapshot and restore
318+
snapshot = vm.snapshot_full()
319+
restored_vm = microvm_factory.build_from_snapshot(snapshot)
320+
restored_vm.resume()
321+
322+
# Rescan PCI bus since no hotplug notification mechanism exists yet
323+
restored_vm.ssh.check_output("echo 1 > /sys/bus/pci/rescan")
324+
325+
# Verify a new virtio-block device entry appeared in lspci
326+
_, lspci_after, _ = restored_vm.ssh.check_output("lspci -n")
327+
new_entries = set(lspci_after.splitlines()) - set(lspci_before.splitlines())
328+
assert len(new_entries) == 1
329+
entry = new_entries.pop()
330+
assert f"{VIRTIO_PCI_VENDOR_ID:04x}:{VIRTIO_PCI_DEVICE_ID_BLOCK:04x}" in entry
331+
332+
# Discover the block device node from the PCI BDF via sysfs
333+
bdf = entry.split()[0]
334+
_, dev_name, _ = restored_vm.ssh.check_output(
335+
f"ls /sys/bus/pci/devices/0000:{bdf}/virtio*/block/"
336+
)
337+
dev_path = f"/dev/{dev_name.strip()}"
338+
339+
# Ensure the device is usable by writing a file to it and reading it back
340+
restored_vm.ssh.check_output("mkdir -p /tmp/block0_mnt")
341+
restored_vm.ssh.check_output(f"mount {dev_path} /tmp/block0_mnt")
342+
restored_vm.ssh.check_output("echo hotplug_test > /tmp/block0_mnt/test")
343+
_, stdout, _ = restored_vm.ssh.check_output("cat /tmp/block0_mnt/test")
344+
assert stdout.strip() == "hotplug_test"
345+
346+
347+
def test_hotplug_max_devices(uvm_any_with_pci):
348+
"""
349+
Test that hotplugging more devices than available PCI slots is rejected.
350+
"""
351+
pci_max_slots = 32
352+
vm = uvm_any_with_pci
353+
354+
# Count how many PCI slots are already in use
355+
_, lspci, _ = vm.ssh.check_output("lspci -n")
356+
used_slots = len(lspci.strip().splitlines())
357+
free_slots = pci_max_slots - used_slots
358+
359+
for i in range(free_slots):
360+
host_file = drive_tools.FilesystemFile(
361+
os.path.join(vm.fsfiles, f"block{i}"), size=1
362+
)
363+
vm.api.drive.put(
364+
drive_id=f"block{i}",
365+
path_on_host=vm.create_jailed_resource(host_file.path),
366+
is_root_device=False,
367+
is_read_only=False,
368+
)
369+
370+
# Verify all PCI slots are occupied
371+
vm.ssh.check_output("echo 1 > /sys/bus/pci/rescan")
372+
_, lspci, _ = vm.ssh.check_output("lspci -n")
373+
assert len(lspci.strip().splitlines()) == pci_max_slots
374+
375+
# The next hotplug must fail — no PCI slots left
376+
host_file = drive_tools.FilesystemFile(
377+
os.path.join(vm.fsfiles, "block_overflow"), size=1
378+
)
379+
with pytest.raises(
380+
RuntimeError, match="Could not find an available device slot on the PCI bus"
381+
):
382+
vm.api.drive.put(
383+
drive_id="block_overflow",
384+
path_on_host=vm.create_jailed_resource(host_file.path),
385+
is_root_device=False,
386+
is_read_only=False,
387+
)

0 commit comments

Comments
 (0)