Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 3 additions & 3 deletions lib/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
...

Expand Down
6 changes: 6 additions & 0 deletions lib/vif.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
37 changes: 37 additions & 0 deletions lib/vm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
76 changes: 74 additions & 2 deletions tests/guest_tools/win/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -104,3 +105,74 @@ 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)}"""
)


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]
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,
)
6 changes: 4 additions & 2 deletions tests/guest_tools/win/guest_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,7 +58,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)
Expand All @@ -75,7 +76,8 @@ 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)
wait_for_vm_xenvif_offboard(vm)
74 changes: 65 additions & 9 deletions tests/guest_tools/win/test_guest_tools_win.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
import pytest

import logging
import time

from . import PowerAction, wait_for_vm_running_and_ssh_up_without_tools
from lib.commands import SSHCommandFailed
from lib.common import wait_for
from lib.vm import VM

from . import (
WINDOWS_SHUTDOWN_COMMAND,
PowerAction,
check_vm_dns,
set_vm_dns,
vif_has_rss,
wait_for_vm_running_and_ssh_up_without_tools,
)
from .guest_tools import (
ERROR_INSTALL_FAILURE,
install_guest_tools,
uninstall_guest_tools,
)

from typing import Any, Tuple

# Requirements:
# - XCP-ng >= 8.2.
#
Expand Down Expand Up @@ -55,38 +69,80 @@
@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_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):
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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a ticket for that explosion?

Copy link
Member Author

@dinhngtu dinhngtu Oct 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, there isn't one, only a problem revealed during debugging. It's already being tracked internally.

time.sleep(5)
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")
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()
vm.ssh(WINDOWS_SHUTDOWN_COMMAND)
wait_for(vm.is_halted, "Shutdown 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):
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")
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]):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the objective of this test? I understand we want to make sure the VM still boots after changing the device ID, but why?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a test of our driver, which after the unplug rework must remain activated even if the device ID changes. It also serves as a proxy for device ID changes if the Windows Update option was toggled. It's not an exact reproduction of the situation, but since we don't yet support the C200 device, it's good enough.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment above the test function?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Also moved the device ID assert up one line.

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()
25 changes: 22 additions & 3 deletions tests/guest_tools/win/test_xenclean.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,20 @@
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,
wait_for_vm_xenvif_offboard,
)

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"])

Expand All @@ -18,11 +28,13 @@ 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()
wait_for_vm_running_and_ssh_up_without_tools(vm)
wait_for_vm_xenvif_offboard(vm)


@pytest.mark.multi_vms
Expand All @@ -46,15 +58,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)
Loading