From 68e0646641d6bbd4fd3a1488895b92409292c31a Mon Sep 17 00:00:00 2001 From: Tu Dinh Date: Thu, 11 Sep 2025 13:51:17 +0200 Subject: [PATCH 01/13] Clean up cached VM even if specified from UUID If CACHE_IMPORTED_VM is specified, the source VM is unconditionally cloned, even if it was referred to by UUID. Clean that up during teardown. Signed-off-by: Tu Dinh --- conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conftest.py b/conftest.py index caf476684..0f1f2e84d 100644 --- a/conftest.py +++ b/conftest.py @@ -469,7 +469,7 @@ def imported_vm(host, vm_ref): yield vm # teardown - if not is_uuid(vm_ref): + if CACHE_IMPORTED_VM or not is_uuid(vm_ref): logging.info("<< Destroy VM") vm.destroy(verify=True) From c857891a9f2bf29107b8ba4fceddea6fb48c7c41 Mon Sep 17 00:00:00 2001 From: Tu Dinh Date: Thu, 11 Sep 2025 18:52:41 +0200 Subject: [PATCH 02/13] host: Use Mapping for host.xe Otherwise you can't pass a dict[str, str] to host.xe, as mypy complained here: lib/vm.py:875: error: Argument 2 to "xe" of "Host" has incompatible type "dict[str, str]"; expected "dict[str, str | bool]" [arg-type] lib/vm.py:875: note: "dict" is invariant -- see https://mypy.readthedocs.io/en/stable/common_issues.html#variance lib/vm.py:875: note: Consider using "Mapping" instead, which is covariant in the value type Signed-off-by: Tu Dinh --- lib/host.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/host.py b/lib/host.py index 68e3654d7..0c76a799a 100644 --- a/lib/host.py +++ b/lib/host.py @@ -12,7 +12,7 @@ import lib.commands as commands import lib.pif as pif -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, TypedDict, Union, overload +from typing import TYPE_CHECKING, Dict, List, Literal, Mapping, Optional, TypedDict, Union, overload if TYPE_CHECKING: from lib.pool import Pool @@ -136,12 +136,12 @@ def scp(self, src, dest, check=True, suppress_fingerprint_warnings=True, local_d ) @overload - def xe(self, action: str, args: Dict[str, Union[str, bool]] = {}, *, check: bool = ..., + def xe(self, action: str, args: Mapping[str, Union[str, bool]] = {}, *, check: bool = ..., simple_output: Literal[True] = ..., minimal: bool = ..., force: bool = ...) -> str: ... @overload - def xe(self, action: str, args: Dict[str, Union[str, bool]] = {}, *, check: bool = ..., + def xe(self, action: str, args: Mapping[str, Union[str, bool]] = {}, *, check: bool = ..., simple_output: Literal[False], minimal: bool = ..., force: bool = ...) -> commands.SSHResult: ... From 46414fccacf1554a088000121131d0c6dc38c39e Mon Sep 17 00:00:00 2001 From: Tu Dinh Date: Thu, 11 Sep 2025 15:14:43 +0200 Subject: [PATCH 03/13] lib/vm: Add functions for setting memory targets Signed-off-by: Tu Dinh --- lib/vm.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/lib/vm.py b/lib/vm.py index 7ae585702..9555c2399 100644 --- a/lib/vm.py +++ b/lib/vm.py @@ -852,6 +852,43 @@ def save_to_cache(self, cache_id): logging.info(f"Marking VM {clone.uuid} as cached") clone.param_set('name-description', self.host.vm_cache_key(cache_id)) + def set_memory_limits( + self, + *, + static_min: int | str | None = None, + static_max: int | str | None = None, + dynamic_min: int | str | None = None, + dynamic_max: int | str | None = None, + ): + # Take both int and str for the memory limits since the latter is what param_get() returns. + if static_min is None: + static_min = self.param_get("memory-static-min") + if static_max is None: + static_max = self.param_get("memory-static-max") + if dynamic_min is None: + dynamic_min = self.param_get("memory-dynamic-min") + if dynamic_max is None: + dynamic_max = self.param_get("memory-dynamic-max") + params = { + "uuid": self.uuid, + "static-min": str(static_min), + "static-max": str(static_max), + "dynamic-min": str(dynamic_min), + "dynamic-max": str(dynamic_max), + } + logging.info( + f"Updating memory limits for vm {self.uuid}: " + f"static min={static_min} " + f"max={static_max} " + f"dynamic min={dynamic_min} " + f"max={dynamic_max}" + ) + return self.host.xe('vm-memory-limits-set', params) + + def set_memory_target(self, target: int | str): + logging.info(f"Setting memory target for vm {self.uuid} to {target}") + return self.host.xe('vm-memory-target-set', {"uuid": self.uuid, "target": str(target)}) + def vm_cache_key_from_def(vm_def, ref_nodeid, test_gitref): vm_name = vm_def["name"] From 88d72b546117dbd14a7c095315a223769526f5c6 Mon Sep 17 00:00:00 2001 From: Tu Dinh Date: Thu, 11 Sep 2025 15:15:46 +0200 Subject: [PATCH 04/13] Add DMC tests These tests verify a VM's responsiveness to memory target changes, and checks for several suspend bugs when DMC is enabled. Signed-off-by: Tu Dinh --- tests/misc/test_dmc.py | 144 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/misc/test_dmc.py diff --git a/tests/misc/test_dmc.py b/tests/misc/test_dmc.py new file mode 100644 index 000000000..b9b141f9e --- /dev/null +++ b/tests/misc/test_dmc.py @@ -0,0 +1,144 @@ +import pytest + +import logging + +from lib.common import wait_for +from lib.snapshot import Snapshot +from lib.vm import VM + +from typing import Tuple + +# Requirements: +# - XCP-ng >= 8.2. +# +# From --vm parameter: +# - A VM that supports ballooning. + +MEMORY_TARGET_HIGH = 8 << 30 +MEMORY_TARGET_LOW = 4 << 30 + + +class DmcMemoryTracker: + """ + Class to monitor whether a VM has reached its specified memory target. + Intended to replicate the logic used by vm-memory-target-wait: + https://github.com/xcp-ng/xcp-ng-tests/pull/348#discussion_r2399188845 + """ + + MEMORY_TARGET_TOLERANCE = 1 << 20 + MEMORY_TARGET_TIMEOUT = 256 + MEMORY_TARGET_POLL_INTERVAL = 10 + + def __init__(self, vm: VM, memory_target: int): + self._vm = vm + self._memory_target = memory_target + self._memory_actual = 0 + self._update() + + def _update(self): + """Update actual memory and register whether it has actually changed.""" + new_memory_actual = int(self._vm.param_get("memory-actual")) + if new_memory_actual != self._memory_actual: + self._memory_actual = new_memory_actual + + def is_memory_target_satisfied(self): + # memory-actual may not equal memory-target even if ballooning has finished. + # There's a tolerance value, currently defined as 1MB. + return abs(self._memory_actual - self._memory_target) <= self.MEMORY_TARGET_TOLERANCE + + def poll(self): + """Determine if ballooning has finished.""" + self._update() + return self.is_memory_target_satisfied() + + def wait(self): + wait_for(self.poll, timeout_secs=self.MEMORY_TARGET_TIMEOUT, retry_delay_secs=self.MEMORY_TARGET_POLL_INTERVAL) + + def memory_actual(self): + return self._memory_actual + + +def wait_for_vm_balloon_finished(vm: VM): + memory_target = int(vm.param_get("memory-target")) + logging.info("Wait for ballooning to finish") + tracker = DmcMemoryTracker(vm, memory_target) + try: + tracker.wait() + logging.info(f"Current memory: {tracker.memory_actual()}") + except TimeoutError: + logging.error(f"Memory target not met: expected {memory_target}, actual {tracker.memory_actual()}") + raise + + +@pytest.fixture(scope="module") +def imported_vm_and_snapshot(imported_vm: VM): + """Cache a VM's state under a snapshot to allow quick reverting of VM states and memory limits.""" + vm = imported_vm + snapshot = vm.snapshot() + yield vm, snapshot + snapshot.destroy(verify=True) + + +@pytest.fixture +def vm_with_memory_limits(imported_vm_and_snapshot: Tuple[VM, Snapshot]): + vm, snapshot = imported_vm_and_snapshot + vm.set_memory_limits(static_max=MEMORY_TARGET_HIGH, dynamic_min=MEMORY_TARGET_HIGH, dynamic_max=MEMORY_TARGET_HIGH) + snapshot = vm.snapshot() + yield vm + snapshot.revert() + + +@pytest.mark.small_vm +class TestDmc: + def start_dmc_vm(self, vm: VM): + vm.start() + vm.wait_for_vm_running_and_ssh_up() + if vm.param_get("other", "feature-balloon", accept_unknown_key=True) != "1": + pytest.skip("VM does not support ballooning") + + def test_dmc_start_low(self, vm_with_memory_limits: VM): + """Start the VM with less memory than the static max.""" + vm = vm_with_memory_limits + vm.set_memory_target(MEMORY_TARGET_LOW) + self.start_dmc_vm(vm) + wait_for_vm_balloon_finished(vm) + # restore + vm.set_memory_target(MEMORY_TARGET_HIGH) + wait_for_vm_balloon_finished(vm) + + def test_dmc_decrease(self, vm_with_memory_limits: VM): + """Decrease the memory of a VM that started without DMC.""" + vm = vm_with_memory_limits + self.start_dmc_vm(vm) + vm.set_memory_target(MEMORY_TARGET_LOW) + wait_for_vm_balloon_finished(vm) + # restore + vm.set_memory_target(MEMORY_TARGET_HIGH) + wait_for_vm_balloon_finished(vm) + + def test_dmc_suspend_pod(self, vm_with_memory_limits: VM): + """Suspend a VM with DMC and populate-on-demand enabled.""" + # In some cases, VMs would crash if they were suspended when two conditions were true: + # - The VM was started using PoD (which is automatically enabled when memory < maxmem at boot); + # - The VM is currently ballooned. + # This is an example crash log: + # (XEN) [2312934.000562] p2m_pod_demand_populate: Dom9 out of PoD memory! (tot=1048605 ents=992 dom0) + # (XEN) [2312934.000566] domain_crash called from p2m_pod_demand_populate+0x4d2/0x8b0 + # This test aims to check this scenario by setting the VM to the low memory target before booting. + vm = vm_with_memory_limits + vm.set_memory_target(MEMORY_TARGET_LOW) + self.start_dmc_vm(vm) + wait_for_vm_balloon_finished(vm) + vm.suspend(verify=True) + vm.resume() + vm.wait_for_vm_running_and_ssh_up() + + def test_dmc_suspend(self, vm_with_memory_limits: VM): + """Suspend a VM with DMC enabled.""" + vm = vm_with_memory_limits + self.start_dmc_vm(vm) + vm.set_memory_target(MEMORY_TARGET_LOW) + wait_for_vm_balloon_finished(vm) + vm.suspend(verify=True) + vm.resume() + vm.wait_for_vm_running_and_ssh_up() From 2cc17302c73ca11a43d209d4c6fc4d36322506d7 Mon Sep 17 00:00:00 2001 From: Tu Dinh Date: Thu, 11 Sep 2025 13:24:14 +0200 Subject: [PATCH 05/13] test_guest_tools_win: Add typing Signed-off-by: Tu Dinh --- tests/guest_tools/win/test_guest_tools_win.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/guest_tools/win/test_guest_tools_win.py b/tests/guest_tools/win/test_guest_tools_win.py index 31de0fc05..e5accc6e0 100644 --- a/tests/guest_tools/win/test_guest_tools_win.py +++ b/tests/guest_tools/win/test_guest_tools_win.py @@ -2,6 +2,8 @@ import logging +from lib.vm import VM + from . import PowerAction, wait_for_vm_running_and_ssh_up_without_tools from .guest_tools import ( ERROR_INSTALL_FAILURE, @@ -9,6 +11,8 @@ uninstall_guest_tools, ) +from typing import Any, Tuple + # Requirements: # - XCP-ng >= 8.2. # @@ -55,11 +59,11 @@ @pytest.mark.multi_vms @pytest.mark.usefixtures("windows_vm") class TestGuestToolsWindows: - def test_tools_after_reboot(self, vm_install_test_tools_per_test_class): + def test_tools_after_reboot(self, vm_install_test_tools_per_test_class: VM): vm = vm_install_test_tools_per_test_class assert vm.are_windows_tools_working() - def test_drivers_detected(self, vm_install_test_tools_per_test_class): + def test_drivers_detected(self, vm_install_test_tools_per_test_class: VM): vm = vm_install_test_tools_per_test_class assert vm.are_windows_tools_working() @@ -67,7 +71,7 @@ def test_drivers_detected(self, vm_install_test_tools_per_test_class): @pytest.mark.multi_vms @pytest.mark.usefixtures("windows_vm") class TestGuestToolsWindowsDestructive: - def test_uninstall_tools(self, vm_install_test_tools_no_reboot): + def test_uninstall_tools(self, vm_install_test_tools_no_reboot: VM): vm = vm_install_test_tools_no_reboot vm.reboot() wait_for_vm_running_and_ssh_up_without_tools(vm) @@ -75,13 +79,15 @@ def test_uninstall_tools(self, vm_install_test_tools_no_reboot): uninstall_guest_tools(vm, action=PowerAction.Reboot) assert vm.are_windows_tools_uninstalled() - def test_uninstall_tools_early(self, vm_install_test_tools_no_reboot): + def test_uninstall_tools_early(self, vm_install_test_tools_no_reboot: VM): vm = vm_install_test_tools_no_reboot logging.info("Uninstall Windows PV drivers before rebooting") uninstall_guest_tools(vm, action=PowerAction.Reboot) assert vm.are_windows_tools_uninstalled() - def test_install_with_other_tools(self, vm_install_other_drivers, guest_tools_iso): + def test_install_with_other_tools( + self, vm_install_other_drivers: Tuple[VM, dict[str, Any]], guest_tools_iso: dict[str, Any] + ): vm, param = vm_install_other_drivers if param["upgradable"]: pytest.xfail("Upgrades may require multiple reboots and are not testable yet") From dd26f0f84341de1b45f00e6d6aecdc4ce10c1571 Mon Sep 17 00:00:00 2001 From: Tu Dinh Date: Wed, 3 Sep 2025 16:56:31 +0200 Subject: [PATCH 06/13] lib/vif: Add VIF plug/unplug methods Signed-off-by: Tu Dinh --- lib/vif.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/vif.py b/lib/vif.py index ffa4d273a..0a19ee5b0 100644 --- a/lib/vif.py +++ b/lib/vif.py @@ -7,6 +7,12 @@ def __init__(self, uuid, vm): self.uuid = uuid self.vm = vm + def plug(self): + self.vm.host.xe("vif-plug", {'uuid': self.uuid}) + + def unplug(self): + self.vm.host.xe("vif-unplug", {'uuid': self.uuid}) + def param_get(self, param_name, key=None, accept_unknown_key=False): return _param_get(self.vm.host, VIF.xe_prefix, self.uuid, param_name, key, accept_unknown_key) From b47867054c6e6b0c08e0ff167d9399b0eebe0912 Mon Sep 17 00:00:00 2001 From: Tu Dinh Date: Thu, 11 Sep 2025 15:21:32 +0200 Subject: [PATCH 07/13] Update Windows guest tool tests Remove duplicate test_tools_after_reboot which was no longer used. Reenable upgrade tests. Add suspend test with emulated NVMe. Add device ID toggle test. Add VIF replug test. Signed-off-by: Tu Dinh --- tests/guest_tools/win/test_guest_tools_win.py | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/tests/guest_tools/win/test_guest_tools_win.py b/tests/guest_tools/win/test_guest_tools_win.py index e5accc6e0..fcfd9eb95 100644 --- a/tests/guest_tools/win/test_guest_tools_win.py +++ b/tests/guest_tools/win/test_guest_tools_win.py @@ -1,7 +1,10 @@ import pytest import logging +import time +from lib.commands import SSHCommandFailed +from lib.common import wait_for from lib.vm import VM from . import PowerAction, wait_for_vm_running_and_ssh_up_without_tools @@ -59,13 +62,19 @@ @pytest.mark.multi_vms @pytest.mark.usefixtures("windows_vm") class TestGuestToolsWindows: - def test_tools_after_reboot(self, vm_install_test_tools_per_test_class: VM): + def test_drivers_detected(self, vm_install_test_tools_per_test_class: VM): vm = vm_install_test_tools_per_test_class assert vm.are_windows_tools_working() - def test_drivers_detected(self, vm_install_test_tools_per_test_class: VM): + def test_vif_replug(self, vm_install_test_tools_per_test_class: VM): vm = vm_install_test_tools_per_test_class - assert vm.are_windows_tools_working() + vifs = vm.vifs() + for vif in vifs: + vif.unplug() + # HACK: Allow some time for the unplug to settle. If not, Windows guests have a tendency to explode. + time.sleep(5) + vif.plug() + wait_for(vm.is_ssh_up, "Wait for SSH up") @pytest.mark.multi_vms @@ -90,9 +99,25 @@ def test_install_with_other_tools( ): vm, param = vm_install_other_drivers if param["upgradable"]: - pytest.xfail("Upgrades may require multiple reboots and are not testable yet") install_guest_tools(vm, guest_tools_iso, PowerAction.Reboot, check=False) assert vm.are_windows_tools_working() else: exitcode = install_guest_tools(vm, guest_tools_iso, PowerAction.Nothing, check=False) assert exitcode == ERROR_INSTALL_FAILURE + + @pytest.mark.usefixtures("uefi_vm") + def test_uefi_vm_suspend_refused_without_tools(self, running_unsealed_windows_vm: VM): + vm = running_unsealed_windows_vm + with pytest.raises(SSHCommandFailed, match="lacks the feature"): + vm.suspend() + wait_for_vm_running_and_ssh_up_without_tools(vm) + + # Test of the unplug rework, where the driver must remain activated even if the device ID changes. + # Also serves as a "close-enough" test of vendor device toggling. + def test_toggle_device_id(self, running_unsealed_windows_vm: VM, guest_tools_iso: dict[str, Any]): + vm = running_unsealed_windows_vm + assert vm.param_get("platform", "device_id") == "0002" + install_guest_tools(vm, guest_tools_iso, PowerAction.Shutdown, check=False) + vm.param_set("platform", "0001", "device_id") + vm.start() + vm.wait_for_vm_running_and_ssh_up() From e774e165c80493e8b05a1687c4d33c66138095df Mon Sep 17 00:00:00 2001 From: Tu Dinh Date: Tue, 21 Oct 2025 02:38:51 +0200 Subject: [PATCH 08/13] windows: Add Windows VIF helper methods These methods help test VIF functionalities and the offboarding process. Signed-off-by: Tu Dinh --- tests/guest_tools/win/__init__.py | 45 +++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/tests/guest_tools/win/__init__.py b/tests/guest_tools/win/__init__.py index c499d3861..1740e894a 100644 --- a/tests/guest_tools/win/__init__.py +++ b/tests/guest_tools/win/__init__.py @@ -5,12 +5,13 @@ from data import ISO_DOWNLOAD_URL from lib.commands import SSHCommandFailed -from lib.common import wait_for +from lib.common import strtobool, wait_for from lib.host import Host from lib.sr import SR +from lib.vif import VIF from lib.vm import VM -from typing import Any, Dict, Union +from typing import Any, Dict, List, Union # HACK: I originally thought that using Stop-Computer -Force would cause the SSH session to sometimes fail. # I could never confirm this in the end, but use a slightly delayed shutdown just to be safe anyway. @@ -104,3 +105,43 @@ def insert_cd_safe(vm: VM, vdi_name: str, cd_path="D:/", retries=2): wait_for(vm.is_halted, "Wait for VM halted") raise TimeoutError(f"Waiting for CD at {cd_path} failed") + + +def vif_get_mac_without_separator(vif: VIF): + mac = vif.param_get("MAC") + assert mac is not None + return mac.replace(":", "") + + +def vif_has_rss(vif: VIF): + # Even if the Xenvif hash setting request fails, Windows can still report the NIC as having RSS enabled as long as + # the relevant OIDs are supported (Get-NetAdapterRss reports Enabled as True and Profile as Default). + # We need to explicitly check MaxProcessors to see if the hash setting request has really succeeded. + mac = vif_get_mac_without_separator(vif) + return strtobool( + vif.vm.execute_powershell_script( + rf"""(Get-NetAdapter | +Where-Object {{$_.PnPDeviceID -notlike 'root\kdnic\*' -and $_.PermanentAddress -eq '{mac}'}} | +Get-NetAdapterRss).MaxProcessors -gt 0""" + ) + ) + + +def vif_get_dns(vif: VIF): + mac = vif_get_mac_without_separator(vif) + return vif.vm.execute_powershell_script( + rf"""Import-Module DnsClient; Get-NetAdapter | +Where-Object {{$_.PnPDeviceID -notlike 'root\kdnic\*' -and $_.PermanentAddress -eq '{mac}'}} | +Get-DnsClientServerAddress -AddressFamily IPv4 | +Select-Object -ExpandProperty ServerAddresses""" + ).splitlines() + + +def vif_set_dns(vif: VIF, nameservers: List[str]): + mac = vif_get_mac_without_separator(vif) + vif.vm.execute_powershell_script( + rf"""Import-Module DnsClient; Get-NetAdapter | +Where-Object {{$_.PnPDeviceID -notlike 'root\kdnic\*' -and $_.PermanentAddress -eq '{mac}'}} | +Get-DnsClientServerAddress -AddressFamily IPv4 | +Set-DnsClientServerAddress -ServerAddresses {",".join(nameservers)}""" + ) From 02529e49484101e8ec1a9173d2551fc7b5b1464e Mon Sep 17 00:00:00 2001 From: Tu Dinh Date: Tue, 21 Oct 2025 22:29:45 +0200 Subject: [PATCH 09/13] windows: Use shutdown command in test_uninstall_tools In some edge cases, Xeniface may not have been initialized after installation, and so vm.reboot() will not work. Signed-off-by: Tu Dinh --- tests/guest_tools/win/test_guest_tools_win.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/guest_tools/win/test_guest_tools_win.py b/tests/guest_tools/win/test_guest_tools_win.py index fcfd9eb95..7827aa5c9 100644 --- a/tests/guest_tools/win/test_guest_tools_win.py +++ b/tests/guest_tools/win/test_guest_tools_win.py @@ -7,7 +7,7 @@ from lib.common import wait_for from lib.vm import VM -from . import PowerAction, wait_for_vm_running_and_ssh_up_without_tools +from . import WINDOWS_SHUTDOWN_COMMAND, PowerAction, wait_for_vm_running_and_ssh_up_without_tools from .guest_tools import ( ERROR_INSTALL_FAILURE, install_guest_tools, @@ -82,7 +82,10 @@ def test_vif_replug(self, vm_install_test_tools_per_test_class: VM): class TestGuestToolsWindowsDestructive: def test_uninstall_tools(self, vm_install_test_tools_no_reboot: VM): vm = vm_install_test_tools_no_reboot - vm.reboot() + vm.ssh(WINDOWS_SHUTDOWN_COMMAND) + wait_for(vm.is_halted, "Shutdown VM") + + vm.start() wait_for_vm_running_and_ssh_up_without_tools(vm) logging.info("Uninstall Windows PV drivers") uninstall_guest_tools(vm, action=PowerAction.Reboot) From ac1a1f84421209078dc4889f9e98c4a9a596547d Mon Sep 17 00:00:00 2001 From: Tu Dinh Date: Tue, 21 Oct 2025 02:41:05 +0200 Subject: [PATCH 10/13] windows: Test NIC setting restoration on uninstall This is flaky and needs to be explicitly tested. Use DNS as a basic, inoffensive setting that won't interfere with VM operation. Signed-off-by: Tu Dinh --- tests/guest_tools/win/__init__.py | 18 +++++++++++++++++ tests/guest_tools/win/test_guest_tools_win.py | 12 ++++++++++- tests/guest_tools/win/test_xenclean.py | 20 +++++++++++++++++-- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/tests/guest_tools/win/__init__.py b/tests/guest_tools/win/__init__.py index 1740e894a..67f94ff0c 100644 --- a/tests/guest_tools/win/__init__.py +++ b/tests/guest_tools/win/__init__.py @@ -145,3 +145,21 @@ def vif_set_dns(vif: VIF, nameservers: List[str]): Get-DnsClientServerAddress -AddressFamily IPv4 | Set-DnsClientServerAddress -ServerAddresses {",".join(nameservers)}""" ) + + +def set_vm_dns(vm: VM): + logging.info("Set VM DNS") + vif = vm.vifs()[0] + assert "1.1.1.1" not in vif_get_dns(vif) + vif_set_dns(vif, ["1.1.1.1"]) + + +def check_vm_dns(vm: VM): + # The restore task takes time to fire so wait for it + vif = vm.vifs()[0] + wait_for( + lambda: "1.1.1.1" in vif_get_dns(vif), + "Check VM DNS retained", + timeout_secs=300, + retry_delay_secs=30, + ) diff --git a/tests/guest_tools/win/test_guest_tools_win.py b/tests/guest_tools/win/test_guest_tools_win.py index 7827aa5c9..4f82e5d4b 100644 --- a/tests/guest_tools/win/test_guest_tools_win.py +++ b/tests/guest_tools/win/test_guest_tools_win.py @@ -7,7 +7,13 @@ from lib.common import wait_for from lib.vm import VM -from . import WINDOWS_SHUTDOWN_COMMAND, PowerAction, wait_for_vm_running_and_ssh_up_without_tools +from . import ( + WINDOWS_SHUTDOWN_COMMAND, + PowerAction, + check_vm_dns, + set_vm_dns, + wait_for_vm_running_and_ssh_up_without_tools, +) from .guest_tools import ( ERROR_INSTALL_FAILURE, install_guest_tools, @@ -87,9 +93,13 @@ def test_uninstall_tools(self, vm_install_test_tools_no_reboot: VM): vm.start() wait_for_vm_running_and_ssh_up_without_tools(vm) + + set_vm_dns(vm) logging.info("Uninstall Windows PV drivers") uninstall_guest_tools(vm, action=PowerAction.Reboot) + logging.info("Check tools uninstalled") assert vm.are_windows_tools_uninstalled() + check_vm_dns(vm) def test_uninstall_tools_early(self, vm_install_test_tools_no_reboot: VM): vm = vm_install_test_tools_no_reboot diff --git a/tests/guest_tools/win/test_xenclean.py b/tests/guest_tools/win/test_xenclean.py index 708d3ef42..c9c269d64 100644 --- a/tests/guest_tools/win/test_xenclean.py +++ b/tests/guest_tools/win/test_xenclean.py @@ -6,10 +6,19 @@ from lib.common import wait_for from lib.vm import VM -from . import WINDOWS_SHUTDOWN_COMMAND, insert_cd_safe, wait_for_vm_running_and_ssh_up_without_tools +from . import ( + WINDOWS_SHUTDOWN_COMMAND, + check_vm_dns, + insert_cd_safe, + set_vm_dns, + wait_for_vm_running_and_ssh_up_without_tools, +) from typing import Any, Dict, Tuple +# Test uninstallation of other drivers using the XenClean program. + + def run_xenclean(vm: VM, guest_tools_iso: Dict[str, Any]): insert_cd_safe(vm, guest_tools_iso["name"]) @@ -46,15 +55,22 @@ def test_xenclean_with_test_tools(self, vm_install_test_tools_no_reboot: VM, gue # HACK: In some cases, vm.reboot(verify=False) followed by vm.insert_cd() (as called by run_xenclean) # may cause the VM to hang at the BIOS screen; wait for VM start to avoid this issue. wait_for_vm_running_and_ssh_up_without_tools(vm) + + set_vm_dns(vm) logging.info("XenClean with test tools") run_xenclean(vm, guest_tools_iso) + logging.info("Check tools uninstalled") assert vm.are_windows_tools_uninstalled() + check_vm_dns(vm) def test_xenclean_with_other_tools(self, vm_install_other_drivers: Tuple[VM, Dict], guest_tools_iso): vm, param = vm_install_other_drivers if param.get("vendor_device"): pytest.skip("Skipping XenClean with vendor device present") - return + + set_vm_dns(vm) logging.info("XenClean with other tools") run_xenclean(vm, guest_tools_iso) + logging.info("Check tools uninstalled") assert vm.are_windows_tools_uninstalled() + check_vm_dns(vm) From d42d006025dbb3922e8d4716629edde7a17479f1 Mon Sep 17 00:00:00 2001 From: Tu Dinh Date: Tue, 21 Oct 2025 02:41:13 +0200 Subject: [PATCH 11/13] windows: Test RSS enablement RSS enablement is flaky and needs to be explicitly tested. Signed-off-by: Tu Dinh --- tests/guest_tools/win/test_guest_tools_win.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/guest_tools/win/test_guest_tools_win.py b/tests/guest_tools/win/test_guest_tools_win.py index 4f82e5d4b..5f3479ea1 100644 --- a/tests/guest_tools/win/test_guest_tools_win.py +++ b/tests/guest_tools/win/test_guest_tools_win.py @@ -12,6 +12,7 @@ PowerAction, check_vm_dns, set_vm_dns, + vif_has_rss, wait_for_vm_running_and_ssh_up_without_tools, ) from .guest_tools import ( @@ -82,6 +83,17 @@ def test_vif_replug(self, vm_install_test_tools_per_test_class: VM): vif.plug() wait_for(vm.is_ssh_up, "Wait for SSH up") + def test_rss(self, vm_install_test_tools_per_test_class: VM): + """ + Receive-side scaling is known to be broken on some driver versions. + + Test that RSS is functional for each NIC. + """ + vm = vm_install_test_tools_per_test_class + vifs = vm.vifs() + for vif in vifs: + assert vif_has_rss(vif) + @pytest.mark.multi_vms @pytest.mark.usefixtures("windows_vm") From 85bc29df8b00476d47435ffade925d4bdd3ba058 Mon Sep 17 00:00:00 2001 From: Tu Dinh Date: Tue, 21 Oct 2025 12:33:26 +0200 Subject: [PATCH 12/13] windows: Extend installation timeouts The default timeouts turned out to be insufficient for driver installs in some cases. Signed-off-by: Tu Dinh --- tests/guest_tools/win/guest_tools.py | 4 ++-- tests/guest_tools/win/test_xenclean.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/guest_tools/win/guest_tools.py b/tests/guest_tools/win/guest_tools.py index 95cdfe712..6f9404132 100644 --- a/tests/guest_tools/win/guest_tools.py +++ b/tests/guest_tools/win/guest_tools.py @@ -57,7 +57,7 @@ def install_guest_tools(vm: VM, guest_tools_iso: Dict[str, Any], action: PowerAc install_cmd += WINDOWS_SHUTDOWN_COMMAND vm.start_background_powershell(install_cmd) if action != PowerAction.Nothing: - wait_for(vm.is_halted, "Wait for VM halted") + wait_for(vm.is_halted, "Wait for VM halted", timeout_secs=600) if action == PowerAction.Reboot: vm.start() wait_for_vm_running_and_ssh_up_without_tools(vm) @@ -75,7 +75,7 @@ def uninstall_guest_tools(vm: VM, action: PowerAction): uninstall_cmd += WINDOWS_SHUTDOWN_COMMAND vm.start_background_powershell(uninstall_cmd) if action != PowerAction.Nothing: - wait_for(vm.is_halted, "Wait for VM halted") + wait_for(vm.is_halted, "Wait for VM halted", timeout_secs=600) if action == PowerAction.Reboot: vm.start() wait_for_vm_running_and_ssh_up_without_tools(vm) diff --git a/tests/guest_tools/win/test_xenclean.py b/tests/guest_tools/win/test_xenclean.py index c9c269d64..2b3ce9e7d 100644 --- a/tests/guest_tools/win/test_xenclean.py +++ b/tests/guest_tools/win/test_xenclean.py @@ -27,7 +27,8 @@ def run_xenclean(vm: VM, guest_tools_iso: Dict[str, Any]): xenclean_cmd = f"Set-Location C:\\; {xenclean_path} -NoReboot -Confirm:$false; {WINDOWS_SHUTDOWN_COMMAND}" vm.start_background_powershell(xenclean_cmd) - wait_for(vm.is_halted, "Wait for VM halted") + # XenClean sometimes takes a bit long due to all the calls to the uninstallers. We need an extended timeout. + wait_for(vm.is_halted, "Wait for VM halted", timeout_secs=900) vm.eject_cd() vm.start() From 12fb0d2fa01ea16fc634d1e77b2a8fae11f99ebf Mon Sep 17 00:00:00 2001 From: Tu Dinh Date: Tue, 21 Oct 2025 22:25:58 +0200 Subject: [PATCH 13/13] windows: Wait for Xenvif offboard after uninstall Xenvif offboard will reset the NIC, which will cause any running SSH commands to fail. Signed-off-by: Tu Dinh --- tests/guest_tools/win/__init__.py | 13 +++++++++++++ tests/guest_tools/win/guest_tools.py | 2 ++ tests/guest_tools/win/test_xenclean.py | 2 ++ 3 files changed, 17 insertions(+) diff --git a/tests/guest_tools/win/__init__.py b/tests/guest_tools/win/__init__.py index 67f94ff0c..1108cbb9a 100644 --- a/tests/guest_tools/win/__init__.py +++ b/tests/guest_tools/win/__init__.py @@ -147,6 +147,19 @@ def vif_set_dns(vif: VIF, nameservers: List[str]): ) +def wait_for_vm_xenvif_offboard(vm: VM): + # Xenvif offboard will reset the NIC, so need to wait for it to disappear first + wait_for( + lambda: strtobool( + vm.execute_powershell_script( + r'$null -eq (Get-ScheduledTask "Copy-XenVifSettings" -ErrorAction SilentlyContinue)', simple_output=True + ) + ), + timeout_secs=300, + retry_delay_secs=30, + ) + + def set_vm_dns(vm: VM): logging.info("Set VM DNS") vif = vm.vifs()[0] diff --git a/tests/guest_tools/win/guest_tools.py b/tests/guest_tools/win/guest_tools.py index 6f9404132..ab5fec863 100644 --- a/tests/guest_tools/win/guest_tools.py +++ b/tests/guest_tools/win/guest_tools.py @@ -10,6 +10,7 @@ enable_testsign, insert_cd_safe, wait_for_vm_running_and_ssh_up_without_tools, + wait_for_vm_xenvif_offboard, ) from typing import Any, Dict @@ -79,3 +80,4 @@ def uninstall_guest_tools(vm: VM, action: PowerAction): if action == PowerAction.Reboot: vm.start() wait_for_vm_running_and_ssh_up_without_tools(vm) + wait_for_vm_xenvif_offboard(vm) diff --git a/tests/guest_tools/win/test_xenclean.py b/tests/guest_tools/win/test_xenclean.py index 2b3ce9e7d..746d79cb8 100644 --- a/tests/guest_tools/win/test_xenclean.py +++ b/tests/guest_tools/win/test_xenclean.py @@ -12,6 +12,7 @@ insert_cd_safe, set_vm_dns, wait_for_vm_running_and_ssh_up_without_tools, + wait_for_vm_xenvif_offboard, ) from typing import Any, Dict, Tuple @@ -33,6 +34,7 @@ def run_xenclean(vm: VM, guest_tools_iso: Dict[str, Any]): vm.start() wait_for_vm_running_and_ssh_up_without_tools(vm) + wait_for_vm_xenvif_offboard(vm) @pytest.mark.multi_vms