From 70e04563c17e3364114fad9c5dd7acd331cb14a2 Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Fri, 5 Jun 2026 12:00:00 +0000 Subject: [PATCH 01/19] Makefile: Enable parallel test builds to cut down on build times Replace the loop with explicit top-level per-test targets so GNU make can parallelise recursive sub-makes across the selected test set. This cuts the parallel build time for the full test suite from 30s to 1.5s by enabling GNU make to schedule work across test directories. Before: time make clean all -j16 >/tmp/make.log real 0m14.743s user 0m29.195s sys 0m17.121s After: real 0m3.136s user 0m26.483s sys 0m14.107s The parallel empty build time is reduced from 6s to about 0.5s. Because the sub-makes rebuild shared objects under common/ and arch/, add a one-time bootstrap build before the parallel fan-out when more than one test is selected. Single-test builds skip the bootstrap so `TESTS=...` works like before. Signed-off-by: Bernhard Kaindl --- Makefile | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index d98a4af..418d513 100644 --- a/Makefile +++ b/Makefile @@ -59,21 +59,43 @@ export CC LD CPP INSTALL INSTALL_DATA INSTALL_DIR INSTALL_PROGRAM OBJCOPY PYTHON # By default enable all the tests TESTS ?= $(wildcard $(ROOT)/tests/*) -.PHONY: all -all: - @set -e; for D in $(TESTS); do \ - [ ! -e $$D/Makefile ] && continue; \ - $(MAKE) -C $$D build; \ - done +# Convert the selected test directories into explicit top-level targets so GNU +# make can schedule independent tests in parallel, rather than hiding the work +# behind one shell loop. + +TEST_MAKEFILES := $(wildcard $(TESTS:%=%/Makefile)) +BUILD_TARGETS := $(patsubst %/Makefile,%/.build,$(TEST_MAKEFILES)) +INSTALL_TARGETS := $(patsubst %/Makefile,%/.install,$(TEST_MAKEFILES)) + +# Multiple test sub-makes rebuild the same objects under common/ and arch/. +# Seed those shared artefacts once before the parallel fan-out, but skip the +# bootstrap entirely when only one test was selected so TESTS filtering keeps +# its expected no-op behaviour. + +ifneq ($(word 2,$(BUILD_TARGETS)),) +SHARED_BOOTSTRAP_TARGET := $(firstword $(BUILD_TARGETS:.build=.shared-ready)) +endif + +.PHONY: all $(BUILD_TARGETS) $(INSTALL_TARGETS) +all: $(SHARED_BOOTSTRAP_TARGET) $(BUILD_TARGETS) + +# Leading '+' preserves jobserver recursion when the parent was run with -j. +$(SHARED_BOOTSTRAP_TARGET): + +$(MAKE) -C $(@D) build + +# Each selected test directory now appears as a first-class prerequisite. +$(BUILD_TARGETS): | $(SHARED_BOOTSTRAP_TARGET) + +$(MAKE) -C $(@D) build .PHONY: install install: @$(INSTALL_DIR) $(DESTDIR)$(xtfdir) $(INSTALL_PROGRAM) xtf-runner $(DESTDIR)$(xtfdir) - @set -e; for D in $(TESTS); do \ - [ ! -e $$D/Makefile ] && continue; \ - $(MAKE) -C $$D install; \ - done + +install: $(SHARED_BOOTSTRAP_TARGET) $(INSTALL_TARGETS) + +$(INSTALL_TARGETS): | $(SHARED_BOOTSTRAP_TARGET) + +$(MAKE) -C $(@D) install define all_sources find include/ arch/ common/ tests/ -name "*.[hcsS]" From ab4e561d7859e818092c0b86feeebcfcd846477f Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Fri, 5 Jun 2026 12:00:00 +0000 Subject: [PATCH 02/19] Change build targets to run from the workspace's top-level directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the Makefile to build and install targets from the top-level directory, rather than from within each test directory. This helps ensure the relative paths in the build output match the source file paths, making it easier to navigate from the build output to the source files when diagnosing build errors. Before: main.c: In function ‘test_main’: main.c:17:11: error: ‘cpu_has_intel_vmx’ undeclared (first use in this function); did you mean ‘cpu_has_vmx’? 17 | if ( !cpu_has_intel_vmx ) | ^~~~~~~~~~~~~~~~~ | cpu_has_vmx After: tests/nested-vmx/main.c: In function ‘test_main’: tests/nested-vmx/main.c:17:11: error: ‘cpu_has_intel_vmx’ undeclared (first use in this function); did you mean ‘cpu_has_vmx’? 17 | if ( !cpu_has_intel_vmx ) | ^~~~~~~~~~~~~~~~~ | cpu_has_vmx Signed-off-by: Bernhard Kaindl --- build/common.mk | 36 ++++++++++++++++++++++++++++++------ build/gen.mk | 23 +++++++++++++++++++---- build/mkcfg.py | 2 +- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/build/common.mk b/build/common.mk index a6e270f..94dd4da 100644 --- a/build/common.mk +++ b/build/common.mk @@ -17,7 +17,22 @@ $(foreach env,$(64BIT_ENVIRONMENTS),$(eval $(env)_arch := x86_64)) comma := , -COMMON_FLAGS := -pipe -nostdinc -I$(ROOT)/include -I$(ROOT)/arch/x86/include -MMD -MP +COMMON_FLAGS := -pipe -nostdinc -Iinclude -Iarch/x86/include -MMD -MP + +# Compile from the repository root so diagnostics use workspace-relative paths +# even when the owning makefile lives in a test subdirectory. +CURDIR_REL := $(patsubst $(ROOT)/%,%,$(CURDIR)) +VPATH := $(ROOT) + +# Convert make's per-test view of a path into the root-relative spelling used +# on the compiler command line after `cd $(ROOT)`. +root-path = $(patsubst $(ROOT)/%,%,$(if $(filter /%,$(1)),$(1),$(if $(CURDIR_REL),$(CURDIR_REL)/$(1),$(1)))) + +# Dependency files still live next to their outputs so the existing `-include` +# sites keep working from recursive sub-makes, but we address them via an +# absolute path while generating them from the repository root. +dep-path = $(call root-path,$(patsubst %.o,%.d,$(patsubst %.lds,%.d,$(1)))) +dep-file = $(if $(filter /%,$(1)),$(patsubst %.o,%.d,$(patsubst %.lds,%.d,$(1))),$(ROOT)/$(call dep-path,$(1))) cc-option = $(shell if [ -z "`echo 'int p=1;' | $(CC) $(1) -c -o /dev/null -x c - 2>&1`" ]; \ then echo y; else echo n; fi) @@ -73,23 +88,32 @@ DEPS-$(1) = \ # Generate .lds with appropriate flags %/link-$(1).lds: $(ROOT)/common/link.lds.S - $$(CPP) $$(AFLAGS_$(1)) -P $$< -o $$@ + # Run the preprocessor from $(ROOT) so diagnostics mention e.g. arch/... + # and tests/... instead of paths relative to the current test directory. + cd $(ROOT) && $$(CPP) $$(AFLAGS_$(1)) -MT $$@ -MF $$(call dep-file,$$@) -P \ + $$(call root-path,$$<) -o $$(call root-path,$$@) # Generate a per-arch .o from .S %-$($(1)_arch).o: %.S - $$(CC) $$(AFLAGS_$($(1)_arch)) -c $$< -o $$@ + # The command runs from $(ROOT), but the target name remains the original + # object path so the rest of the dependency graph does not change. + cd $(ROOT) && $$(CC) $$(AFLAGS_$($(1)_arch)) -MT $$@ -MF $$(call dep-file,$$@) -c \ + $$(call root-path,$$<) -o $$(call root-path,$$@) # Generate a per-arch .o from .c %-$($(1)_arch).o: %.c - $$(CC) $$(CFLAGS_$($(1)_arch)) -c $$< -o $$@ + cd $(ROOT) && $$(CC) $$(CFLAGS_$($(1)_arch)) -MT $$@ -MF $$(call dep-file,$$@) -c \ + $$(call root-path,$$<) -o $$(call root-path,$$@) # Generate a per-env .o from .S %-$(1).o: %.S - $$(CC) $$(AFLAGS_$(1)) -c $$< -o $$@ + cd $(ROOT) && $$(CC) $$(AFLAGS_$(1)) -MT $$@ -MF $$(call dep-file,$$@) -c \ + $$(call root-path,$$<) -o $$(call root-path,$$@) # Generate a per-env .o from .c %-$(1).o: %.c - $$(CC) $$(CFLAGS_$(1)) -c $$< -o $$@ + cd $(ROOT) && $$(CC) $$(CFLAGS_$(1)) -MT $$@ -MF $$(call dep-file,$$@) -c \ + $$(call root-path,$$<) -o $$(call root-path,$$@) endef diff --git a/build/gen.mk b/build/gen.mk index df474a1..4353659 100644 --- a/build/gen.mk +++ b/build/gen.mk @@ -48,15 +48,30 @@ hvm64-format := $(firstword $(filter elf32-x86-64,$(shell $(OBJCOPY) --help)) el define PERENV_build ifneq ($(1),hvm64) +# # Generic link line for most environments +# +# Link from $(ROOT) for the same reason as compilation: preserve +# diagnostic paths relative to the workspace root while keeping +# the make target names unchanged for recursive callers. +# test-$(1)-$(NAME): $$(DEPS-$(1)) $$(link-$(1)) - $(LD) $$(LDFLAGS_$(1)) $$(DEPS-$(1)) -o $$@ + cd $(ROOT) && $(LD) $$(LDFLAGS_$(1)) \ + $$(foreach dep,$$(DEPS-$(1)),$$(call root-path,$$(dep))) \ + -o $$(call root-path,$$@) else +# # hvm64 needs linking normally, then converting to elf32-x86-64 or elf32-i386 +# +# Keep the temporary file root-relative as well so objcopy sees +# the same path spelling that the linker emitted diagnostics for. +# test-$(1)-$(NAME): $$(DEPS-$(1)) $$(link-$(1)) - $(LD) $$(LDFLAGS_$(1)) $$(DEPS-$(1)) -o $$@.tmp - $(OBJCOPY) $$@.tmp -O $(hvm64-format) $$@ - rm -f $$@.tmp + cd $(ROOT) && $(LD) $$(LDFLAGS_$(1)) \ + $$(foreach dep,$$(DEPS-$(1)),$$(call root-path,$$(dep))) \ + -o $$(call root-path,$$@).tmp + cd $(ROOT) && $(OBJCOPY) $$(call root-path,$$@).tmp -O $(hvm64-format) $$(call root-path,$$@) + rm -f $$(call root-path,$$@).tmp endif cfg-$(1) ?= $(defcfg-$($(1)_guest)) diff --git a/build/mkcfg.py b/build/mkcfg.py index 65d5d03..8619172 100755 --- a/build/mkcfg.py +++ b/build/mkcfg.py @@ -11,7 +11,7 @@ _, out, defcfg, vcpus, extracfg, varycfg = sys.argv # Evaluate environment and name from $OUT -_, env, name = out.split('.')[0].split('-', 2) +_, env, name = os.path.basename(out).split('.')[0].split('-', 2) # Possibly split apart the variation suffix variation = '' From a31eaf5b506d69ce8438bbcaf765217eaa515bbe Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Mon, 8 Jun 2026 12:00:00 +0000 Subject: [PATCH 03/19] pre-commit config: remove reorder-python-imports (breaks black formatting) The reorder-python-imports hook removes an empty line which breaks black formatting (black is a standard, widely-used Python formatter). It also unconditionally forces to split all imports using "from" imports into one line per "from" which is non-standard in Python. Removing it makes room for the standard Python isort hook and allows to ensure PEP8-compliant formatting using the Python black formatter. While at it, also add brief installation notes for using pre-commit. Signed-off-by: Bernhard Kaindl --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2f1c3ce..c3ac6ad 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,11 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks # +# Installation notes: +# - Install pre-commit, e.g. via pip: +# pip install pre-commit +# - Enable it as git pre-commit hook for the current repository: +# pre-commit install fail_fast: false default_stages: [commit, push] @@ -28,11 +33,6 @@ repos: args: ['--fix=lf'] - id: trailing-whitespace -- repo: https://github.com/asottile/reorder-python-imports - rev: v3.12.0 - hooks: - - id: reorder-python-imports - - repo: local hooks: - id: git-diff # https://github.com/pre-commit/pre-commit/issues/1712 From 378cafc77139c94d7597af6782549b4df62b4b1c Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Mon, 8 Jun 2026 12:00:00 +0000 Subject: [PATCH 04/19] pre-commit: Apply migrate-config to fix deprecated stage names Fix this warning by pre-commit: top-level `default_stages` uses deprecated stage names (commit, push) which will be removed in a future version. run: `pre-commit migrate-config` to automatically fix this. Signed-off-by: Bernhard Kaindl --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c3ac6ad..1f97450 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ # pre-commit install fail_fast: false -default_stages: [commit, push] +default_stages: [pre-commit, pre-push] repos: - repo: https://github.com/pre-commit/pre-commit-hooks From 02234f7ad17afc9687f6daa1e8b0cd6d8f0f8ed9 Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Fri, 12 Jun 2026 12:00:00 +0000 Subject: [PATCH 05/19] CI, xtf-runner, mkcfg.py, mkinfo.py: Remove Python 2.7 compatibility Signed-off-by: Bernhard Kaindl --- .pre-commit-config.yaml | 1 - .pylintrc | 78 +++++------------------------------------ build/mkcfg.py | 1 - build/mkinfo.py | 3 +- xtf-runner | 12 ++----- 5 files changed, 12 insertions(+), 83 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1f97450..fc65ea6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,7 +28,6 @@ repos: - id: destroyed-symlinks - id: end-of-file-fixer - id: fix-byte-order-marker - - id: fix-encoding-pragma - id: mixed-line-ending args: ['--fix=lf'] - id: trailing-whitespace diff --git a/.pylintrc b/.pylintrc index 7c7e50c..8a3106d 100644 --- a/.pylintrc +++ b/.pylintrc @@ -8,9 +8,6 @@ # pygtk.require(). #init-hook= -# Profiled execution. -profile=no - # Add files or directories to the blacklist. They should be base names, not # paths. ignore=CVS @@ -37,7 +34,7 @@ extension-pkg-whitelist= # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time. See also the "--disable" option for examples. -#enable= +enable=unneeded-not,consider-using-set-comprehension # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifiers separated by comma (,) or put this @@ -48,7 +45,7 @@ extension-pkg-whitelist= # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=bad-whitespace, bad-continuation, global-statement, star-args +disable=import-error,global-statement,unspecified-encoding,consider-using-f-string [REPORTS] @@ -58,11 +55,6 @@ disable=bad-whitespace, bad-continuation, global-statement, star-args # mypackage.mymodule.MyReporterClass. output-format=text -# Put messages in a separate file for each module / package specified on the -# command line instead of printing them on stdout. Reports (if any) will be -# written in a file name "pylint_global.[txt|html]". -files-output=no - # Tells whether to display a full report or only the messages reports=no @@ -73,10 +65,6 @@ reports=no # (RP0004). evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) -# Add a comment according to your evaluation note. This is used by the global -# evaluation report (RP0004). -comment=no - # Template used to display messages. This is a python new-style format string # used to format the message information. See doc for all details #msg-template= @@ -118,10 +106,6 @@ ignored-modules= # (useful for classes with attributes dynamically set). ignored-classes=SQLObject -# When zope mode is activated, add a predefined set of Zope acquired attributes -# to generated-members. -zope=no - # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E0201 when accessed. Python regular # expressions are accepted. @@ -138,7 +122,7 @@ logging-modules=logging [FORMAT] # Maximum number of characters on a single line. -max-line-length=80 +max-line-length=88 # Regexp for a line that is allowed to be longer than the limit. ignore-long-lines=^\s*(# )??$ @@ -147,9 +131,6 @@ ignore-long-lines=^\s*(# )??$ # else. single-line-if-stmt=no -# List of optional constructs for which whitespace checking is disabled -no-space-check=trailing-comma,dict-separator - # Maximum number of lines in a module max-module-lines=1000 @@ -163,12 +144,6 @@ indent-after-paren=4 [BASIC] -# Required attributes for module, separated by a comma -required-attributes= - -# List of builtins function names that should not be used, separated by a comma -bad-functions=apply,input - # Good variable names which should always be accepted, separated by a comma good-names=e,i,j,k,ex,Run,_ @@ -179,68 +154,35 @@ bad-names=foo,bar,baz,toto,tutu,tata # the name regexes allow several styles. name-group= -# Include a hint for the correct naming format with invalid-name -include-naming-hint=no - # Regular expression matching correct function names -function-rgx=[a-z_][a-z0-9_]{1,35}$ - -# Naming hint for function names -function-name-hint=[a-z_][a-z0-9_]{2,30}$ +function-rgx=[a-z_][a-z0-9_]{1,45}$ # Regular expression matching correct variable names variable-rgx=[a-z_][a-z0-9_]{1,35}$ -# Naming hint for variable names -variable-name-hint=[a-z_][a-z0-9_]{2,30}$ - # Regular expression matching correct constant names const-rgx=(([A-Za-z_][A-Za-z0-9_]*)|(__.*__))$ -# Naming hint for constant names -const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - # Regular expression matching correct attribute names attr-rgx=[a-z_][a-z0-9_]{1,35}$ -# Naming hint for attribute names -attr-name-hint=[a-z_][a-z0-9_]{2,30}$ - # Regular expression matching correct argument names argument-rgx=[a-z_][a-z0-9_]{1,35}$ -# Naming hint for argument names -argument-name-hint=[a-z_][a-z0-9_]{2,30}$ - # Regular expression matching correct class attribute names class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ -# Naming hint for class attribute names -class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ - # Regular expression matching correct inline iteration names inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ -# Naming hint for inline iteration names -inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ - # Regular expression matching correct class names class-rgx=[A-Z_][a-zA-Z0-9]+$ -# Naming hint for class names -class-name-hint=[A-Z_][a-zA-Z0-9]+$ - # Regular expression matching correct module names module-rgx=(([a-z_][a-z0-9_-]*)|([A-Z][a-zA-Z0-9-]+))$ -# Naming hint for module names -module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - # Regular expression matching correct method names -method-rgx=[a-z_][a-z0-9_]{1,35}$ - -# Naming hint for method names -method-name-hint=[a-z_][a-z0-9_]{2,30}$ +method-rgx=[a-z_][a-z0-9_]{1,45}$ # Regular expression which should only match function or class names that do # not require a docstring. @@ -285,10 +227,6 @@ int-import-graph= [CLASSES] -# List of interface methods to ignore, separated by a comma. This is used for -# instance to not check methods defines in Zope's Interface base class. -ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by - # List of method names used to declare (i.e. assign) instance attributes. defining-attr-methods=__init__,__new__,setUp @@ -302,14 +240,14 @@ valid-metaclass-classmethod-first-arg=mcs [DESIGN] # Maximum number of arguments for function / method -max-args=5 +max-args=7 # Argument names that match this expression will be ignored. Default to name # with leading underscore ignored-argument-names=_.* # Maximum number of locals for function / method body -max-locals=50 +max-locals=60 # Maximum number of return / yield for function / method body max-returns=250 @@ -337,4 +275,4 @@ max-public-methods=20 # Exceptions that will emit a warning when being caught. Defaults to # "Exception" -overgeneral-exceptions=Exception +overgeneral-exceptions=builtins.Exception diff --git a/build/mkcfg.py b/build/mkcfg.py index 8619172..f8f006d 100755 --- a/build/mkcfg.py +++ b/build/mkcfg.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ Construct an xl configuration file for a test (from various fragments), and substitue variables appropriately. diff --git a/build/mkinfo.py b/build/mkinfo.py index 30f9983..7698991 100755 --- a/build/mkinfo.py +++ b/build/mkinfo.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- +"""Generate test info JSON files from command-line arguments.""" + import json import sys diff --git a/xtf-runner b/xtf-runner index 8e27a48..9c3a425 100755 --- a/xtf-runner +++ b/xtf-runner @@ -5,8 +5,6 @@ xtf-runner - A utility for enumerating and running XTF tests. Currently assumes the presence and availability of the `xl` toolstack. """ -from __future__ import print_function -from __future__ import unicode_literals import json import os @@ -17,12 +15,6 @@ from optparse import OptionParser from os import path from subprocess import PIPE - -# Python 2/3 compatibility -if sys.version_info >= (3, ): - basestring = str - - # Wrap Subprocess functions to use universal_newlines by default Popen = partial(subprocess.Popen, universal_newlines = True) subproc_call = partial(subprocess.call, universal_newlines = True) @@ -120,13 +112,13 @@ class TestInfo(object): """ name = test_json["name"] - if not isinstance(name, basestring): + if not isinstance(name, str): raise TypeError("Expected string for 'name', got '{0}'" .format(type(name))) self.name = name cat = test_json["category"] - if not isinstance(cat, basestring): + if not isinstance(cat, str): raise TypeError("Expected string for 'category', got '{0}'" .format(type(cat))) if not cat in all_categories: From 9733902ceb40ca345972653e8583ff1b2fe09927 Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Mon, 8 Jun 2026 12:00:00 +0000 Subject: [PATCH 06/19] build: Decrease incremental build time by using Ninja from 0.5s to 0.2s Update the build system to use Ninja for builds when available. This reduces the minimal incremental build time from 0.5s to 0.15s. This reduction helps when auto-rebuilding XTF based on inotify-watches, enabling a quick turnaround of changes. Recursive make remains available as a fallback if ninja is not available and when USE_MAKE=1 is set for explicit override. When USE_MAKE is not enabled, the top-level Makefile emits a Ninja graph from the existing test metadata and drives it with Ninja. The test Makefiles are parsed as metadata by defining XTF_METADATA_ONLY. A Python generator turns the captured metadata into a Ninja graph for binaries, cfg files, info.json, and install targets. The generated Ninja graph also preserves the existing DESTDIR install layout, including xtf-runner and all selected test artifacts under $(xtftestdir). As before, the build system is designed to be portable and does not require Ninja to be installed, but it can take advantage of it when available. When ninja is used, the output of the build is also more concise and focused on the actual build steps, without the noise of make's recursive invocation and command echoing. Signed-off-by: Bernhard Kaindl --- .gitignore | 5 + Makefile | 134 +++++++++ build/common.mk | 10 + build/core.mk | 17 ++ build/gen-ninja.py | 689 ++++++++++++++++++++++++++++++++++++++++++++ build/gen.mk | 7 + build/load-tests.mk | 48 +++ 7 files changed, 910 insertions(+) create mode 100644 build/core.mk create mode 100755 build/gen-ninja.py create mode 100644 build/load-tests.mk diff --git a/.gitignore b/.gitignore index dcacdf3..5402e64 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,12 @@ *.pyc *.pyo *.swp +/.ninja_deps +/.ninja_log /arch/*/*.lds +/build/.xtf.ninja.*.context +/build/xtf.ninja +/build/xtf.ninja.vars /cscope.* /dist/ /docs/autogenerated/ diff --git a/Makefile b/Makefile index 418d513..4e6306e 100644 --- a/Makefile +++ b/Makefile @@ -59,6 +59,29 @@ export CC LD CPP INSTALL INSTALL_DATA INSTALL_DIR INSTALL_PROGRAM OBJCOPY PYTHON # By default enable all the tests TESTS ?= $(wildcard $(ROOT)/tests/*) +# Prefer Ninja when it is available, but keep the recursive make path as the +# fallback and as an explicit override via USE_MAKE=1. +NINJA_AVAILABLE := $(if $(shell command -v ninja 2>/dev/null),1,0) +USE_MAKE ?= $(if $(NINJA_AVAILABLE),0,1) + +ACTIVE_GOALS := $(if $(MAKECMDGOALS),$(MAKECMDGOALS),all) +NINJA_GOALS := ninja-vars ninja-file ninja-build ninja-install +METADATA_GOALS := $(NINJA_GOALS) +COMMON_GOALS := $(NINJA_GOALS) + +ifeq ($(USE_MAKE),0) +METADATA_GOALS += all install +COMMON_GOALS += all install +endif + +ifneq ($(filter $(METADATA_GOALS),$(ACTIVE_GOALS)),) +include $(ROOT)/build/load-tests.mk +endif + +ifneq ($(filter $(COMMON_GOALS),$(ACTIVE_GOALS)),) +include $(ROOT)/build/common.mk +endif + # Convert the selected test directories into explicit top-level targets so GNU # make can schedule independent tests in parallel, rather than hiding the work # behind one shell loop. @@ -76,6 +99,15 @@ ifneq ($(word 2,$(BUILD_TARGETS)),) SHARED_BOOTSTRAP_TARGET := $(firstword $(BUILD_TARGETS:.build=.shared-ready)) endif +ifeq ($(USE_MAKE),0) + +.PHONY: all install +all: ninja-build + +install: ninja-install + +else + .PHONY: all $(BUILD_TARGETS) $(INSTALL_TARGETS) all: $(SHARED_BOOTSTRAP_TARGET) $(BUILD_TARGETS) @@ -97,6 +129,8 @@ install: $(SHARED_BOOTSTRAP_TARGET) $(INSTALL_TARGETS) $(INSTALL_TARGETS): | $(SHARED_BOOTSTRAP_TARGET) +$(MAKE) -C $(@D) install +endif + define all_sources find include/ arch/ common/ tests/ -name "*.[hcsS]" endef @@ -106,6 +140,104 @@ cscope: $(all_sources) > cscope.files cscope -b -q -k +NINJA_CONTEXT_HASH := $(shell \ + printf '%s\n' \ + '$(sort $(TESTS))' \ + '$(CC)' \ + '$(CPP)' \ + '$(LD)' \ + '$(OBJCOPY)' \ + '$(PYTHON)' \ + '$(LLVM)' \ + '$(CROSS_COMPILE)' \ + | sha1sum | cut -d' ' -f1) +NINJA_CONTEXT_STAMP := $(ROOT)/build/.xtf.ninja.$(NINJA_CONTEXT_HASH).context +NINJA_VARS_FILE := $(ROOT)/build/xtf.ninja.vars +NINJA_FILE := $(ROOT)/build/xtf.ninja +HVM64_FORMAT := $(firstword \ + $(filter elf32-x86-64,$(shell $(OBJCOPY) --help)) \ + elf32-i386) +NINJA_METADATA_INPUTS := \ + $(ROOT)/Makefile \ + $(ROOT)/build/common.mk \ + $(ROOT)/build/core.mk \ + $(ROOT)/build/files.mk \ + $(ROOT)/build/gen.mk \ + $(ROOT)/build/load-tests.mk \ + $(ROOT)/build/gen-ninja.py \ + $(TEST_MAKEFILES) \ + $(wildcard $(ROOT)/Makefile.local) + +.PHONY: ninja-vars ninja-file ninja-build ninja-install + +ninja-vars: + @printf 'global\t%s\t%s\n' ROOT '$(ROOT)' + @printf 'global\t%s\t%s\n' CC '$(CC)' + @printf 'global\t%s\t%s\n' CPP '$(CPP)' + @printf 'global\t%s\t%s\n' LD '$(LD)' + @printf 'global\t%s\t%s\n' OBJCOPY '$(OBJCOPY)' + @printf 'global\t%s\t%s\n' PYTHON '$(PYTHON)' + @printf 'global\t%s\t%s\n' DESTDIR '$(DESTDIR)' + @printf 'global\t%s\t%s\n' xtfdir '$(xtfdir)' + @printf 'global\t%s\t%s\n' xtftestdir '$(xtftestdir)' + @printf 'global\t%s\t%s\n' INSTALL '$(INSTALL)' + @printf 'global\t%s\t%s\n' INSTALL_DATA '$(INSTALL_DATA)' + @printf 'global\t%s\t%s\n' INSTALL_DIR '$(INSTALL_DIR)' + @printf 'global\t%s\t%s\n' INSTALL_PROGRAM '$(INSTALL_PROGRAM)' + @printf 'global\t%s\t%s\n' HVM64_FORMAT '$(HVM64_FORMAT)' + @printf 'objects\t%s\t%s\n' perbits '$(obj-perbits)' + @printf 'objects\t%s\t%s\n' perenv '$(obj-perenv)' + @$(foreach env,$(ALL_ENVIRONMENTS), \ + printf 'env\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + '$(env)' \ + '$($(env)_guest)' \ + '$($(env)_arch)' \ + '$(AFLAGS_$($(env)_arch))' \ + '$(CFLAGS_$($(env)_arch))' \ + '$(AFLAGS_$(env))' \ + '$(CFLAGS_$(env))' \ + '$(link-$(env))' \ + '$(LDFLAGS_$(env))' \ + '$(defcfg-$($(env)_guest))'; \ + ) + @$(foreach env,$(ALL_ENVIRONMENTS), \ + printf 'env_objects\t%s\t%s\n' \ + '$(env)' \ + '$(obj-$(env))'; \ + ) + @$(foreach key,$(REGISTERED_TESTS), \ + printf 'test\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + '$(key)' \ + '$(TEST_DIR_$(key))' \ + '$(TEST_NAME_$(key))' \ + '$(TEST_CATEGORY_$(key))' \ + '$(TEST_ENVS_$(key))' \ + '$(TEST_EXTRA_CFG_$(key))' \ + '$(TEST_VARY_CFG_$(key))' \ + '$(TEST_VCPUS_$(key))' \ + '$(TEST_LOCAL_OBJ_PERENV_$(key))'; \ + ) + +$(NINJA_CONTEXT_STAMP): + @mkdir -p $(dir $@) + @: > $@ + +$(NINJA_VARS_FILE): $(NINJA_CONTEXT_STAMP) $(NINJA_METADATA_INPUTS) + @$(MAKE) -s ninja-vars TESTS='$(TESTS)' > $@.tmp + @if ! cmp -s $@.tmp $@ 2>/dev/null; then mv -f $@.tmp $@; else rm -f $@.tmp; fi + +$(NINJA_FILE): $(NINJA_VARS_FILE) $(ROOT)/build/gen-ninja.py + @cd $(ROOT) && $(PYTHON) build/gen-ninja.py $(NINJA_VARS_FILE) $@.tmp + @if ! cmp -s $@.tmp $@ 2>/dev/null; then mv -f $@.tmp $@; else rm -f $@.tmp; fi + +ninja-file: $(NINJA_FILE) + +ninja-build: ninja-file + cd $(ROOT) && ninja -f $(NINJA_FILE) + +ninja-install: ninja-file + cd $(ROOT) && ninja -f $(NINJA_FILE) install + .PHONY: gtags gtags: $(all_sources) | gtags -f - @@ -115,6 +247,8 @@ clean: find . \( -name "*.o" -o -name "*.d" -o -name "*.lds" \) -delete find tests/ \( -perm -a=x -name "test-*" -o -name "test-*.cfg" \ -o -name "info.json" \) -delete + rm -f $(ROOT)/build/{.,}xtf.ninja* .ninja_deps .ninja_log \ + $(NINJA_VARS_FILE) $(NINJA_FILE) .PHONY: distclean distclean: clean diff --git a/build/common.mk b/build/common.mk index 94dd4da..941fab0 100644 --- a/build/common.mk +++ b/build/common.mk @@ -1,3 +1,11 @@ +ifeq ($(XTF_METADATA_ONLY),1) + +# In metadata-loading mode, the top-level loader already provided the shared +# environment constants. This include stays as a compatibility shim so current +# test Makefiles can be parsed without emitting build rules. + +else + ALL_CATEGORIES := special functional xsa utility in-development ALL_ENVIRONMENTS := pv64 pv32pae hvm64 hvm32pae hvm32pse hvm32 @@ -122,3 +130,5 @@ $(foreach env,$(ALL_ENVIRONMENTS),$(eval $(call PERENV_setup,$(env)))) define move-if-changed if ! cmp -s $(1) $(2); then mv -f $(1) $(2); else rm -f $(1); fi endef + +endif diff --git a/build/core.mk b/build/core.mk new file mode 100644 index 0000000..8315d20 --- /dev/null +++ b/build/core.mk @@ -0,0 +1,17 @@ +ALL_CATEGORIES := special functional xsa utility in-development nested-svm + +ALL_ENVIRONMENTS := pv64 pv32pae hvm64 hvm32pae hvm32pse hvm32 + +PV_ENVIRONMENTS := $(filter pv%,$(ALL_ENVIRONMENTS)) +HVM_ENVIRONMENTS := $(filter hvm%,$(ALL_ENVIRONMENTS)) +32BIT_ENVIRONMENTS := $(filter pv32% hvm32%,$(ALL_ENVIRONMENTS)) +64BIT_ENVIRONMENTS := $(filter pv64% hvm64%,$(ALL_ENVIRONMENTS)) +SVM_ENVIRONMENTS := hvm64 + +# $(env)_guest => pv or hvm mapping +$(foreach env,$(PV_ENVIRONMENTS),$(eval $(env)_guest := pv)) +$(foreach env,$(HVM_ENVIRONMENTS),$(eval $(env)_guest := hvm)) + +# $(env)_arch => x86_32/64 mapping +$(foreach env,$(32BIT_ENVIRONMENTS),$(eval $(env)_arch := x86_32)) +$(foreach env,$(64BIT_ENVIRONMENTS),$(eval $(env)_arch := x86_64)) diff --git a/build/gen-ninja.py b/build/gen-ninja.py new file mode 100755 index 0000000..e765561 --- /dev/null +++ b/build/gen-ninja.py @@ -0,0 +1,689 @@ +#!/usr/bin/env python3 +"""Generate build/xtf.ninja from the metadata manifest emitted by Make. + +The top-level Makefile first reduces the current build configuration to a small +tab-separated manifest. This script turns that manifest into the concrete +Ninja file used to build the selected tests and install their outputs. + +At a high level, the script follows the same structure as the recursive make +path: read the build configuration, resolve the per-environment and per-test +data, then emit the concrete commands needed for objects, linker scripts, +binaries, cfg files, metadata files, and install targets. +""" + +import os +import sys +from dataclasses import dataclass + + +@dataclass +class EnvInfo: + """Describe one concrete build environment loaded from the manifest. + + Each instance contains the already-expanded flag strings and file paths + needed to emit build statements for one environment such as pv64 or hvm32pae. + """ + + guest: str + arch: str + aflags_arch: str + cflags_arch: str + aflags_env: str + cflags_env: str + link: str + ldflags: str + defcfg: str + + +@dataclass +class TestInfo: + """Hold all manifest data needed to emit one test directory. + + The generator uses this record to create the per-test outputs that would + otherwise be expanded by the recursive make path: binaries, cfg files, + variation cfg files, metadata, and install copies. + """ + + key: str + directory: str + name: str + category: str + envs: list[str] + extra_cfg: str + vary_cfg: list[str] + vcpus: str + local_objs: list[str] + + +def split_words(value: str) -> list[str]: + """Split one manifest field into whitespace-separated words. + + The manifest stores make list values in plain text fields, so this helper + recreates the corresponding Python list representation. + """ + + return [word for word in value.split() if word] + + +def parse_manifest(path: str) -> tuple[ + dict[str, str], + dict[str, EnvInfo], + dict[str, list[str]], + list[TestInfo], + list[str], + list[str], +]: + """Parse the Make-generated manifest into typed Python structures. + + The manifest is line-oriented and tab-separated. Each record starts with a + kind tag such as global, env, or test followed by the fields for that + record type. + + The parsing work is deliberately kept simple and explicit because the input + format is produced by the Makefile rather than by a schema-aware tool. The + result mirrors the manifest's own structure: + + * globals_map holds scalar tool and path settings. + * envs holds one EnvInfo per named runtime environment. + * env_objects holds per-environment object lists. + * tests holds one TestInfo per selected test directory. + * objects_perbits and objects_perenv keep the shared object lists. + + Keeping those groups separate makes the later emission code easier to read. + The generator can iterate over the same conceptual pieces that exist in the + build system instead of repeatedly unpacking raw tab-separated strings. + """ + + globals_map: dict[str, str] = {} + envs: dict[str, EnvInfo] = {} + env_objects: dict[str, list[str]] = {} + tests: list[TestInfo] = [] + objects_perbits: list[str] = [] + objects_perenv: list[str] = [] + + with open(path, encoding="utf-8") as handle: + for raw_line in handle: + line = raw_line.rstrip("\n") + if not line: + continue + fields = line.split("\t") + kind = fields[0] + + if kind == "global": + _, key, value = fields + globals_map[key] = value + elif kind == "objects": + _, scope, value = fields + if scope == "perbits": + objects_perbits = split_words(value) + elif scope == "perenv": + objects_perenv = split_words(value) + elif kind == "env": + ( + _, + name, + guest, + arch, + aflags_arch, + cflags_arch, + aflags_env, + cflags_env, + link, + ldflags, + defcfg, + ) = fields + envs[name] = EnvInfo( + guest=guest, + arch=arch, + aflags_arch=aflags_arch, + cflags_arch=cflags_arch, + aflags_env=aflags_env, + cflags_env=cflags_env, + link=link, + ldflags=ldflags, + defcfg=defcfg, + ) + elif kind == "env_objects": + _, name, value = fields + env_objects[name] = split_words(value) + elif kind == "test": + ( + _, + key, + directory, + name, + category, + env_list, + extra_cfg, + vary_cfg, + vcpus, + local_objs, + ) = fields + tests.append( + TestInfo( + key=key, + directory=directory, + name=name, + category=category, + envs=split_words(env_list), + extra_cfg=extra_cfg, + vary_cfg=split_words(vary_cfg), + vcpus=vcpus, + local_objs=split_words(local_objs), + ) + ) + else: + raise ValueError(f"Unexpected manifest line: {line}") + + return globals_map, envs, env_objects, tests, objects_perbits, objects_perenv + + +def to_rel(root: str, path: str) -> str: + """Return a path in the form expected by the generated Ninja file. + + The manifest can contain either absolute or already-relative paths. Ninja + files are generated relative to the repository root, so absolute paths are + normalised back to that form here. + """ + + if not path: + return "" + if os.path.isabs(path): + return os.path.relpath(path, root) + return path + + +def source_for_obj(root: str, obj: str) -> tuple[str, str]: + """Resolve one object name to its source path and compile mode. + + XTF object lists name the target object, not the original source. This + helper probes for .c and .S siblings and returns both the chosen + source file and the Ninja rule name that should compile it. + """ + + rel_obj = to_rel(root, obj) + c_src = rel_obj[:-2] + ".c" + s_src = rel_obj[:-2] + ".S" + + if os.path.exists(os.path.join(root, c_src)): + return c_src, "cc" + if os.path.exists(os.path.join(root, s_src)): + return s_src, "as" + + raise FileNotFoundError(f"No source found for object {obj}") + + +def depfile_for(output: str) -> str: + """Map a generated output file to its dependency file name.""" + + if output.endswith(".lds"): + return output[:-4] + ".d" + if output.endswith(".o"): + return output[:-2] + ".d" + raise ValueError(f"No depfile mapping for {output}") + + +def emit_rule( + lines: list[str], + name: str, + command: str, + *, + depfile: str | None = None, + deps: str | None = None, +) -> None: + """Append a reusable Ninja command definition. + + In Ninja terminology, a rule names the command template, while the real + per-file work is described later by build lines that reference that rule. + """ + + lines.append(f"rule {name}") + lines.append(f" command = {command}") + if depfile is not None: + lines.append(f" depfile = {depfile}") + if deps is not None: + lines.append(f" deps = {deps}") + lines.append("") + + +def emit_phony(lines: list[str], output: str, inputs: list[str]) -> None: + """Append a phony target. + + This is the Ninja equivalent of a grouping target with no recipe of its own + whose purpose is to collect other concrete outputs behind a stable name. + """ + + emit_build(lines, output, "phony", inputs) + + +def emit_build( + lines: list[str], + output: str, + rule: str, + inputs: list[str], + variables: dict[str, str] | None = None, + implicit_inputs: list[str] | None = None, +) -> None: + """Append one concrete Ninja build statement. + + For maintainers used to GNU make, this is the closest equivalent to writing + out one fully expanded target rule after all variables have been resolved. + implicit_inputs are emitted after | so Ninja tracks them as + dependencies without adding them to the command line itself. + """ + + line = f"build {output}: {rule}" + if inputs: + line += " " + " ".join(inputs) + if implicit_inputs: + line += " | " + " ".join(implicit_inputs) + lines.append(line) + if variables: + for key, value in variables.items(): + lines.append(f" {key} = {value}") + lines.append("") + + +def build_ninja( + root: str, + globals_map: dict[str, str], + envs: dict[str, EnvInfo], + env_objects: dict[str, list[str]], + tests: list[TestInfo], + objects_perbits: list[str], + objects_perenv: list[str], +) -> list[str]: + """Construct the full Ninja file as a list of text lines. + + The implementation works in two stages. First it emits the small set of + reusable command definitions shared by the whole file. Then it walks every + selected test and every enabled environment for that test, emitting the + concrete statements needed to build the required outputs. + + Ninja often calls those concrete statements edges in the dependency + graph. In GNU make terms, you can read them as explicit instantiated rules + connecting a specific output file to the exact inputs and command variables + needed to rebuild it. + + The returned list is written directly to build/xtf.ninja by main(). + """ + + cc = globals_map["CC"] + cpp = globals_map["CPP"] + ld = globals_map["LD"] + objcopy = globals_map["OBJCOPY"] + python = globals_map["PYTHON"] + destdir = globals_map["DESTDIR"] + hvm64_format = globals_map["HVM64_FORMAT"] + install_data = globals_map["INSTALL_DATA"] + install_program = globals_map["INSTALL_PROGRAM"] + xtfdir = globals_map["xtfdir"] + xtftestdir = globals_map["xtftestdir"] + + install_xtfdir = f"{destdir}{xtfdir}" if destdir else xtfdir + install_xtftestdir = f"{destdir}{xtftestdir}" if destdir else xtftestdir + + lines = [ + "# Autogenerated by build/gen-ninja.py. Do not edit.", + "ninja_required_version = 1.3", + "", + ] + + # Define the reusable command blocks referenced later by concrete build + # statements. Ninja separates the command definition from each individual + # output. If you think in make terms, this is similar to writing down the + # shared recipe form once and then reusing it with different file-specific + # variables for each concrete target. + emit_rule( + lines, + "cc", + f"{cc} $cflags -MT $out -MF $depfile -c $in -o $out", + depfile="$depfile", + deps="gcc", + ) + emit_rule( + lines, + "as", + f"{cc} $aflags -MT $out -MF $depfile -c $in -o $out", + depfile="$depfile", + deps="gcc", + ) + emit_rule( + lines, + "cpp_lds", + f"{cpp} $aflags -MT $out -MF $depfile -P $in -o $out", + depfile="$depfile", + deps="gcc", + ) + emit_rule(lines, "link", f"{ld} $ldflags $in -o $out") + emit_rule( + lines, + "link_hvm64", + f"{ld} $ldflags $in -o $tmpout && {objcopy} $tmpout -O {hvm64_format} $out" + " && rm -f $tmpout", + ) + emit_rule( + lines, + "mkcfg", + f'{python} build/mkcfg.py $out "$defcfg" "$vcpus" "$extracfg" "$varycfg"', + ) + emit_rule( + lines, + "mkinfo", + f'{python} build/mkinfo.py $out "$name" "$category" "$envs" "$variations"', + ) + emit_rule(lines, "install_data", f"mkdir -p $outdir && {install_data} $in $out") + emit_rule( + lines, "install_program", f"mkdir -p $outdir && {install_program} $in $out" + ) + + seen_outputs: set[str] = set() + build_targets: list[str] = [] + install_targets: list[str] = [] + + def emit_object(original_obj: str, output_obj: str, flags: str) -> None: + """Emit one object-file build statement if it is not already present. + + Multiple tests can depend on the same shared object, so the generator + must deduplicate these outputs while still discovering whether the input + source is C or assembly. + """ + + if output_obj in seen_outputs: + return + source, rule = source_for_obj(root, original_obj) + emit_build( + lines, + output_obj, + rule, + [source], + { + "cflags" if rule == "cc" else "aflags": flags, + "depfile": depfile_for(output_obj), + }, + ) + seen_outputs.add(output_obj) + + def emit_link_script(env_name: str) -> str: + """Emit one generated linker script when an environment first needs it. + + The recursive make path treats these linker scripts as generated files, + so the Ninja path mirrors that behaviour and emits them lazily. + """ + + output = to_rel(root, envs[env_name].link) + if output in seen_outputs: + return output + emit_build( + lines, + output, + "cpp_lds", + ["common/link.lds.S"], + { + "aflags": envs[env_name].aflags_env, + "depfile": depfile_for(output), + }, + ) + seen_outputs.add(output) + return output + + for test in tests: + info_output = os.path.join(test.directory, "info.json") + + # Start with the descriptive metadata for the test directory itself. + # This is not part of the compiled binary, but it travels with the test + # outputs and is installed alongside them for consumers that need to + # know the test name, category, supported environments, and variations. + # Emit the metadata file describing what this test is, which + # environments it supports, and which config variations exist. + emit_build( + lines, + info_output, + "mkinfo", + [os.path.join(test.directory, "Makefile"), "build/mkinfo.py"], + { + "name": test.name, + "category": test.category, + "envs": " ".join(test.envs), + "variations": " ".join(test.vary_cfg), + }, + ) + build_targets.append(info_output) + + install_info = os.path.join( + to_rel(root, install_xtftestdir), test.name, "info.json" + ) + emit_build( + lines, + install_info, + "install_data", + [info_output], + {"outdir": os.path.dirname(install_info)}, + ) + install_targets.append(install_info) + + for env_name in test.envs: + env = envs[env_name] + + dep_outputs: list[str] = [] + + # First emit the objects that are shared by all tests of the same + # bitness. These correspond to the common object lists in the make + # build and are reused across many later link steps. + for obj in objects_perbits: + rel_obj = to_rel(root, obj) + output_obj = rel_obj[:-2] + f"-{env.arch}.o" + emit_object( + obj, + output_obj, + ( + env.cflags_arch + if source_for_obj(root, obj)[1] == "cc" + else env.aflags_arch + ), + ) + dep_outputs.append(output_obj) + + # Collect the object files that are specific to this exact + # environment. This combines shared per-environment sources with + # the test directory's own object list. Together with the per-bits + # objects above, this produces the full set of link inputs for one + # test binary in one environment. + for obj in env_objects.get(env_name, []) + objects_perenv + test.local_objs: + rel_obj = to_rel(root, obj) + output_obj = rel_obj[:-2] + f"-{env_name}.o" + emit_object( + obj, + output_obj, + ( + env.cflags_env + if source_for_obj(root, obj)[1] == "cc" + else env.aflags_env + ), + ) + dep_outputs.append(output_obj) + + # Emit the final link step for one test binary. The linker script + # is tracked as an implicit dependency: Ninja rebuilds when it + # changes, but it is not appended to the link command as a normal + # input file. This keeps the command line aligned with what the + # linker actually consumes while still preserving correct rebuilds. + link_script = emit_link_script(env_name) + bin_output = os.path.join(test.directory, f"test-{env_name}-{test.name}") + link_inputs = dep_outputs + link_vars = {"ldflags": env.ldflags} + link_rule = "link" + if env_name == "hvm64": + link_rule = "link_hvm64" + link_vars["tmpout"] = bin_output + ".tmp" + emit_build( + lines, + bin_output, + link_rule, + link_inputs, + link_vars, + implicit_inputs=[link_script], + ) + build_targets.append(bin_output) + + install_bin = os.path.join( + to_rel(root, install_xtftestdir), + test.name, + os.path.basename(bin_output), + ) + emit_build( + lines, + install_bin, + "install_program", + [bin_output], + {"outdir": os.path.dirname(install_bin)}, + ) + install_targets.append(install_bin) + + # Emit the default xl cfg file for this test/environment pair. + # In practice this is the generated runtime configuration derived + # from the environment default plus any test-local extras. + cfg_output = os.path.join( + test.directory, f"test-{env_name}-{test.name}.cfg" + ) + cfg_inputs = [ + "build/mkcfg.py", + to_rel(root, env.defcfg), + os.path.join(test.directory, "Makefile"), + ] + if test.extra_cfg: + cfg_inputs.append(to_rel(root, test.extra_cfg)) + emit_build( + lines, + cfg_output, + "mkcfg", + cfg_inputs, + { + "defcfg": to_rel(root, env.defcfg), + "vcpus": test.vcpus, + "extracfg": to_rel(root, test.extra_cfg), + "varycfg": "", + }, + ) + build_targets.append(cfg_output) + + install_cfg = os.path.join( + to_rel(root, install_xtftestdir), + test.name, + os.path.basename(cfg_output), + ) + emit_build( + lines, + install_cfg, + "install_data", + [cfg_output], + {"outdir": os.path.dirname(install_cfg)}, + ) + install_targets.append(install_cfg) + + for variation in test.vary_cfg: + local_vary = os.path.join(test.directory, f"{variation}.cfg.in") + + # Variation fragments can either be private to the test or come + # from the shared config/ directory, so resolve the input path + # before emitting the cfg-generation statement. Resolving that + # choice here keeps the build rule emission below straightforward + # because it only has to work with the already-selected input. + vary_input = ( + local_vary + if os.path.exists(os.path.join(root, local_vary)) + else os.path.join("config", f"{variation}.cfg.in") + ) + vary_output = os.path.join( + test.directory, f"test-{env_name}-{test.name}~{variation}.cfg" + ) + vary_inputs = [ + "build/mkcfg.py", + to_rel(root, env.defcfg), + os.path.join(test.directory, "Makefile"), + vary_input, + ] + if test.extra_cfg: + vary_inputs.append(to_rel(root, test.extra_cfg)) + emit_build( + lines, + vary_output, + "mkcfg", + vary_inputs, + { + "defcfg": to_rel(root, env.defcfg), + "vcpus": test.vcpus, + "extracfg": to_rel(root, test.extra_cfg), + "varycfg": vary_input, + }, + ) + build_targets.append(vary_output) + + install_vary = os.path.join( + to_rel(root, install_xtftestdir), + test.name, + os.path.basename(vary_output), + ) + emit_build( + lines, + install_vary, + "install_data", + [vary_output], + {"outdir": os.path.dirname(install_vary)}, + ) + install_targets.append(install_vary) + + # The install side of the graph also needs the top-level xtf-runner helper, + # which is not associated with any one test directory. It is emitted last + # because unlike the per-test install targets above, it depends only on the + # top-level repository layout and not on any manifest test record. + runner_install = os.path.join(to_rel(root, install_xtfdir), "xtf-runner") + emit_build( + lines, + runner_install, + "install_program", + ["xtf-runner"], + {"outdir": os.path.dirname(runner_install)}, + ) + install_targets.append(runner_install) + + emit_phony(lines, "build", build_targets) + emit_phony(lines, "install", install_targets) + lines.append("default build") + lines.append("") + return lines + + +def main() -> int: + """Load the manifest, generate Ninja lines, and write the output file.""" + + if len(sys.argv) != 3: + print("Usage: gen-ninja.py MANIFEST OUT", file=sys.stderr) + return 2 + + manifest_path, output_path = sys.argv[1:3] + + # Decode the Make-generated manifest first so the rest of the file can work + # in terms of environments, tests, and object lists rather than raw text + # records. This keeps the parsing concerns local to one place and lets the + # emitter logic below read more like the structure of the build itself. + globals_map, envs, env_objects, tests, objects_perbits, objects_perenv = ( + parse_manifest(manifest_path) + ) + root = globals_map["ROOT"] + + # Build the full Ninja file in memory first, then let the Makefile's + # temporary-output convention decide whether the generated file changed. + # That separation keeps this script focused purely on content generation. + lines = build_ninja( + root, globals_map, envs, env_objects, tests, objects_perbits, objects_perenv + ) + + with open(output_path, "w", encoding="utf-8") as handle: + handle.write("\n".join(lines)) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/gen.mk b/build/gen.mk index 4353659..efe96e6 100644 --- a/build/gen.mk +++ b/build/gen.mk @@ -1,3 +1,8 @@ +ifeq ($(XTF_METADATA_ONLY),1) + +# In metadata-loading mode, this file is a compatibility shim only. + +else # Sanity checking of expected parameters @@ -115,3 +120,5 @@ clean: .PHONY: FORCE FORCE: + +endif diff --git a/build/load-tests.mk b/build/load-tests.mk new file mode 100644 index 0000000..42b2979 --- /dev/null +++ b/build/load-tests.mk @@ -0,0 +1,48 @@ +include $(ROOT)/build/core.mk + +TEST_MAKEFILES := $(wildcard $(TESTS:%=%/Makefile)) +REGISTERED_TESTS := + +define xtf_metadata_key +$(subst -,_,$(subst /,_,$(1))) +endef + +define xtf_reset_test_metadata +NAME := +CATEGORY := +TEST-ENVS := +TEST-EXTRA-CFG := +VARY-CFG := +VCPUS := +obj-perenv := +endef + +define xtf_canonicalise_test_obj +$(if $(filter $(ROOT)/% /%,$(1)),$(1),$(CURRENT_TEST_DIR)/$(1)) +endef + +define xtf_load_test +$$(eval $$(call xtf_reset_test_metadata)) +CURRENT_TEST_DIR := $$(patsubst %/Makefile,%,$(1)) +XTF_METADATA_ONLY := 1 +include $(1) +TEST_KEY := $$(call xtf_metadata_key,$$(CURRENT_TEST_DIR)) +REGISTERED_TESTS += $$(TEST_KEY) +TEST_DIR_$$(TEST_KEY) := $$(CURRENT_TEST_DIR) +TEST_NAME_$$(TEST_KEY) := $$(NAME) +TEST_CATEGORY_$$(TEST_KEY) := $$(CATEGORY) +TEST_ENVS_$$(TEST_KEY) := $$(TEST-ENVS) +TEST_EXTRA_CFG_$$(TEST_KEY) := $$(if $$(TEST-EXTRA-CFG), \ + $$(CURRENT_TEST_DIR)/$$(TEST-EXTRA-CFG)) +TEST_VARY_CFG_$$(TEST_KEY) := $$(VARY-CFG) +TEST_VCPUS_$$(TEST_KEY) := $$(if $$(VCPUS),$$(VCPUS),1) +TEST_LOCAL_OBJ_PERENV_$$(TEST_KEY) := \ + $$(foreach obj,$$(obj-perenv), \ + $$(call xtf_canonicalise_test_obj,$$(obj))) +undefine TEST_KEY +undefine XTF_METADATA_ONLY +undefine CURRENT_TEST_DIR +endef + +$(foreach test_makefile,$(TEST_MAKEFILES), \ + $(eval $(call xtf_load_test,$(test_makefile)))) From f62942dc7b83450850489ea4d5afb9c5155d6b8f Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Fri, 12 Jun 2026 20:13:05 +0000 Subject: [PATCH 07/19] Add instructions for GitHub Copilot to run pre-commit after changes --- .github/copilot-instructions.md | 50 +++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..24b51c7 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,50 @@ +# Repository Instructions for GitHub Copilot + +## Building and Testing + +Rules: +- Run "make" in the repository root to build all code, documentation, + and tests. +- Never attempt to compile only a subset of the repository. + Always build the entire repository. +- If the build does not rebuild the files you changed, you can assume that + another user already ran a build that produced the same output. You can skip the build and run tests directly. +- Test your changes by actually running the test using: + + ./xtf-runner + + Example: When testing the `tests/nested-svm-clgi-stgi` test, run: + + ./xtf-runner nested-svm-clgi-stgi + +- Only after building and running tests should you run the final + validation command for Copilot-authored changes. + +## CI validation + +This repository uses `pre-commit` in CI. + +It checks for code formatting, linting, and other issues in code, +build system, documentation, and tests. It is configured to run on +all files in the repository. + +Among other checks, it runs: +- Adds missing trailing newlines to all files (add a trailing newline yourself) +- Removes trailing whitespace from all files. +- `isort`, `black`, and `flake8` for Python code. + +The full list of checks is in the `.pre-commit-config.yaml` file in the +repository. + +After making code, build-system, documentation, or test changes in this +repository, run: + +```sh +SKIP=git-diff pre-commit run -av +``` + +Treat this as the final validation command for Copilot-authored changes. +If the command is unavailable or cannot complete in the current environment, +report that clearly along with any narrower checks that were run instead +and tell a human reviewer to install pre-commit using `pip install pre-commit` +and run the full command before merging. From 940738fb47377be2939658c36232b533bb5099ba Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Mon, 8 Jun 2026 12:00:00 +0000 Subject: [PATCH 08/19] Add isort, black and flake8 for Python formating & CI Signed-off-by: Bernhard Kaindl --- .flake8 | 2 ++ .pre-commit-config.yaml | 35 +++++++++++++++++++++++++++++++++++ build/mkcfg.py | 31 +++++++++++++++++++------------ build/mkinfo.py | 8 +++----- 4 files changed, 59 insertions(+), 17 deletions(-) create mode 100644 .flake8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e44b810 --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +ignore = E501 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc65ea6..6b13788 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,6 +32,41 @@ repos: args: ['--fix=lf'] - id: trailing-whitespace + +# This is the pre-commit hook to run black, the Python code formatter. +- repo: https://github.com/psf/black + rev: 26.5.1 + hooks: + - id: black + # We use the --skip-string-normalization option to preserve the + # minimise churn initially, leaving string normalization for + # later cleanup commit to not mix with the formatting changes. + args: ['--skip-string-normalization'] + # To keep initial formatting limited in scope, skip the runner for now. + exclude: xtf-runner + + +# This is the isort hook recommended for use with black +- repo: https://github.com/pycqa/isort + rev: 8.0.1 + hooks: + - id: isort + args: ['--profile', 'black'] + + +# This is the flake8 hook used by black itself: +- repo: https://github.com/pycqa/flake8 + rev: 7.3.0 + hooks: + - id: flake8 + additional_dependencies: + - flake8-bugbear + - flake8-comprehensions + - flake8-simplify + # To keep initial cleanup limited in scope, skip the runner for now. + exclude: xtf-runner + + - repo: local hooks: - id: git-diff # https://github.com/pre-commit/pre-commit/issues/1712 diff --git a/build/mkcfg.py b/build/mkcfg.py index f8f006d..ba54753 100755 --- a/build/mkcfg.py +++ b/build/mkcfg.py @@ -3,6 +3,7 @@ Construct an xl configuration file for a test (from various fragments), and substitue variables appropriately. """ + import os import sys @@ -18,26 +19,32 @@ parts = name.split('~', 1) name, variation = parts[0], '~' + parts[1] + def expand(text): - """ Expand certain variables in text """ - return (text - .replace("@@NAME@@", name) - .replace("@@ENV@@", env) - .replace("@@VCPUS@@", vcpus) - .replace("@@XTFDIR@@", os.environ["xtfdir"]) - .replace("@@VARIATION@@", variation) - ) + """Expand certain variables in text""" + return ( + text.replace("@@NAME@@", name) + .replace("@@ENV@@", env) + .replace("@@VCPUS@@", vcpus) + .replace("@@XTFDIR@@", os.environ["xtfdir"]) + .replace("@@VARIATION@@", variation) + ) + -config = open(defcfg).read() +with open(defcfg) as f: + config = f.read() if extracfg: config += "\n# Test Extra Configuration:\n" - config += open(extracfg).read() + with open(extracfg) as f: + config += f.read() if varycfg: config += "\n# Test Variation Configuration:\n" - config += open(varycfg).read() + with open(varycfg) as f: + config += f.read() cfg = expand(config) -open(out, "w").write(cfg) +with open(out, "w") as f: + f.write(cfg) diff --git a/build/mkinfo.py b/build/mkinfo.py index 7698991..8469591 100755 --- a/build/mkinfo.py +++ b/build/mkinfo.py @@ -12,14 +12,12 @@ "category": cat, "environments": [], "variations": [], - } +} if envs: template["environments"] = envs.split(" ") if variations: template["variations"] = variations.split(" ") -open(out, "w").write( - json.dumps(template, indent=4, separators=(',', ': ')) - + "\n" - ) +with open(out, "w") as f: + f.write(json.dumps(template, indent=4, separators=(",", ": ")) + "\n") From 3d7d3aa70960ba3b27878050b0411d5b5b9ded80 Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Thu, 11 Jun 2026 12:00:00 +0000 Subject: [PATCH 09/19] Create new XTF test category nested-svm for nested SVM tests With the new test category, all nested SVM tests can be run using: ./xtf-runner nested-svm Signed-off-by: Bernhard Kaindl --- build/common.mk | 3 ++- xtf-runner | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/build/common.mk b/build/common.mk index 941fab0..9da0dbe 100644 --- a/build/common.mk +++ b/build/common.mk @@ -6,7 +6,7 @@ ifeq ($(XTF_METADATA_ONLY),1) else -ALL_CATEGORIES := special functional xsa utility in-development +ALL_CATEGORIES := special functional xsa utility in-development nested-svm ALL_ENVIRONMENTS := pv64 pv32pae hvm64 hvm32pae hvm32pse hvm32 @@ -14,6 +14,7 @@ PV_ENVIRONMENTS := $(filter pv%,$(ALL_ENVIRONMENTS)) HVM_ENVIRONMENTS := $(filter hvm%,$(ALL_ENVIRONMENTS)) 32BIT_ENVIRONMENTS := $(filter pv32% hvm32%,$(ALL_ENVIRONMENTS)) 64BIT_ENVIRONMENTS := $(filter pv64% hvm64%,$(ALL_ENVIRONMENTS)) +SVM_ENVIRONMENTS := hvm64 # $(env)_guest => pv or hvm mapping $(foreach env,$(PV_ENVIRONMENTS),$(eval $(env)_guest := pv)) diff --git a/xtf-runner b/xtf-runner index 9c3a425..c2bd524 100755 --- a/xtf-runner +++ b/xtf-runner @@ -40,7 +40,7 @@ def exit_code(state): }[state] # All test categories -default_categories = {"functional", "xsa"} +default_categories = {"functional", "xsa", "nested-svm"} non_default_categories = {"special", "utility", "in-development"} all_categories = default_categories | non_default_categories From 056c505c333753496347ff6aa09bf41901c9d8c1 Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Thu, 11 Jun 2026 12:00:00 +0000 Subject: [PATCH 10/19] Remove the obsolete empty placeholder nested-svm test The empty placeholder nested-svm test is obsoleted by the tests for VMRUN, VMLOAD and VMSAVE, and should be removed. Signed-off-by: Bernhard Kaindl --- tests/nested-svm/Makefile | 11 ----------- tests/nested-svm/extra.cfg.in | 1 - tests/nested-svm/main.c | 34 ---------------------------------- 3 files changed, 46 deletions(-) delete mode 100644 tests/nested-svm/Makefile delete mode 100644 tests/nested-svm/extra.cfg.in delete mode 100644 tests/nested-svm/main.c diff --git a/tests/nested-svm/Makefile b/tests/nested-svm/Makefile deleted file mode 100644 index a457d8a..0000000 --- a/tests/nested-svm/Makefile +++ /dev/null @@ -1,11 +0,0 @@ -include $(ROOT)/build/common.mk - -NAME := nested-svm -CATEGORY := in-development -TEST-ENVS := $(HVM_ENVIRONMENTS) - -TEST-EXTRA-CFG := extra.cfg.in - -obj-perenv += main.o - -include $(ROOT)/build/gen.mk diff --git a/tests/nested-svm/extra.cfg.in b/tests/nested-svm/extra.cfg.in deleted file mode 100644 index ae494f8..0000000 --- a/tests/nested-svm/extra.cfg.in +++ /dev/null @@ -1 +0,0 @@ -nestedhvm = 1 diff --git a/tests/nested-svm/main.c b/tests/nested-svm/main.c deleted file mode 100644 index 3dc0ff1..0000000 --- a/tests/nested-svm/main.c +++ /dev/null @@ -1,34 +0,0 @@ -/** - * @file tests/nested-svm/main.c - * @ref test-nested-svm - * - * @page test-nested-svm Nested SVM - * - * Functional testing of the SVM features in a nested-virt environment. - * - * @see tests/nested-svm/main.c - */ -#include - -const char test_title[] = "Nested SVM testing"; - -void test_main(void) -{ - if ( !cpu_has_svm ) - return xtf_skip("Skip: SVM not available\n"); - - if ( !vendor_is_amd ) - xtf_warning("Warning: SVM found on non-AMD processor\n"); - - xtf_success(NULL); -} - -/* - * Local variables: - * mode: C - * c-file-style: "BSD" - * c-basic-offset: 4 - * tab-width: 4 - * indent-tabs-mode: nil - * End: - */ From 17f906fa960019c841931308e3adebd4893a6a2f Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Fri, 12 Jun 2026 12:00:00 +0000 Subject: [PATCH 11/19] CI: Bump actions: checkout@v7 (nodejs20 deprecation), codeql v3->v4 Signed-off-by: Bernhard Kaindl --- .github/workflows/{build.yml => ci.yml} | 24 ++++++++---------------- .github/workflows/codeql.yml | 14 ++++++++++---- .github/workflows/scan-build.yml | 2 +- 3 files changed, 19 insertions(+), 21 deletions(-) rename .github/workflows/{build.yml => ci.yml} (70%) diff --git a/.github/workflows/build.yml b/.github/workflows/ci.yml similarity index 70% rename from .github/workflows/build.yml rename to .github/workflows/ci.yml index 3041fc0..6c991af 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/ci.yml @@ -4,22 +4,14 @@ on: [push, pull_request] jobs: python: - name: "Python Tests" - - runs-on: ubuntu-22.04 - + name: "Pre-commit Checks" + runs-on: ubuntu-24.04 steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: pre-commit checks - setup cache - uses: actions/cache@v4 - with: - path: ~/.cache/pre-commit - key: pre-commit|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} - - - name: pre-commit checks - run checks - uses: pre-commit/action@v3.0.1 + # Some organisations require pinning of actions to a specific commit SHA. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Some organisations allow only verified actions to be used in workflows. + # The owner of this pre-commit action is verified: + - uses: cloudposse/github-action-pre-commit@ed9906221c8a4ee1dcb1e665da895998e0f7b396 # v4.1.0 C: name: "C Builds" @@ -61,7 +53,7 @@ jobs: sudo apt-get update -q sudo apt-get install -y build-essential python3 ${{matrix.compiler}} ${EXTRA} - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Build run: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ac4cf03..f480ca1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,6 +9,12 @@ on: jobs: analyse: + # Only run CodeQL analysis on the main repository, not forks + # because forks may not have CodeQL analysis enabled and we + # don't want to run CodeQL analysis on forks to avoid wasting + # GitHub Actions minutes on forks. For test code, CodeQL analysis + # is not required, so we only run it on the main repository. + if: ${{ github.repository_owner == 'andyhhp' }} strategy: matrix: @@ -17,13 +23,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: github/codeql-action/init@v3 + - uses: github/codeql-action/init@v4 with: languages: ${{matrix.lang}} queries: security-and-quality - - uses: github/codeql-action/autobuild@v3 + - uses: github/codeql-action/autobuild@v4 - - uses: github/codeql-action/analyze@v3 + - uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/scan-build.yml b/.github/workflows/scan-build.yml index 2e232e8..20182a3 100644 --- a/.github/workflows/scan-build.yml +++ b/.github/workflows/scan-build.yml @@ -13,7 +13,7 @@ jobs: sudo apt-get update -q sudo apt-get install clang-tools-14 - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Scan-build run: | From c5ff73a6ae0ffa99dd42f422fb925bafb419e832 Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Fri, 12 Jun 2026 12:00:00 +0000 Subject: [PATCH 12/19] CI: Switch build.yml to 24.04, re-add gcc-13, update clang versions Signed-off-by: Bernhard Kaindl --- .github/workflows/ci.yml | 34 +++++++++++--------------------- .github/workflows/scan-build.yml | 20 ------------------- 2 files changed, 11 insertions(+), 43 deletions(-) delete mode 100644 .github/workflows/scan-build.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c991af..93f0738 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: build +name: CI on: [push, pull_request] @@ -19,26 +19,8 @@ jobs: strategy: matrix: arch: [x86] - compiler: [llvm-12, llvm-13, llvm-14] - - include: - - arch: x86 - compiler: gcc-9 - - arch: x86 - compiler: gcc-10 - - arch: x86 - compiler: gcc-11 - - arch: x86 - compiler: gcc-12 - - - arch: x86 - compiler: clang-12 - - arch: x86 - compiler: clang-13 - - arch: x86 - compiler: clang-14 - - runs-on: ubuntu-22.04 + compiler: [llvm-18, llvm-19, gcc-12, gcc-13, gcc-14] + runs-on: ubuntu-24.04 steps: - name: Install @@ -46,12 +28,12 @@ jobs: c=${{matrix.compiler}} v=${c##llvm-} case $c in - # Need all {llvm,clang,lld}-$v packages + # Need all {clang,lld}-$v packages llvm-*) EXTRA="clang-${v} lld-${v}" ;; esac sudo apt-get update -q - sudo apt-get install -y build-essential python3 ${{matrix.compiler}} ${EXTRA} + sudo apt-get install -y ${{matrix.compiler}} ${EXTRA} - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -65,3 +47,9 @@ jobs: esac make -j`nproc` ARCH=${{matrix.arch}} $CROSS $COMP + + - name: scan-build + if: matrix.compiler == 'llvm-19' + run: | + sudo apt-get install -y clang-tools-19 + scan-build-19 --status-bugs -analyze-headers make -j`nproc` diff --git a/.github/workflows/scan-build.yml b/.github/workflows/scan-build.yml deleted file mode 100644 index 20182a3..0000000 --- a/.github/workflows/scan-build.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: scan-build - -on: [push, pull_request] - -jobs: - scan-build: - - runs-on: ubuntu-22.04 - - steps: - - name: Install - run: | - sudo apt-get update -q - sudo apt-get install clang-tools-14 - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Scan-build - run: | - scan-build-14 --status-bugs -analyze-headers make -j`nproc` From 340b6cb619ec79f4536cc481b19f42c393fb1da7 Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Fri, 12 Jun 2026 12:00:00 +0000 Subject: [PATCH 13/19] xtf-runner: split implementation into an importable Python package Move the existing xtf-runner implementation from the top-level executable to xtf.runner.cli, fully update it to Python 3 syntax and style and fix all pylint warnings to enable pylint in CI. The implementation can now also be imported directly by new Python code. Add a selftest for the extracted runner. The test builds a temporary XTF metadata tree and mocks the Xen toolstack boundary, covering list selection output, run summary and exit-code mapping, and the console execution sequence used to create, attach to and unpause a guest. While at it, add support for detecting expected crashes with XFAIL in the test's console output and show them as pass with expected fail. Signed-off-by: Bernhard Kaindl --- .pre-commit-config.yaml | 24 +- .pylintrc | 3 +- Makefile | 12 +- build/gen-ninja.py | 28 ++ build/mkcfg.py | 2 +- xtf-runner | 735 +----------------------------------- xtf/__init__.py | 1 + xtf/runner/__init__.py | 1 + xtf/runner/cli.py | 805 ++++++++++++++++++++++++++++++++++++++++ xtf/runner/selftest.py | 161 ++++++++ 10 files changed, 1032 insertions(+), 740 deletions(-) create mode 100644 xtf/__init__.py create mode 100644 xtf/runner/__init__.py create mode 100755 xtf/runner/cli.py create mode 100644 xtf/runner/selftest.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6b13788..6b9d4d2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,8 +42,6 @@ repos: # minimise churn initially, leaving string normalization for # later cleanup commit to not mix with the formatting changes. args: ['--skip-string-normalization'] - # To keep initial formatting limited in scope, skip the runner for now. - exclude: xtf-runner # This is the isort hook recommended for use with black @@ -63,10 +61,28 @@ repos: - flake8-bugbear - flake8-comprehensions - flake8-simplify - # To keep initial cleanup limited in scope, skip the runner for now. - exclude: xtf-runner +# This is the mypy hook: +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v2.1.0 + hooks: + - id: mypy + args: [--strict] + exclude: ^docs/gdb-test-fw/reference/ + additional_dependencies: + - pytest + - types-gdb + + +# This is the pylint hook: +- repo: https://github.com/PyCQA/pylint + rev: v4.0.5 + hooks: + - id: pylint + + +# Custom hook to show not staged changes, which may be caused by fixup commits. - repo: local hooks: - id: git-diff # https://github.com/pre-commit/pre-commit/issues/1712 diff --git a/.pylintrc b/.pylintrc index 8a3106d..08bfae3 100644 --- a/.pylintrc +++ b/.pylintrc @@ -45,7 +45,7 @@ enable=unneeded-not,consider-using-set-comprehension # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=import-error,global-statement,unspecified-encoding,consider-using-f-string +disable=import-error,global-statement,unspecified-encoding [REPORTS] @@ -241,6 +241,7 @@ valid-metaclass-classmethod-first-arg=mcs # Maximum number of arguments for function / method max-args=7 +max-positional-arguments=7 # Argument names that match this expression will be ignored. Default to name # with leading underscore diff --git a/Makefile b/Makefile index 4e6306e..47b179e 100644 --- a/Makefile +++ b/Makefile @@ -123,6 +123,12 @@ $(BUILD_TARGETS): | $(SHARED_BOOTSTRAP_TARGET) install: @$(INSTALL_DIR) $(DESTDIR)$(xtfdir) $(INSTALL_PROGRAM) xtf-runner $(DESTDIR)$(xtfdir) + @find xtf -path '*/__pycache__' -prune -o -name '*.py' -print | \ + while read -r f; do \ + d="$(DESTDIR)$(xtfdir)/$$(dirname "$$f")"; \ + $(INSTALL_DIR) "$$d"; \ + $(INSTALL_DATA) "$$f" "$$d"; \ + done install: $(SHARED_BOOTSTRAP_TARGET) $(INSTALL_TARGETS) @@ -261,4 +267,8 @@ doxygen: Doxyfile .PHONY: pylint pylint: - -pylint --rcfile=.pylintrc xtf-runner + -pylint --rcfile=.pylintrc xtf-runner xtf + +.PHONY: runner-selftest +runner-selftest: + $(PYTHON) -m xtf.runner.selftest diff --git a/build/gen-ninja.py b/build/gen-ninja.py index e765561..3b3eac7 100755 --- a/build/gen-ninja.py +++ b/build/gen-ninja.py @@ -284,6 +284,23 @@ def emit_build( lines.append("") +def python_modules(root: str, package: str) -> list[str]: + """Return Python source files under package, excluding cache directories.""" + + modules: list[str] = [] + package_root = os.path.join(root, package) + + for dirpath, dirnames, filenames in os.walk(package_root): + dirnames[:] = [name for name in dirnames if name != "__pycache__"] + + for filename in filenames: + if not filename.endswith(".py"): + continue + modules.append(os.path.relpath(os.path.join(dirpath, filename), root)) + + return sorted(modules) + + def build_ninja( root: str, globals_map: dict[str, str], @@ -647,6 +664,17 @@ def emit_link_script(env_name: str) -> str: ) install_targets.append(runner_install) + for module in python_modules(root, "xtf"): + module_install = os.path.join(to_rel(root, install_xtfdir), module) + emit_build( + lines, + module_install, + "install_data", + [module], + {"outdir": os.path.dirname(module_install)}, + ) + install_targets.append(module_install) + emit_phony(lines, "build", build_targets) emit_phony(lines, "install", install_targets) lines.append("default build") diff --git a/build/mkcfg.py b/build/mkcfg.py index ba54753..e9e1c41 100755 --- a/build/mkcfg.py +++ b/build/mkcfg.py @@ -20,7 +20,7 @@ name, variation = parts[0], '~' + parts[1] -def expand(text): +def expand(text: str) -> str: """Expand certain variables in text""" return ( text.replace("@@NAME@@", name) diff --git a/xtf-runner b/xtf-runner index c2bd524..98c6378 100755 --- a/xtf-runner +++ b/xtf-runner @@ -1,740 +1,9 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -xtf-runner - A utility for enumerating and running XTF tests. +"""Compatibility wrapper for the importable xtf-runner implementation.""" -Currently assumes the presence and availability of the `xl` toolstack. -""" - -import json -import os -import subprocess import sys -from functools import partial -from optparse import OptionParser -from os import path -from subprocess import PIPE - -# Wrap Subprocess functions to use universal_newlines by default -Popen = partial(subprocess.Popen, universal_newlines = True) -subproc_call = partial(subprocess.call, universal_newlines = True) -check_output = partial(subprocess.check_output, universal_newlines = True) - - -# All results of a test, keep in sync with C code report.h. -# Notes: -# - WARNING is not a result on its own. -# - CRASH isn't known to the C code, but covers all cases where a valid -# result was not found. -all_results = ('SUCCESS', 'SKIP', 'ERROR', 'FAILURE', 'CRASH') - -# Return the exit code for different states. Avoid using 1 and 2 because -# python interpreter uses them -- see document for sys.exit. -def exit_code(state): - """ Convert a test result to an xtf-runner exit code. """ - return { "SUCCESS": 0, - "SKIP": 3, - "ERROR": 4, - "FAILURE": 5, - "CRASH": 6, - }[state] - -# All test categories -default_categories = {"functional", "xsa", "nested-svm"} -non_default_categories = {"special", "utility", "in-development"} -all_categories = default_categories | non_default_categories - -# All test environments -pv_environments = {"pv64", "pv32pae"} -hvm_environments = {"hvm64", "hvm32pae", "hvm32pse", "hvm32"} -all_environments = pv_environments | hvm_environments - - -class RunnerError(Exception): - """ Errors relating to xtf-runner itself """ - - -def env_to_virt_caps(env): - """ Identify which virt cap(s) are needed for an environment """ - if env in hvm_environments: - return {"hvm"} - caps = {"pv"} - if env == "pv32pae": - caps |= {"pv32"} - return caps - - -class TestInstance(object): - """ Object representing a single test. """ - - def __init__(self, arg): - """ Parse and verify 'arg' as a test instance. """ - self.env, self.name, self.variation = parse_test_instance_string(arg) - - if self.env is None: - raise RunnerError("No environment for '{0}'".format(arg)) - - if self.variation is None and get_all_test_info()[self.name].variations: - raise RunnerError("Test '{0}' has variations, but none specified" - .format(self.name)) - - self.req_caps = env_to_virt_caps(self.env) - self.req_caps |= {"hap", "shadow"} & set((self.variation, )) - - def vm_name(self): - """ Return the VM name as `xl` expects it. """ - return repr(self) - - def cfg_path(self): - """ Return the path to the `xl` config file for this test. """ - return path.join("tests", self.name, repr(self) + ".cfg") - - def __repr__(self): - if not self.variation: - return "test-{0}-{1}".format(self.env, self.name) - else: - return "test-{0}-{1}~{2}".format(self.env, self.name, self.variation) - - def __hash__(self): - return hash(repr(self)) - - def __eq__(self, other): - return repr(self) == repr(other) - - -class TestInfo(object): - """ Object representing a tests info.json, in a more convenient form. """ - - def __init__(self, test_json): - """Parse and verify 'test_json'. - - May raise KeyError, TypeError or ValueError. - """ - - name = test_json["name"] - if not isinstance(name, str): - raise TypeError("Expected string for 'name', got '{0}'" - .format(type(name))) - self.name = name - - cat = test_json["category"] - if not isinstance(cat, str): - raise TypeError("Expected string for 'category', got '{0}'" - .format(type(cat))) - if not cat in all_categories: - raise ValueError("Unknown category '{0}'".format(cat)) - self.cat = cat - - envs = test_json["environments"] - if not isinstance(envs, list): - raise TypeError("Expected list for 'environments', got '{0}'" - .format(type(envs))) - if not envs: - raise ValueError("Expected at least one environment") - for env in envs: - if not env in all_environments: - raise ValueError("Unknown environments '{0}'".format(env)) - self.envs = envs - - variations = test_json["variations"] - if not isinstance(variations, list): - raise TypeError("Expected list for 'variations', got '{0}'" - .format(type(variations))) - self.variations = variations - - def all_instances(self, env_filter = None, vary_filter = None): - """Return a list of TestInstances, for each supported environment. - Optionally filtered by env_filter. May return an empty list if - the filter doesn't match any supported environment. - """ - - if env_filter: - envs = set(env_filter).intersection(self.envs) - else: - envs = self.envs - - if vary_filter: - variations = set(vary_filter).intersection(self.variations) - else: - variations = self.variations - - res = [] - if variations: - for env in envs: - for vary in variations: - res.append(TestInstance("test-{0}-{1}~{2}" - .format(env, self.name, vary))) - else: - res = [ TestInstance("test-{0}-{1}".format(env, self.name)) - for env in envs ] - return res - - def __repr__(self): - return "TestInfo({0})".format(self.name) - - -def parse_test_instance_string(arg): - """Parse a test instance string. - - Has the form: '[[test-]$ENV-]$NAME[~$VARIATION]' - - Optional 'test-' prefix - Optional $ENV environment part - Mandatory $NAME - Optional ~$VARIATION suffix - - Verifies: - - $NAME is valid - - if $ENV, it is valid for $NAME - - if $VARIATION, it is valid for $NAME - - Returns: tuple($ENV or None, $NAME, $VARIATION or None) - """ - - all_tests = get_all_test_info() - - variation = None - if '~' in arg: - arg, variation = arg.split('~', 1) - - parts = arg.split('-', 2) - parts_len = len(parts) - - # If arg =~ test-$ENV-$NAME - if parts_len == 3 and parts[0] == "test" and parts[1] in all_environments: - _, env, name = parts - - # If arg =~ $ENV-$NAME - elif parts_len > 0 and parts[0] in all_environments: - env, name = parts[0], "-".join(parts[1:]) - - # If arg =~ $NAME - elif arg in all_tests: - env, name = None, arg - - # Otherwise, give up - else: - raise RunnerError("Unrecognised test '{0}'".format(arg)) - - # At this point, 'env' has always been checked for plausibility. 'name' - # might not be - - if name not in all_tests: - raise RunnerError("Unrecognised test name '{0}' for '{1}'" - .format(name, arg)) - - info = all_tests[name] - - if env and env not in info.envs: - raise RunnerError("Test '{0}' has no environment '{1}'" - .format(name, env)) - - # If a variation has been given, check it is valid - if variation is not None: - if not info.variations: - raise RunnerError("Test '{0}' has no variations".format(name)) - elif not variation in info.variations: - raise RunnerError("No variation '{0}' for test '{1}'" - .format(variation, name)) - - return env, name, variation - - -# Cached data from tests/*/info.json -_all_test_info = {} - -def get_all_test_info(): - """ Open and collate each info.json """ - if not _all_test_info: # Cache on first request - - for test in os.listdir("tests"): - try: - with open(path.join("tests", test, "info.json")) as f: - - info = TestInfo(json.load(f)) - - if info.name != test: - raise ValueError # JSON also looks bad - - _all_test_info[test] = info - - except (IOError, # Ignore directories without a info.json - ValueError, KeyError, TypeError): # Ingore bad JSON - continue - - return _all_test_info - - -# Cached virt caps -_virt_caps = set() - -def get_virt_caps(): - """ Query Xen for the virt capabilities of the host """ - global _virt_caps - - if not _virt_caps: # Cache on first request - - # Filter down to caps we're happy for tests to use - caps = {"pv", "hvm", "hap", "shadow"} - caps &= set(check_output(["xl", "info", "virt_caps"]).split()) - - # Synthesize a pv32 virt cap by looking at xen_caps - if ("pv" in caps and - "xen-3.0-x86_32p" in check_output(["xl", "info", "xen_caps"])): - caps |= {"pv32"} - - _virt_caps = caps - - return _virt_caps - - -def tests_from_selection(cats, envs, tests, caps): - """Given a selection of possible categories, environment and tests, return - all tests within the provided parameters. - - Multiple entries for each individual parameter are combined or-wise. - e.g. cats=['special', 'functional'] chooses all tests which are either - special or functional. envs=['hvm64', 'pv64'] chooses all tests which are - either pv64 or hvm64. - - Multiple parameter are combined and-wise, taking the intersection rather - than the union. e.g. cats=['functional'], envs=['pv64'] gets the tests - which are both part of the functional category and the pv64 environment. - - By default, not all categories are available. Selecting envs=['pv64'] - alone does not include the non-default categories, as this is most likely - not what the author intended. Any reference to non-default categories in - cats[] or tests[] turns them all back on, so non-default categories are - available when explicitly referenced. - """ - - all_tests = get_all_test_info() - all_test_info = all_tests.values() - res = [] - - if cats: - # If a selection of categories have been requested, start with all test - # instances in any of the requested categories. - for info in all_test_info: - if info.cat in cats: - res.extend(info.all_instances()) - - if envs: - # If a selection of environments have been requested, reduce the - # category selection to requested environments, or pick all suitable - # tests matching the environments request. - if res: - res = [ x for x in res if x.env in envs ] - else: - # Work out whether to include non-default categories or not. - categories = default_categories - if non_default_categories & set(cats): - categories = all_categories - - elif tests: - sel_test_names = set(x.name for x in tests) - sel_test_cats = set(all_tests[x].cat for x in sel_test_names) - - if non_default_categories & sel_test_cats: - categories = all_categories - - for info in all_test_info: - if info.cat in categories: - res.extend(info.all_instances(env_filter = envs)) - - if tests: - # If a selection of tests has been requested, reduce the results so - # far to the requested tests (this is meaningful in the case that - # tests[] has been specified without a specific environment), or just - # take the tests verbatim. - if res: - res = [ x for x in res if x in tests ] - else: - res = tests - - if caps: - res = [ x for x in res if x.req_caps.issubset(caps) ] - - # Sort the results. Variation third, Env second and Name fist. - res = sorted(res, key = lambda test: test.variation or "") - res = sorted(res, key = lambda test: test.env) - res = sorted(res, key = lambda test: test.name) - return res - - -def interpret_selection(opts): - """Interpret the argument list as a collection of categories, environments, - pseduo-environments and partial and complete test names. - - Returns a list of all test instances within the selection. - """ - - args = set(opts.args) - - # First, filter into large buckets - cats = all_categories & args - envs = all_environments & args - others = args - cats - envs - - # Add all categories if --all or --non-default is passed - if opts.all: - cats |= default_categories - if opts.non_default: - cats |= non_default_categories - - # Allow "pv" and "hvm" as a combination of environments - if "pv" in others: - envs |= pv_environments - others -= {"pv"} - - if "hvm" in others: - envs |= hvm_environments - others -= {"hvm"} - - # No input? No selection. - if not cats and not envs and not others: - return [] - - all_tests = get_all_test_info() - tests = [] - - # Second, sanity check others as full or partial test names - for arg in others: - env, name, vary = parse_test_instance_string(arg) - - instances = all_tests[name].all_instances( - env_filter = env and [env] or None, - vary_filter = vary and [vary] or None, - ) - - if not instances: - raise RunnerError("No appropriate instances for '{0}' (env {1})" - .format(arg, env)) - - tests.extend(instances) - - # Third, if --host is passed, also filter by capabilities - caps = None - if opts.host: - caps = get_virt_caps() - - return tests_from_selection(cats, envs, set(tests), caps) - - -def list_tests(opts): - """ List tests """ - - if opts.environments: - # The caller only wants the environment list - for env in sorted(all_environments): - print(env) - return - - if not opts.selection: - raise RunnerError("No tests selected") - - for sel in opts.selection: - print(sel) - - -def interpret_result(logline): - """ Interpret the final log line of a guest for a result """ - - if not "Test result:" in logline: - return "CRASH" - - for res in all_results: - if res in logline: - return res - - return "CRASH" - - -def run_test_console(opts, test): - """ Run a specific, obtaining results via xenconsole """ - - cmd = ['xl', 'create', '-p', test.cfg_path()] - if not opts.quiet: - print("Executing '{0}'".format(" ".join(cmd))) - - create = Popen(cmd, stdout = PIPE, stderr = PIPE) - _, stderr = create.communicate() - - if create.returncode: - if opts.quiet: - print("Executing '{0}'".format(" ".join(cmd))) - print(stderr) - raise RunnerError("Failed to create VM") - - cmd = ['xl', 'console', test.vm_name()] - if not opts.quiet: - print("Executing '{0}'".format(" ".join(cmd))) - - console = Popen(cmd, stdout = PIPE) - - cmd = ['xl', 'unpause', test.vm_name()] - if not opts.quiet: - print("Executing '{0}'".format(" ".join(cmd))) - - rc = subproc_call(cmd) - if rc: - if opts.quiet: - print("Executing '{0}'".format(" ".join(cmd))) - raise RunnerError("Failed to unpause VM") - - stdout, _ = console.communicate() - - if console.returncode: - raise RunnerError("Failed to obtain VM console") - - lines = stdout.splitlines() - - if lines: - if not opts.quiet: - print("\n".join(lines)) - print("") - - else: - return "CRASH" - - return interpret_result(lines[-1]) - - -def run_test_logfile(opts, test): - """ Run a specific test, obtaining results from a logfile """ - - logpath = path.join(opts.logfile_dir, - opts.logfile_pattern.replace("%s", str(test))) - - if not opts.quiet: - print("Using logfile '{0}'".format(logpath)) - - fd = os.open(logpath, os.O_CREAT | os.O_RDONLY, 0o644) - logfile = os.fdopen(fd) - logfile.seek(0, os.SEEK_END) - - cmd = ['xl', 'create', '-F', test.cfg_path()] - if not opts.quiet: - print("Executing '{0}'".format(" ".join(cmd))) - - guest = Popen(cmd, stdout = PIPE, stderr = PIPE) - - _, stderr = guest.communicate() - - if guest.returncode: - if opts.quiet: - print("Executing '{0}'".format(" ".join(cmd))) - print(stderr) - raise RunnerError("Failed to run test") - - line = "" - for line in logfile.readlines(): - - line = line.rstrip() - if not opts.quiet: - print(line) - - if "Test result:" in line: - print("") - break - - logfile.close() - - return interpret_result(line) - - -def run_test(opts, test): - """ Run a single test instance """ - - # If caps say the test can't run, short circuit to SKIP - if not test.req_caps.issubset(get_virt_caps()): - return "SKIP" - - fn = { - "console": run_test_console, - "logfile": run_test_logfile, - }[opts.results_mode] - - return fn(opts, test) - - -def run_tests(opts): - """ Run tests """ - - tests = opts.selection - if not tests: - raise RunnerError("No tests to run") - - rc = all_results.index('SUCCESS') - results = [] - - for test in tests: - - res = run_test(opts, test) - res_idx = all_results.index(res) - if res_idx > rc: - rc = res_idx - - results.append(res) - - print("Combined test results:") - - for test, res in zip(tests, results): - - if res == "SUCCESS" and opts.quiet >= 2: - continue - - print("{0:<40} {1}".format(str(test), res)) - - return exit_code(all_results[rc]) - - -def main(): - """ Main entrypoint """ - - # Change stdout to be line-buffered. - sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1) - - # Normalise $CWD to the directory this script is in - os.chdir(path.dirname(path.abspath(sys.argv[0]))) - - # Avoid wrapping the epilog text - OptionParser.format_epilog = lambda self, formatter: self.epilog - - parser = OptionParser( - usage = "%prog [--list] [options]", - description = "Xen Test Framework enumeration and running tool", - epilog = ( - "\n" - "Overview:\n" - " Running with --list will print the entire selection\n" - " to the console. Running without --list will execute\n" - " all tests in the selection, printing a summary of their\n" - " results at the end.\n" - "\n" - " To determine how runner should get output from Xen, use\n" - ' --results-mode option. The default value is "console", \n' - " which means using xenconsole program to extract output.\n" - ' The other supported value is "logfile", which\n' - " means to get output from log file.\n" - "\n" - ' The "logfile" mode requires users to configure\n' - " xenconsoled to log guest console output. This mode\n" - " is useful for Xen version < 4.8. Also see --logfile-dir\n" - " and --logfile-pattern options.\n" - "\n" - "Selections:\n" - " A selection is zero or more of any of the following\n" - " parameters: Categories, Environments and Tests.\n" - " Multiple instances of the same type of parameter are\n" - " unioned while the end result in intersected across\n" - " types. e.g.\n" - "\n" - " 'functional xsa'\n" - " All tests in the functional and xsa categories\n" - "\n" - " 'functional xsa hvm32'\n" - " All tests in the functional and xsa categories\n" - " which are implemented for the hvm32 environment\n" - "\n" - " 'invlpg example'\n" - " The invlpg and example tests in all implemented\n" - " environments\n" - "\n" - " 'invlpg example pv'\n" - " The pv environments of the invlpg and example tests\n" - "\n" - " 'pv32pae-pv-iopl'\n" - " The pv32pae environment of the pv-iopl test only\n" - "\n" - " Additionally, --host may be passed to restrict the\n" - " selection to tests applicable to the current host.\n" - " --all may be passed to choose all default categories\n" - " without needing to explicitly name them. --non-default\n" - " is available to obtain the non-default categories.\n" - "\n" - " The special parameter --environments may be passed to\n" - " get the full list of environments. This option does not\n" - " make sense combined with a selection.\n" - "\n" - "Examples:\n" - " Listing all tests implemented for hvm32 environment:\n" - " ./xtf-runner --list hvm32\n" - "\n" - " Listing all functional tests appropriate for this host:\n" - " ./xtf-runner --list functional --host\n" - "\n" - " Running all the pv-iopl tests:\n" - " ./xtf-runner pv-iopl\n" - " \n" - " Combined test results:\n" - " test-pv64-pv-iopl SUCCESS\n" - " test-pv32pae-pv-iopl SUCCESS\n" - "\n" - " Exit code for this script:\n" - " 0: everything is ok\n" - " 1,2: reserved for python interpreter\n" - " 3: test(s) are skipped\n" - " 4: test(s) report error\n" - " 5: test(s) report failure\n" - " 6: test(s) crashed\n" - "\n" - ), - ) - - parser.add_option("-l", "--list", action = "store_true", - dest = "list_tests", - help = "List tests in the selection", - ) - parser.add_option("-a", "--all", action = "store_true", - dest = "all", - help = "Select all default categories", - ) - parser.add_option("--non-default", action = "store_true", - dest = "non_default", - help = "Select all non default categories", - ) - parser.add_option("--environments", action = "store_true", - dest = "environments", - help = "List all the known environments", - ) - parser.add_option("--host", action = "store_true", - dest = "host", help = "Restrict selection to applicable" - " tests for the current host", - ) - parser.add_option("-m", "--results-mode", action = "store", - dest = "results_mode", default = "console", - type = "choice", choices = ("console", "logfile"), - help = "Control how xtf-runner gets its test results") - parser.add_option("--logfile-dir", action = "store", - dest = "logfile_dir", default = "/var/log/xen/console/", - type = "string", - help = ('Specify the directory to look for console logs, ' - 'defaults to "/var/log/xen/console/"'), - ) - parser.add_option("--logfile-pattern", action = "store", - dest = "logfile_pattern", default = "guest-%s.log", - type = "string", - help = ('Specify the log file name pattern, ' - 'defaults to "guest-%s.log"'), - ) - parser.add_option("-q", "--quiet", action = "count", - dest = "quiet", default = 0, - help = ("Progressively make the output less verbose. " - "1) No console logs, only test results. " - "2) Not even SUCCESS results."), - ) - - opts, args = parser.parse_args() - opts.args = args - - opts.selection = interpret_selection(opts) - - if opts.list_tests: - return list_tests(opts) - else: - return run_tests(opts) +from xtf.runner.cli import RunnerError, main if __name__ == "__main__": try: diff --git a/xtf/__init__.py b/xtf/__init__.py new file mode 100644 index 0000000..8324a00 --- /dev/null +++ b/xtf/__init__.py @@ -0,0 +1 @@ +"""Python support code for the Xen Test Framework.""" diff --git a/xtf/runner/__init__.py b/xtf/runner/__init__.py new file mode 100644 index 0000000..88a65d6 --- /dev/null +++ b/xtf/runner/__init__.py @@ -0,0 +1 @@ +"""Importable implementation of xtf-runner.""" diff --git a/xtf/runner/cli.py b/xtf/runner/cli.py new file mode 100755 index 0000000..adac4ed --- /dev/null +++ b/xtf/runner/cli.py @@ -0,0 +1,805 @@ +#!/usr/bin/env python3 +""" +xtf-runner - A utility for enumerating and running XTF tests. + +Currently assumes the presence and availability of the `xl` toolstack. +""" + +import json +import os +import subprocess +import sys +from argparse import ArgumentParser, Namespace, RawDescriptionHelpFormatter +from functools import partial +from os import path +from subprocess import PIPE +from typing import Any, Dict, List, Optional, Set, Tuple + +# Wrap Subprocess functions to use universal_newlines by default +Popen = partial(subprocess.Popen, universal_newlines=True) +subproc_call = partial(subprocess.call, universal_newlines=True) +check_output = partial(subprocess.check_output, universal_newlines=True) + + +# All results of a test, keep in sync with C code report.h where applicable. +# Notes: +# - WARNING is not a result on its own. +# - CRASH isn't known to the C code, but covers all cases where a valid +# result was not found. +# - XFAIL isn't known to the C code, but covers marked expected crashes. +all_results = ("SUCCESS", "XFAIL", "SKIP", "ERROR", "FAILURE", "CRASH") + + +# Return the exit code for different states. Avoid using 1 and 2 because +# python interpreter uses them -- see document for sys.exit. +def exit_code(state: str) -> int: + """Convert a test result to an xtf-runner exit code.""" + return { + "SUCCESS": 0, + "XFAIL": 0, + "SKIP": 3, + "ERROR": 4, + "FAILURE": 5, + "CRASH": 6, + }[state] + + +# All test categories +default_categories = {"functional", "xsa", "nested-svm"} +non_default_categories = {"special", "utility", "in-development"} +all_categories = default_categories | non_default_categories + +# All test environments +pv_environments = {"pv64", "pv32pae"} +hvm_environments = {"hvm64", "hvm32pae", "hvm32pse", "hvm32"} +all_environments = pv_environments | hvm_environments + + +class RunnerError(Exception): + """Errors relating to xtf-runner itself""" + + +def env_to_virt_caps(env: str) -> Set[str]: + """Identify which virt cap(s) are needed for an environment""" + if env in hvm_environments: + return {"hvm"} + caps = {"pv"} + if env == "pv32pae": + caps |= {"pv32"} + return caps + + +class TestInstance: + """Object representing a single test.""" + + def __init__(self, arg: str) -> None: + """Parse and verify 'arg' as a test instance.""" + env_result, name_result, variation_result = parse_test_instance_string(arg) + self.env: Optional[str] = env_result + self.name: str = name_result + self.variation: Optional[str] = variation_result + + if self.env is None: + raise RunnerError(f"No environment for '{arg}'") + + if self.variation is None and get_all_test_info()[self.name].variations: + raise RunnerError(f"Test '{self.name}' has variations, but none specified") + + self.req_caps: Set[str] = env_to_virt_caps(self.env) + self.req_caps |= {"hap", "shadow"} & {self.variation} + + def vm_name(self) -> str: + """Return the VM name as `xl` expects it.""" + return repr(self) + + def cfg_path(self) -> str: + """Return the path to the `xl` config file for this test.""" + return path.join("tests", self.name, repr(self) + ".cfg") + + def __repr__(self) -> str: + if not self.variation: + return f"test-{self.env}-{self.name}" + return f"test-{self.env}-{self.name}~{self.variation}" + + def __hash__(self) -> int: + return hash(repr(self)) + + def __eq__(self, other: Any) -> bool: + return repr(self) == repr(other) + + +class TestInfo: + """Object representing a tests info.json, in a more convenient form.""" + + def __init__(self, test_json: Any) -> None: + """Parse and verify 'test_json'. + + May raise KeyError, TypeError or ValueError. + """ + + name: Any = test_json["name"] + if not isinstance(name, str): + raise TypeError(f"Expected string for 'name', got '{type(name)}'") + self.name: str = name + + cat: Any = test_json["category"] + if not isinstance(cat, str): + raise TypeError(f"Expected string for 'category', got '{type(cat)}'") + if cat not in all_categories: + raise ValueError(f"Unknown category '{cat}'") + self.cat: str = cat + + envs: Any = test_json["environments"] + if not isinstance(envs, list): + raise TypeError(f"Expected list for 'environments', got '{type(envs)}'") + if not envs: + raise ValueError("Expected at least one environment") + for env in envs: + if env not in all_environments: + raise ValueError(f"Unknown environments '{env}'") + self.envs: List[str] = envs + + variations: Any = test_json["variations"] + if not isinstance(variations, list): + raise TypeError(f"Expected list for 'variations', got '{type(variations)}'") + self.variations: List[str] = variations + + def all_instances( + self, + env_filter: Optional[Set[str]] = None, + vary_filter: Optional[List[str]] = None, + ) -> List[TestInstance]: + """Return a list of TestInstances, for each supported environment. + Optionally filtered by env_filter. May return an empty list if + the filter doesn't match any supported environment. + """ + + if env_filter: + envs_list = list(set(env_filter).intersection(self.envs)) + else: + envs_list = self.envs + + if vary_filter: + variations_list = list(set(vary_filter).intersection(self.variations)) + else: + variations_list = self.variations + + res = [] + if variations_list: + for env in envs_list: + for vary in variations_list: + res.append(TestInstance(f"test-{env}-{self.name}~{vary}")) + else: + res = [TestInstance(f"test-{env}-{self.name}") for env in envs_list] + return res + + def __repr__(self) -> str: + return f"TestInfo({self.name})" + + +def parse_test_instance_string(arg: str) -> Tuple[Optional[str], str, Optional[str]]: + """Parse a test instance string. + + Has the form: '[[test-]$ENV-]$NAME[~$VARIATION]' + + Optional 'test-' prefix + Optional $ENV environment part + Mandatory $NAME + Optional ~$VARIATION suffix + + Verifies: + - $NAME is valid + - if $ENV, it is valid for $NAME + - if $VARIATION, it is valid for $NAME + + Returns: tuple($ENV or None, $NAME, $VARIATION or None) + """ + + all_tests = get_all_test_info() + + variation = None + if "~" in arg: + arg, variation = arg.split("~", 1) + + parts = arg.split("-", 2) + parts_len = len(parts) + + env = None + name = "" + + # If arg =~ test-$ENV-$NAME + if parts_len == 3 and parts[0] == "test" and parts[1] in all_environments: + _, env, name = parts + + # If arg =~ $ENV-$NAME + elif parts_len > 0 and parts[0] in all_environments: + env, name = parts[0], "-".join(parts[1:]) + + # If arg =~ $NAME + elif arg in all_tests: + env, name = None, arg + + # Otherwise, give up + else: + raise RunnerError(f"Unrecognised test '{arg}'") + + # At this point, 'env' has always been checked for plausibility. 'name' + # might not be + + if name not in all_tests: + raise RunnerError(f"Unrecognised test name '{name}' for '{arg}'") + + info = all_tests[name] + + if env and env not in info.envs: + raise RunnerError(f"Test '{name}' has no environment '{env}'") + + # If a variation has been given, check it is valid + if variation is not None: + if not info.variations: + raise RunnerError(f"Test '{name}' has no variations") + if variation not in info.variations: + raise RunnerError(f"No variation '{variation}' for test '{name}'") + + return env, name, variation + + +# Cached data from tests/*/info.json +_all_test_info: Dict[str, TestInfo] = {} + + +def get_all_test_info() -> Dict[str, TestInfo]: + """Open and collate each info.json""" + if not _all_test_info: # Cache on first request + + for test in os.listdir("tests"): + try: + with open(path.join("tests", test, "info.json")) as file: + info = TestInfo(json.load(file)) + + if info.name != test: + raise ValueError # JSON also looks bad + + _all_test_info[test] = info + + except ( + IOError, # Ignore directories without a info.json + ValueError, + KeyError, + TypeError, + ): # Ignore bad JSON + continue + + return _all_test_info + + +# Cached virt caps +_virt_caps: Set[str] = set() + + +def get_virt_caps() -> Set[str]: + """Query Xen for the virt capabilities of the host""" + global _virt_caps + + if not _virt_caps: # Cache on first request + + # Filter down to caps we're happy for tests to use + caps = {"pv", "hvm", "hap", "shadow"} + caps &= set(check_output(["xl", "info", "virt_caps"]).split()) + + # Synthesize a pv32 virt cap by looking at xen_caps + if "pv" in caps and "xen-3.0-x86_32p" in check_output( + ["xl", "info", "xen_caps"] + ): + caps |= {"pv32"} + + _virt_caps = caps + + return _virt_caps + + +def reset_all_test_info() -> None: + """Reset the cached test info (mainly for testing).""" + global _all_test_info + _all_test_info = {} + + +def reset_virt_caps() -> None: + """Reset the cached virt caps (mainly for testing).""" + global _virt_caps + _virt_caps = set() + + +def tests_from_selection( + cats: Optional[Set[str]], + envs: Optional[Set[str]], + tests: Set[TestInstance], + caps: Optional[Set[str]], +) -> List[TestInstance]: + """Given a selection of possible categories, environment and tests, return + all tests within the provided parameters. + + Multiple entries for each individual parameter are combined or-wise. + e.g. cats=['special', 'functional'] chooses all tests which are either + special or functional. envs=['hvm64', 'pv64'] chooses all tests which are + either pv64 or hvm64. + + Multiple parameter are combined and-wise, taking the intersection rather + than the union. e.g. cats=['functional'], envs=['pv64'] gets the tests + which are both part of the functional category and the pv64 environment. + + By default, not all categories are available. Selecting envs=['pv64'] + alone does not include the non-default categories, as this is most likely + not what the author intended. Any reference to non-default categories in + cats[] or tests[] turns them all back on, so non-default categories are + available when explicitly referenced. + """ + + all_tests = get_all_test_info() + all_test_info = all_tests.values() + res = [] + + if cats: + # If a selection of categories have been requested, start with all test + # instances in any of the requested categories. + for info in all_test_info: + if info.cat in cats: + res.extend(info.all_instances()) + + if envs: + # If a selection of environments have been requested, reduce the + # category selection to requested environments, or pick all suitable + # tests matching the environments request. + if res: + res = [x for x in res if x.env in envs] + else: + # Work out whether to include non-default categories or not. + categories = default_categories + if cats and non_default_categories & set(cats): + categories = all_categories + + elif tests: + sel_test_names = {x.name for x in tests} + sel_test_cats = {all_tests[x].cat for x in sel_test_names} + + if non_default_categories & sel_test_cats: + categories = all_categories + + for info in all_test_info: + if info.cat in categories: + res.extend(info.all_instances(env_filter=envs)) + + if tests: + # If a selection of tests has been requested, reduce the results so + # far to the requested tests (this is meaningful in the case that + # tests[] has been specified without a specific environment), or just + # take the tests verbatim. + if res: + res = [x for x in res if x in tests] + else: + res = list(tests) + + if caps: + res = [x for x in res if x.req_caps.issubset(caps)] + + # Sort the results. Variation third, Env second and Name fist. + res = sorted(res, key=lambda test: test.variation if test.variation else "") + res = sorted(res, key=lambda test: test.env if test.env else "") + res = sorted(res, key=lambda test: test.name) + return res + + +def interpret_selection(opts: Namespace) -> List[TestInstance]: + """Interpret the argument list as a collection of categories, environments, + pseduo-environments and partial and complete test names. + + Returns a list of all test instances within the selection. + """ + + args = set(opts.args) + + # First, filter into large buckets + cats = all_categories & args + envs = all_environments & args + others = args - cats - envs + + # Add all categories if --all or --non-default is passed + if opts.all: + cats |= default_categories + if opts.non_default: + cats |= non_default_categories + + # Allow "pv" and "hvm" as a combination of environments + if "pv" in others: + envs |= pv_environments + others -= {"pv"} + + if "hvm" in others: + envs |= hvm_environments + others -= {"hvm"} + + # No input? No selection. + if not cats and not envs and not others: + return [] + + all_tests = get_all_test_info() + tests = [] + + # Second, sanity check others as full or partial test names + for arg in others: + env, name, vary = parse_test_instance_string(arg) + + instances = all_tests[name].all_instances( + env_filter={env} if env else None, + vary_filter=[vary] if vary else None, + ) + + if not instances: + raise RunnerError(f"No appropriate instances for '{arg}' (env {env})") + + tests.extend(instances) + + # Third, if --host is passed, also filter by capabilities + caps = None + if opts.host: + caps = get_virt_caps() + + return tests_from_selection(cats, envs, set(tests), caps) + + +def list_tests(opts: Namespace) -> None: + """List tests""" + + if opts.environments: + # The caller only wants the environment list + for env in sorted(all_environments): + print(env) + return + + if not opts.selection: + raise RunnerError("No tests selected") + + for sel in opts.selection: + print(sel) + + +def interpret_result(logline: str) -> str: + """Interpret the final log line of a guest for a result""" + + if "Test result:" not in logline: + return "CRASH" + + for res in all_results: + if res in logline: + return res + + return "CRASH" + + +def interpret_console_lines(lines: List[str]) -> str: + """Interpret a guest console log for a result.""" + + res = interpret_result(lines[-1]) + if res == "CRASH" and any("XFAIL:" in line for line in lines): + return "XFAIL" + + return res + + +def run_test_console(opts: Namespace, test: TestInstance) -> str: + """Run a specific, obtaining results via xenconsole""" + + cmd = ["xl", "create", "-p", test.cfg_path()] + if not opts.quiet: + print(f"Executing '{' '.join(cmd)}'") + + create = Popen(cmd, stdout=PIPE, stderr=PIPE) + _, stderr = create.communicate() + + if create.returncode: + if opts.quiet: + print(f"Executing '{' '.join(cmd)}'") + print(stderr) + raise RunnerError("Failed to create VM") + + cmd = ["xl", "console", test.vm_name()] + if not opts.quiet: + print(f"Executing '{' '.join(cmd)}'") + + console = Popen(cmd, stdout=PIPE) + + cmd = ["xl", "unpause", test.vm_name()] + if not opts.quiet: + print(f"Executing '{' '.join(cmd)}'") + + rc = subproc_call(cmd) + if rc: + if opts.quiet: + print(f"Executing '{' '.join(cmd)}'") + raise RunnerError("Failed to unpause VM") + + stdout, _ = console.communicate() + + if console.returncode: + raise RunnerError("Failed to obtain VM console") + + lines = stdout.splitlines() + + if lines: + if not opts.quiet: + print("\n".join(lines)) + print("") + + else: + return "CRASH" + + return interpret_console_lines(lines) + + +def run_test_logfile(opts: Namespace, test: TestInstance) -> str: + """Run a specific test, obtaining results from a logfile""" + + logpath = path.join(opts.logfile_dir, opts.logfile_pattern.replace("%s", str(test))) + + if not opts.quiet: + print(f"Using logfile '{logpath}'") + + fd = os.open(logpath, os.O_CREAT | os.O_RDONLY, 0o644) + logfile = os.fdopen(fd) + logfile.seek(0, os.SEEK_END) + + cmd = ["xl", "create", "-F", test.cfg_path()] + if not opts.quiet: + print(f"Executing '{' '.join(cmd)}'") + + guest = Popen(cmd, stdout=PIPE, stderr=PIPE) + + _, stderr = guest.communicate() + + if guest.returncode: + if opts.quiet: + print(f"Executing '{' '.join(cmd)}'") + print(stderr) + raise RunnerError("Failed to run test") + + line = "" + saw_xfail = False + for line in logfile.readlines(): + + line = line.rstrip() + if not opts.quiet: + print(line) + + saw_xfail |= "XFAIL:" in line + + if "Test result:" in line: + print("") + break + + logfile.close() + + res = interpret_result(line) + if res == "CRASH" and saw_xfail: + return "XFAIL" + + return res + + +def run_test(opts: Namespace, test: TestInstance) -> str: + """Run a single test instance""" + + # If caps say the test can't run, short circuit to SKIP + if not test.req_caps.issubset(get_virt_caps()): + return "SKIP" + + fn: Any = { + "console": run_test_console, + "logfile": run_test_logfile, + }[opts.results_mode] + + return fn(opts, test) # type: ignore[no-any-return] + + +def run_tests(opts: Namespace) -> int: + """Run tests""" + + tests = opts.selection + if not tests: + raise RunnerError("No tests to run") + + rc = all_results.index("SUCCESS") + results = [] + + for test in tests: + + res = run_test(opts, test) + res_idx = all_results.index(res) + rc = max(rc, res_idx) + + results.append(res) + + print("Combined test results:") + + for test, res in zip(tests, results): + + if res == "SUCCESS" and opts.quiet >= 2: + continue + + print(f"{str(test):<40} {res}") + + return exit_code(all_results[rc]) + + +def main(root: Optional[str] = None, line_buffer_stdout: bool = True) -> int: + """Main entrypoint""" + + # Change stdout to be line-buffered. + if line_buffer_stdout: + sys.stdout = os.fdopen(sys.stdout.fileno(), "w", 1) + + # Normalise $CWD to the directory this script is in + if root is None: + root = path.dirname(path.abspath(sys.argv[0])) + os.chdir(root) + + parser = ArgumentParser( + usage="%(prog)s [--list] [options]", + description="Xen Test Framework enumeration and running tool", + formatter_class=RawDescriptionHelpFormatter, + epilog=( + "\n" + "Overview:\n" + " Running with --list will print the entire selection\n" + " to the console. Running without --list will execute\n" + " all tests in the selection, printing a summary of their\n" + " results at the end.\n" + "\n" + " To determine how runner should get output from Xen, use\n" + ' --results-mode option. The default value is "console", \n' + " which means using xenconsole program to extract output.\n" + ' The other supported value is "logfile", which\n' + " means to get output from log file.\n" + "\n" + ' The "logfile" mode requires users to configure\n' + " xenconsoled to log guest console output. This mode\n" + " is useful for Xen version < 4.8. Also see --logfile-dir\n" + " and --logfile-pattern options.\n" + "\n" + "Selections:\n" + " A selection is zero or more of any of the following\n" + " parameters: Categories, Environments and Tests.\n" + " Multiple instances of the same type of parameter are\n" + " unioned while the end result in intersected across\n" + " types. e.g.\n" + "\n" + " 'functional xsa'\n" + " All tests in the functional and xsa categories\n" + "\n" + " 'functional xsa hvm32'\n" + " All tests in the functional and xsa categories\n" + " which are implemented for the hvm32 environment\n" + "\n" + " 'invlpg example'\n" + " The invlpg and example tests in all implemented\n" + " environments\n" + "\n" + " 'invlpg example pv'\n" + " The pv environments of the invlpg and example tests\n" + "\n" + " 'pv32pae-pv-iopl'\n" + " The pv32pae environment of the pv-iopl test only\n" + "\n" + " Additionally, --host may be passed to restrict the\n" + " selection to tests applicable to the current host.\n" + " --all may be passed to choose all default categories\n" + " without needing to explicitly name them. --non-default\n" + " is available to obtain the non-default categories.\n" + "\n" + " The special parameter --environments may be passed to\n" + " get the full list of environments. This option does not\n" + " make sense combined with a selection.\n" + "\n" + "Examples:\n" + " Listing all tests implemented for hvm32 environment:\n" + " ./xtf-runner --list hvm32\n" + "\n" + " Listing all functional tests appropriate for this host:\n" + " ./xtf-runner --list functional --host\n" + "\n" + " Running all the pv-iopl tests:\n" + " ./xtf-runner pv-iopl\n" + " \n" + " Combined test results:\n" + " test-pv64-pv-iopl SUCCESS\n" + " test-pv32pae-pv-iopl SUCCESS\n" + "\n" + " Exit code for this script:\n" + " 0: everything is ok\n" + " 1,2: reserved for python interpreter\n" + " 3: test(s) are skipped\n" + " 4: test(s) report error\n" + " 5: test(s) report failure\n" + " 6: test(s) crashed\n" + "\n" + ), + ) + + parser.add_argument( + "-l", + "--list", + action="store_true", + dest="list_tests", + help="List tests in the selection", + ) + parser.add_argument( + "-a", + "--all", + action="store_true", + dest="all", + help="Select all default categories", + ) + parser.add_argument( + "--non-default", + action="store_true", + dest="non_default", + help="Select all non default categories", + ) + parser.add_argument( + "--environments", + action="store_true", + dest="environments", + help="List all the known environments", + ) + parser.add_argument( + "--host", + action="store_true", + dest="host", + help="Restrict selection to applicable" " tests for the current host", + ) + parser.add_argument( + "-m", + "--results-mode", + action="store", + dest="results_mode", + default="console", + choices=["console", "logfile"], + help="Control how xtf-runner gets its test results", + ) + parser.add_argument( + "--logfile-dir", + action="store", + dest="logfile_dir", + default="/var/log/xen/console/", + help=( + "Specify the directory to look for console logs, " + 'defaults to "/var/log/xen/console/"' + ), + ) + parser.add_argument( + "--logfile-pattern", + action="store", + dest="logfile_pattern", + default="guest-%s.log", + help=("Specify the log file name pattern, " 'defaults to "guest-%%s.log"'), + ) + parser.add_argument( + "-q", + "--quiet", + action="count", + dest="quiet", + default=0, + help=( + "Progressively make the output less verbose. " + "1) No console logs, only test results. " + "2) Not even SUCCESS results." + ), + ) + + opts, args = parser.parse_known_args() + opts.args = args + opts.selection = interpret_selection(opts) + + if opts.list_tests: + list_tests(opts) + return 0 + return run_tests(opts) diff --git a/xtf/runner/selftest.py b/xtf/runner/selftest.py new file mode 100644 index 0000000..bf27438 --- /dev/null +++ b/xtf/runner/selftest.py @@ -0,0 +1,161 @@ +"""Selftests for the importable xtf-runner implementation.""" + +import contextlib +import io +import json +import os +import sys +import tempfile +import unittest +from os import path +from types import SimpleNamespace +from typing import Generator, List, Optional, Tuple +from unittest import mock + +from xtf.runner import cli as runner + + +def _write_test_info( + root: str, name: str, envs: Optional[List[str]] = None, category: str = "functional" +) -> None: + """Create a minimal XTF test metadata file under a temporary root.""" + if envs is None: + envs = ["hvm64"] + + test_dir = path.join(root, "tests", name) + os.makedirs(test_dir) + + with open(path.join(test_dir, "info.json"), "w", encoding="utf-8") as info: + json.dump( + { + "name": name, + "category": category, + "environments": envs, + "variations": [], + }, + info, + ) + + +@contextlib.contextmanager +def _temporary_runner_root() -> Generator[str, None, None]: + """Use a temporary directory as the runner's repository root.""" + cwd = os.getcwd() + argv = sys.argv[:] + + with tempfile.TemporaryDirectory() as tmpdir: + os.makedirs(path.join(tmpdir, "tests")) + + runner.reset_all_test_info() + runner.reset_virt_caps() + + try: + yield tmpdir + finally: + os.chdir(cwd) + sys.argv = argv + runner.reset_all_test_info() + runner.reset_virt_caps() + + +class RunnerCliTests(unittest.TestCase): + """Tests for legacy CLI behaviour through xtf.runner.cli.""" + + def test_list_prints_selected_test_instances(self) -> None: + """`xtf-runner --list NAME` prints matching test instances.""" + with _temporary_runner_root() as tmpdir: + _write_test_info(tmpdir, "alpha", ["hvm64", "hvm32"]) + sys.argv = [path.join(tmpdir, "xtf-runner"), "--list", "alpha"] + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + rc = runner.main(root=tmpdir, line_buffer_stdout=False) + + self.assertIsNone(rc) + self.assertEqual( + stdout.getvalue().splitlines(), + [ + "test-hvm32-alpha", + "test-hvm64-alpha", + ], + ) + + def test_run_prints_combined_results_and_exit_code(self) -> None: + """Running a selected test preserves summary and exit-code mapping.""" + with _temporary_runner_root() as tmpdir: + _write_test_info(tmpdir, "alpha") + sys.argv = [path.join(tmpdir, "xtf-runner"), "hvm64-alpha", "-q"] + + stdout = io.StringIO() + with mock.patch.object( + runner, "get_virt_caps", return_value={"hvm"} + ), mock.patch.object( + runner, "run_test_console", return_value="SUCCESS" + ) as run_console, contextlib.redirect_stdout( + stdout + ): + rc = runner.main(root=tmpdir, line_buffer_stdout=False) + + self.assertEqual(rc, runner.exit_code("SUCCESS")) + run_console.assert_called_once() + self.assertEqual(str(run_console.call_args[0][1]), "test-hvm64-alpha") + self.assertIn("Combined test results:", stdout.getvalue()) + self.assertIn("test-hvm64-alpha", stdout.getvalue()) + self.assertIn("SUCCESS", stdout.getvalue()) + + def test_run_test_console_uses_xl_lifecycle(self) -> None: + """Console-mode execution still uses create-paused/console/unpause.""" + opts = SimpleNamespace(quiet=1) + test = SimpleNamespace( + cfg_path=lambda: "tests/alpha/test-hvm64-alpha.cfg", + vm_name=lambda: "test-hvm64-alpha", + ) + + popen_calls = [] + + class FakeProcess: + """Small fake for subprocess.Popen return objects.""" + + def __init__( + self, + cmd: List[str], + stdout: Optional[object] = None, + stderr: Optional[object] = None, + ) -> None: + del stdout, stderr + self.cmd = cmd + self.returncode = 0 + popen_calls.append(cmd) + + def communicate(self) -> Tuple[str, Optional[str]]: + """Simulate a successful console session with a test result.""" + if self.cmd[:2] == ["xl", "console"]: + return "boot\nTest result: SUCCESS\n", None + return "", "" + + with mock.patch.object( + runner, "Popen", side_effect=FakeProcess + ), mock.patch.object(runner, "subproc_call", return_value=0) as call: + result = runner.run_test_console(opts, test) # type: ignore[arg-type] + + self.assertEqual(result, "SUCCESS") + self.assertEqual( + popen_calls, + [ + ["xl", "create", "-p", "tests/alpha/test-hvm64-alpha.cfg"], + ["xl", "console", "test-hvm64-alpha"], + ], + ) + call.assert_called_once_with(["xl", "unpause", "test-hvm64-alpha"]) + + def test_marked_crash_is_xfail(self) -> None: + """Marked crashes are reported as expected failures.""" + self.assertEqual( + runner.interpret_console_lines(["boot", "XFAIL: expected crash"]), + "XFAIL", + ) + self.assertEqual(runner.interpret_console_lines(["boot"]), "CRASH") + + +if __name__ == "__main__": + unittest.main() From 19c9814fcbb3523552f3f8353e00ec33dea06c5b Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Thu, 9 Jul 2026 12:00:00 +0000 Subject: [PATCH 14/19] common/nested-svm: Add common infrastructure for testing nested VMRUN Based on an initial experiment by Ross to run a minimal L2. Signed-off-by: Ross Lagerwall Signed-off-by: Bernhard Kaindl --- common/nested-svm/setup-l2.c | 188 ++++++++++++ include/nested-svm/setup-l2.h | 49 ++++ include/nested-svm/vmcb.h | 519 ++++++++++++++++++++++++++++++++++ 3 files changed, 756 insertions(+) create mode 100644 common/nested-svm/setup-l2.c create mode 100644 include/nested-svm/setup-l2.h create mode 100644 include/nested-svm/vmcb.h diff --git a/common/nested-svm/setup-l2.c b/common/nested-svm/setup-l2.c new file mode 100644 index 0000000..568addd --- /dev/null +++ b/common/nested-svm/setup-l2.c @@ -0,0 +1,188 @@ +#include + +/* AMD MSRs. */ + +/* The VMRUN host-save area */ +#define MSR_VM_HSAVE_PA 0xc0010117U + +/* + * The L2 VMCB lives here. VMRUN auto-saves and restores the bulk of + * L1's state via the host-save area pointed to by MSR_VM_HSAVE_PA. + */ +struct vmcb vmcb12 __page_aligned_bss; + +/* Backing store for the VMRUN host-save area (MSR_VM_HSAVE_PA). */ +uint8_t hsave[PAGE_SIZE] __page_aligned_bss; + +/* Stack used by L2. Two pages of backing store. */ +uint8_t l2_stack[2 * PAGE_SIZE] __page_aligned_bss; + +/* Private IDT used by L2 guests in nested-SVM tests. */ +static env_gate l2_idt[256] __page_aligned_bss; + +/* Build a segment descriptor for the VMCB from a user descriptor. */ +static uint16_t user_desc_vmcb_attr(const user_desc *desc) +{ + return desc->type | + (desc->s << 4) | + (desc->dpl << 5) | + (desc->p << 7) | + (desc->limit1 << 8) | + (desc->avl << 12) | + (desc->l << 13) | + (desc->d << 14) | + (desc->g << 15); +} + +/* Check if a selector is null. */ +static bool selector_is_null(uint16_t sel) +{ + return !(sel & ~(X86_SEL_TI | X86_SEL_RPL_MASK)); +} + +/* Mark a segment as unusable in the VMCB. */ +static void vmcb_set_seg_unusable(struct vmcb_seg *seg, uint16_t sel) +{ + seg->sel = sel; + seg->attr = 0; + seg->limit = 0; + seg->base = 0; +} + +/* Build a segment descriptor for the VMCB from a user descriptor. */ +static void vmcb_set_seg_desc(struct vmcb_seg *seg, const user_desc *gdt, + uint16_t gdt_limit, uint16_t sel) +{ + uint16_t sel_offset = sel & ~(X86_SEL_TI | X86_SEL_RPL_MASK); + unsigned int gdt_desc_bytes = sizeof(*gdt); + const user_desc *desc; + + if ( selector_is_null(sel) ) + { + vmcb_set_seg_unusable(seg, sel); + return; + } + + if ( (sel & X86_SEL_TI) || + (sel_offset + gdt_desc_bytes - 1 > gdt_limit) ) + { + vmcb_set_seg_unusable(seg, 0); + return; + } + + desc = (const user_desc *)((const char *)gdt + sel_offset); + + if ( !desc->s ) + gdt_desc_bytes *= 2; + + if ( sel_offset + gdt_desc_bytes - 1 > gdt_limit ) + { + vmcb_set_seg_unusable(seg, 0); + return; + } + + seg->sel = sel; + seg->attr = user_desc_vmcb_attr(desc); + seg->limit = user_desc_limit(desc); + seg->base = user_desc_base(desc); +} + +/* set or clear EFER.SVME and return the original EFER value */ +uint64_t update_efer_svme(bool enable) +{ + uint64_t efer = rdmsr(MSR_EFER); + uint64_t new_efer = enable ? (efer | EFER_SVME) : (efer & ~EFER_SVME); + + wrmsr(MSR_EFER, new_efer); + return efer; +} + +/* Enable SVM in L1 and program the host-save area used by VMRUN. */ +bool svm_l1_enable_svm(void) +{ + if ( !cpu_has_svm ) { + xtf_skip("Skip: SVM not available\n"); + return false; + } + + update_efer_svme(true); + wrmsr(MSR_VM_HSAVE_PA, _u(hsave)); + return true; +} + +/* Build a minimal long-mode L2 VMCB that reuses the current L1 environment. */ +void svm_l2_build_vmcb(struct vmcb *vmcb, const struct svm_l2_config *cfg) +{ + struct svm_l2_config default_l2 = { + .efer = rdmsr(MSR_EFER), + .intercept_insns_vec_00c.fields.hlt = 1, + .intercept_insns_vec_010.fields.vmrun = 1, + .rsp = _u(&l2_stack[sizeof(l2_stack)]), + }; + desc_ptr gdt_desc, idt_desc; + const user_desc *gdt; + + memset(vmcb, 0, sizeof(*vmcb)); + + if ( !cfg ) + cfg = &default_l2; + vmcb->intercept_insns_vec_00c = cfg->intercept_insns_vec_00c; + vmcb->intercept_insns_vec_010 = cfg->intercept_insns_vec_010; + vmcb->intercept_insns_vec_014 = cfg->intercept_insns_vec_014; + vmcb->asid = cfg->asid ? cfg->asid : 1; + + vmcb->cr0 = read_cr0(); + vmcb->cr3 = read_cr3(); + vmcb->cr4 = read_cr4(); + vmcb->efer = cfg->efer; + vmcb->rflags = read_flags(); + + vmcb->rsp = cfg->rsp; + vmcb->rip = cfg->rip; + + sgdt(&gdt_desc); + sidt(&idt_desc); + vmcb->v_intr_ctrl.fields.vIRQ_prio = 2; /* vIRQ priority */ + vmcb->gdtr.base = gdt_desc.base; + vmcb->gdtr.limit = gdt_desc.limit; + vmcb->idtr.base = idt_desc.base; + vmcb->idtr.limit = idt_desc.limit; + gdt = (const user_desc *)gdt_desc.base; + + vmcb_set_seg_desc(&vmcb->ldtr, gdt, gdt_desc.limit, sldt()); + vmcb_set_seg_desc(&vmcb->tr, gdt, gdt_desc.limit, str()); + + vmcb->cs.sel = __KERN_CS; + vmcb->cs.attr = 0xa9b; + vmcb->cs.limit = ~0u; + + vmcb->ds.sel = __USER_DS; + vmcb->ds.attr = 0xcf3; + vmcb->ds.limit = ~0u; + vmcb->es = vmcb->fs = vmcb->gs = vmcb->ds; + + vmcb->ss.sel = __KERN_DS; + vmcb->ss.attr = 0; + vmcb->ss.limit = 0; +} + +/* Build an L2 IDT with a single interrupt gate for the supplied vector. */ +void setup_l2_idt(struct vmcb *vmcb, unsigned int vector, + void (*handler)(void)) +{ + pack_intr_gate(&l2_idt[vector], __KERN_CS, _u(handler), 0, 0); + + vmcb->idtr.base = _u(l2_idt); + vmcb->idtr.limit = sizeof(l2_idt) - 1; +} + +void print_efer(const char *prefix, uint64_t efer) +{ + printk("%s: EFER: 0x%lx, set:", prefix, efer); + if (efer & (1u << 0)) printk(" SCE"); + if (efer & (1u << 8)) printk(" LME"); + if (efer & (1u << 10)) printk(" LMA"); + if (efer & (1u << 11)) printk(" NXE"); + if (efer & (1u << 12)) printk(" SVME"); + printk("\n"); +} diff --git a/include/nested-svm/setup-l2.h b/include/nested-svm/setup-l2.h new file mode 100644 index 0000000..68c6adf --- /dev/null +++ b/include/nested-svm/setup-l2.h @@ -0,0 +1,49 @@ +#ifndef XTF_NESTED_SVM_SETUP_L2_H +#define XTF_NESTED_SVM_SETUP_L2_H + +#include + +#include "vmcb.h" + +/* Minimal caller-supplied state for building an L2 VMCB from L1. */ +struct svm_l2_config { + unsigned long rip; + unsigned long rsp; + uint32_t asid; + intercept_insns_vec_00c_t intercept_insns_vec_00c; + intercept_insns_vec_010_t intercept_insns_vec_010; + intercept_insns_vec_014_t intercept_insns_vec_014; + uint64_t efer; +}; + +/* The VMCB of L1 for L2, commonly called vmcb12 in nested SVM documentation */ +extern struct vmcb vmcb12 __page_aligned_bss; + +/* Backing store for the VMRUN host-save area (MSR_VM_HSAVE_PA). */ +extern uint8_t hsave[PAGE_SIZE] __page_aligned_bss; + +/* Stack used by L2. Two pages of backing store. */ +extern uint8_t l2_stack[2 * PAGE_SIZE] __page_aligned_bss; + +uint64_t update_efer_svme(bool enable); + +/* Enable SVM in L1 and program the host-save area used by VMRUN. */ +bool svm_l1_enable_svm(void); + +/* Leave L1 out of nested-hypervisor mode before guest shutdown. */ +void svm_l1_finish_vmrun(void); + +/* Build a minimal long-mode L2 VMCB that reuses the current L1 environment. */ +void svm_l2_build_vmcb(struct vmcb *vmcb, const struct svm_l2_config *cfg); + +/* Build an L2 IDT with a single interrupt gate for the supplied vector. */ +void setup_l2_idt(struct vmcb *vmcb, unsigned int vector, + void (*handler)(void)); + +/* Enter an L2 guest via the shared VMLOAD/VMRUN/VMSAVE trampoline. */ +void svm_vmrun(unsigned long l2_vmcb_pa); + +/* Print the EFER bits which are set in the supplied value. */ +void print_efer(const char *prefix, uint64_t efer); + +#endif /* XTF_NESTED_SVM_SETUP_L2_H */ diff --git a/include/nested-svm/vmcb.h b/include/nested-svm/vmcb.h new file mode 100644 index 0000000..2e8458b --- /dev/null +++ b/include/nested-svm/vmcb.h @@ -0,0 +1,519 @@ +/* Shared minimal VMCB definitions for nested-SVM tests. */ +#ifndef XTF_NESTED_SVM_VMCB_H +#define XTF_NESTED_SVM_VMCB_H + +#include + +struct vmcb_seg { + uint16_t sel; + uint16_t attr; + uint32_t limit; + uint64_t base; +}; + +/* VMCB 0x060: Virtual Interrupt Control to inject virtual (INTR) interrupts */ +typedef union v_intr_ctrl { + uint64_t bytes; + struct v_intr_ctrl_fields { /* V_INTR_CTRL */ + uint64_t v_tpr : 8; /* 0:7 - virt Task Priority Register */ + uint64_t vIRQ_pending : 1; /* 8 - vIRQ pending */ + uint64_t vGIF : 1; /* 9 - vGIF value */ + uint64_t reserved_1 : 1; /* 10 - Reserved */ + uint64_t vNMI_pending : 1; /* 11 - vNMI pending */ + uint64_t vNMI_blocking : 1; /* 12 - vNMI blocking */ + uint64_t reserved_2 : 3; /* Reserved */ + uint64_t vIRQ_prio : 4; /* 16:19 - vIRQ Priority */ + uint64_t v_ign_tpr : 1; /* 20 - vIRQ ignore TPR */ + uint64_t reserved_3 : 3; /* Reserved */ + uint64_t vIRQ_masking : 1; /* 24 - vIRQ Masking */ + uint64_t vGIF_enabled : 1; /* 25 - vGIF enable/disable */ + uint64_t vNMI_enabled : 1; /* 26 - vNMI enable/disable */ + uint64_t reserved_4 : 3; /* Reserved */ + uint64_t x2AVIC_enable : 1; /* 30 - x2AVIC enable/disable */ + uint64_t AVIC_enable : 1; /* 31 - AVIC enable/disable */ + uint64_t vector : 8; /* 32:39 - Virtual Interrupt Vector */ + uint64_t reserved_5 : 24; /* 40:63 - Reserved */ + } __attribute__((packed)) fields; +} v_intr_ctrl_t; + +typedef union intercept_insns_00c { + uint32_t bytes; + struct intercept_insns_vec_00c_fields { + uint32_t intr : 1; /* 0 - INTR (physical maskable interrupt) */ + uint32_t nmi : 1; /* 1 - NMI */ + uint32_t smi : 1; /* 2 - SMI */ + uint32_t init : 1; /* 3 - INIT */ + uint32_t vintr : 1; /* 4 - VINTR (virtual maskable interrupt) */ + uint32_t cr0 : 1; /* 5 - CR0 bit writes other than TS or MP */ + uint32_t rd_idtr : 1; /* 6 - IDTR read */ + uint32_t rd_gdtr : 1; /* 7 - GDTR read */ + uint32_t rd_ldtr : 1; /* 8 - LDTR read */ + uint32_t rd_tr : 1; /* 9 - TR read */ + uint32_t wr_idtr : 1; /* 10 - IDTR write */ + uint32_t wr_gdtr : 1; /* 11 - GDTR write */ + uint32_t wr_ldtr : 1; /* 12 - LDTR write */ + uint32_t wr_tr : 1; /* 13 - TR write */ + uint32_t rdtsc : 1; /* 14 - RDTSC */ + uint32_t rdpmc : 1; /* 15 - RDPMC */ + uint32_t pushf : 1; /* 16 - PUSHF */ + uint32_t popf : 1; /* 17 - POPF */ + uint32_t cpuid : 1; /* 18 - CPUID */ + uint32_t rsm : 1; /* 19 - RSM */ + uint32_t iret : 1; /* 20 - IRET */ + uint32_t intn : 1; /* 21 - INTn */ + uint32_t invd : 1; /* 22 - INVD */ + uint32_t pause : 1; /* 23 - PAUSE */ + uint32_t hlt : 1; /* 24 - HLT */ + uint32_t invlpg : 1; /* 25 - INVLPG */ + uint32_t invlpga : 1; /* 26 - INVLPGA */ + uint32_t ioio : 1; /* 27 - IOIO_PROT */ + uint32_t msr : 1; /* 28 - MSR_PROT */ + uint32_t task_switch : 1; /* 29 - TASK_SWITCH */ + uint32_t ferr_freeze : 1; /* 30 - FERR_FREEZE */ + uint32_t shutdown : 1; /* 31 - Shutdown events */ + } __attribute__((packed)) fields; +} intercept_insns_vec_00c_t; + +typedef union intercept_insns_vec_010 { + uint32_t bytes; + struct intercept_insns_vec_010_fields { + uint32_t vmrun : 1; /* 0 - VMRUN */ + uint32_t vmmcall : 1; /* 1 - VMMCALL */ + uint32_t vmload : 1; /* 2 - VMLOAD */ + uint32_t vmsave : 1; /* 3 - VMSAVE */ + uint32_t stgi : 1; /* 4 - STGI */ + uint32_t clgi : 1; /* 5 - CLGI */ + uint32_t skinit : 1; /* 6 - SKINIT */ + uint32_t rdtscp : 1; /* 7 - RDTSCP */ + uint32_t icebp : 1; /* 8 - ICEBP */ + uint32_t wbinvd : 1; /* 9 - WBINVD/WBNOINVD */ + uint32_t monitor : 1; /* 10 - MONITOR/MONITORX */ + uint32_t mwait : 1; /* 11 - MWAIT/MWAITX unconditionally */ + uint32_t mwait_mon : 1; /* 12 - MWAIT/MWAITX monitor conditional */ + uint32_t xsetbv : 1; /* 13 - XSETBV */ + uint32_t rdpru : 1; /* 14 - RDPRU */ + uint32_t efer : 1; /* 15 - EFER write */ + uint32_t cr0 : 1; /* 16 - CR0 write */ + uint32_t cr1 : 1; /* 17 - CR1 write */ + uint32_t cr2 : 1; /* 18 - CR2 write */ + uint32_t cr3 : 1; /* 19 - CR3 write */ + uint32_t cr4 : 1; /* 20 - CR4 write */ + uint32_t cr5 : 1; /* 21 - CR5 write */ + uint32_t cr6 : 1; /* 22 - CR6 write */ + uint32_t cr7 : 1; /* 23 - CR7 write */ + uint32_t cr8 : 1; /* 24 - CR8 write */ + uint32_t cr9 : 1; /* 25 - CR9 write */ + uint32_t cr10 : 1; /* 26 - CR10 write */ + uint32_t cr11 : 1; /* 27 - CR11 write */ + uint32_t cr12 : 1; /* 28 - CR12 write */ + uint32_t cr13 : 1; /* 29 - CR13 write */ + uint32_t cr14 : 1; /* 30 - CR14 write */ + uint32_t cr15 : 1; /* 31 - CR15 write */ + } __attribute__((packed)) fields; +} intercept_insns_vec_010_t; + + +typedef union intercept_insns_vec_014 { + uint32_t bytes; + struct intercept_insns_vec_014_fields { + uint32_t invlpgb_all : 1; /* 0 - All INVLPGB insns */ + uint32_t invlpgb_ill : 1; /* 1 - Illegally specified INVLPGBs */ + uint32_t invpciid : 1; /* 2 - INVPCID */ + uint32_t mcommit : 1; /* 3 - MCOMMIT */ + uint32_t tlbsync : 1; /* 4 - TLB SYNC */ + uint32_t buslock : 1; /* 5 - BUSLOCK */ + uint32_t hlt_no_pending : 1; /* 6 - HLT with no pending virq */ + } __attribute__((packed)) fields; +} intercept_insns_vec_014_t; + +struct vmcb { + uint16_t intercept_read_cr; + uint16_t intercept_write_cr; + uint16_t intercept_read_dr; + uint16_t intercept_write_dr; + uint32_t intercept_exceptions; + intercept_insns_vec_00c_t intercept_insns_vec_00c; + intercept_insns_vec_010_t intercept_insns_vec_010; + intercept_insns_vec_014_t intercept_insns_vec_014; + uint8_t _pad_018[0x03C - 0x018]; + uint16_t pause_filter_threshold; + uint16_t pause_filter_count; + uint64_t iopm_base_pa; + uint64_t msrpm_base_pa; + uint64_t tsc_offset; + uint32_t asid; + uint8_t tlb_control; + uint8_t _pad_05d[3]; + /* 0x060: Virtual Interrupt Control to inject virtual interrupts */ + v_intr_ctrl_t v_intr_ctrl; + uint64_t int_state; + uint64_t exitcode; + uint64_t exitinfo1; + uint64_t exitinfo2; + uint64_t exit_int_info; + uint64_t np_enable; + uint8_t _pad_098[0x0a8 - 0x098]; + /* 0x0a8: Event Injection */ + uint64_t event_inj; + uint64_t h_cr3; + uint8_t _pad_0b8[0x400 - 0x0b8]; + struct vmcb_seg es; + struct vmcb_seg cs; + struct vmcb_seg ss; + struct vmcb_seg ds; + struct vmcb_seg fs; + struct vmcb_seg gs; + struct vmcb_seg gdtr; + struct vmcb_seg ldtr; + struct vmcb_seg idtr; + struct vmcb_seg tr; + uint8_t _pad_4a0[0x4cb - 0x4a0]; + uint8_t cpl; + uint32_t _pad_4cc; + uint64_t efer; + uint8_t _pad_4d8[0x548 - 0x4d8]; + uint64_t cr4; + uint64_t cr3; + uint64_t cr0; + uint64_t dr7; + uint64_t dr6; + uint64_t rflags; + uint64_t rip; + uint8_t _pad_580[0x5d8 - 0x580]; + uint64_t rsp; + uint8_t _pad_5e0[0x5f8 - 0x5e0]; + uint64_t rax; + uint8_t _pad_tail[0x1000 - 0x600]; +}; + +#define VMCB_CHECK(field, offset) \ + _Static_assert(__builtin_offsetof(struct vmcb, field) == (offset), \ + "VMCB layout mismatch: " #field) +VMCB_CHECK(intercept_insns_vec_00c, 0x000c); +VMCB_CHECK(intercept_insns_vec_010, 0x0010); +VMCB_CHECK(intercept_insns_vec_014, 0x0014); +VMCB_CHECK(asid, 0x0058); +VMCB_CHECK(v_intr_ctrl, 0x0060); +VMCB_CHECK(exitcode, 0x0070); +VMCB_CHECK(es, 0x0400); +VMCB_CHECK(gdtr, 0x0460); +VMCB_CHECK(idtr, 0x0480); +VMCB_CHECK(tr, 0x0490); +VMCB_CHECK(efer, 0x04d0); +VMCB_CHECK(cr4, 0x0548); +VMCB_CHECK(cr3, 0x0550); +VMCB_CHECK(cr0, 0x0558); +VMCB_CHECK(rflags, 0x0570); +VMCB_CHECK(rip, 0x0578); +VMCB_CHECK(rsp, 0x05d8); +VMCB_CHECK(rax, 0x05f8); +_Static_assert(sizeof(struct vmcb) == 0x1000, "VMCB size != 4 KiB"); +#undef VMCB_CHECK + +/* VMCB exit codes (Exception-vector exit-code slots) */ +#define VMEXIT_READ_CR0 0x000 +#define VMEXIT_READ_CR2 0x002 +#define VMEXIT_READ_CR3 0x003 +#define VMEXIT_READ_CR4 0x004 +#define VMEXIT_READ_CR8 0x008 +#define VMEXIT_WRITE_CR0 0x010 +#define VMEXIT_WRITE_CR2 0x012 +#define VMEXIT_WRITE_CR3 0x013 +#define VMEXIT_WRITE_CR4 0x014 +#define VMEXIT_WRITE_CR8 0x018 +#define VMEXIT_READ_DR0 0x020 +#define VMEXIT_READ_DR1 0x021 +#define VMEXIT_READ_DR2 0x022 +#define VMEXIT_READ_DR3 0x023 +#define VMEXIT_READ_DR4 0x024 +#define VMEXIT_READ_DR5 0x025 +#define VMEXIT_READ_DR6 0x026 +#define VMEXIT_READ_DR7 0x027 +#define VMEXIT_WRITE_DR0 0x030 +#define VMEXIT_WRITE_DR1 0x031 +#define VMEXIT_WRITE_DR2 0x032 +#define VMEXIT_WRITE_DR3 0x033 +#define VMEXIT_WRITE_DR4 0x034 +#define VMEXIT_WRITE_DR5 0x035 +#define VMEXIT_WRITE_DR6 0x036 +#define VMEXIT_WRITE_DR7 0x037 +#define VMEXIT_EXCP_BASE 0x040 +#define VMEXIT_EXCP_DE 0x040 /* vector 0, #DE */ +#define VMEXIT_EXCP_DB 0x041 /* vector 1, #DB */ +#define VMEXIT_EXCP_BP 0x043 /* vector 3, #BP */ +#define VMEXIT_EXCP_OF 0x044 /* vector 4, #OF */ +#define VMEXIT_EXCP_BR 0x045 /* vector 5, #BR */ +#define VMEXIT_EXCP_UD 0x046 /* vector 6, #UD */ +#define VMEXIT_EXCP_NM 0x047 /* vector 7, #NM */ +#define VMEXIT_EXCP_DF 0x048 /* vector 8, #DF */ +#define VMEXIT_EXCP_TS 0x04a /* vector 10, #TS */ +#define VMEXIT_EXCP_NP 0x04b /* vector 11, #NP */ +#define VMEXIT_EXCP_SS 0x04c /* vector 12, #SS */ +#define VMEXIT_EXCP_GP 0x04d /* vector 13, #GP */ +#define VMEXIT_EXCP_PF 0x04e /* vector 14, #PF */ +#define VMEXIT_EXCP_MF 0x050 /* vector 16, #MF */ +#define VMEXIT_EXCP_AC 0x051 /* vector 17, #AC */ +#define VMEXIT_EXCP_MC 0x052 /* vector 18, #MC */ +#define VMEXIT_EXCP_XF 0x053 /* vector 19, #XF */ +#define VMEXIT_EXCP_CP 0x055 /* vector 21, #CP */ +#define VMEXIT_EXCP_HV 0x05c /* vector 28, #HV */ +#define VMEXIT_EXCP_VC 0x05d /* vector 29, #VC */ +#define VMEXIT_EXCP_SX 0x05e /* vector 30, #SX */ +#define VMEXIT_INTR 0x060 +#define VMEXIT_NMI 0x061 +#define VMEXIT_SMI 0x062 +#define VMEXIT_INIT 0x063 +#define VMEXIT_VINTR 0x064 +#define VMEXIT_CR0_SEL_WRITE 0x065 +#define VMEXIT_IDTR_READ 0x066 +#define VMEXIT_GDTR_READ 0x067 +#define VMEXIT_LDTR_READ 0x068 +#define VMEXIT_TR_READ 0x069 +#define VMEXIT_IDTR_WRITE 0x06a +#define VMEXIT_GDTR_WRITE 0x06b +#define VMEXIT_LDTR_WRITE 0x06c +#define VMEXIT_TR_WRITE 0x06d +#define VMEXIT_RDTSC 0x06e +#define VMEXIT_RDPMC 0x06f +#define VMEXIT_PUSHF 0x070 +#define VMEXIT_POPF 0x071 +#define VMEXIT_CPUID 0x072 +#define VMEXIT_RSM 0x073 +#define VMEXIT_IRET 0x074 +#define VMEXIT_SWINT 0x075 +#define VMEXIT_INVD 0x076 +#define VMEXIT_PAUSE 0x077 +#define VMEXIT_HLT 0x078 +#define VMEXIT_INVLPG 0x079 +#define VMEXIT_INVLPGA 0x07a +#define VMEXIT_IOIO 0x07b +#define VMEXIT_MSR 0x07c +#define VMEXIT_TASK_SWITCH 0x07d +#define VMEXIT_FERR_FREEZE 0x07e +#define VMEXIT_SHUTDOWN 0x07f +#define VMEXIT_VMRUN 0x080 +#define VMEXIT_VMMCALL 0x081 +#define VMEXIT_VMLOAD 0x082 +#define VMEXIT_VMSAVE 0x083 +#define VMEXIT_STGI 0x084 +#define VMEXIT_CLGI 0x085 +#define VMEXIT_SKINIT 0x086 +#define VMEXIT_RDTSCP 0x087 +#define VMEXIT_ICEBP 0x088 +#define VMEXIT_WBINVD 0x089 +#define VMEXIT_MONITOR 0x08a +#define VMEXIT_MWAIT 0x08b +#define VMEXIT_MWAIT_COND 0x08c +#define VMEXIT_XSETBV 0x08d +#define VMEXIT_RDPRU 0x08e +#define VMEXIT_EFER_WRITE_TRAP 0x08f +#define VMEXIT_CR0_WRITE_TRAP 0x090 +#define VMEXIT_CR1_WRITE_TRAP 0x091 +#define VMEXIT_CR2_WRITE_TRAP 0x092 +#define VMEXIT_CR3_WRITE_TRAP 0x093 +#define VMEXIT_CR4_WRITE_TRAP 0x094 +#define VMEXIT_CR5_WRITE_TRAP 0x095 +#define VMEXIT_CR6_WRITE_TRAP 0x096 +#define VMEXIT_CR7_WRITE_TRAP 0x097 +#define VMEXIT_CR8_WRITE_TRAP 0x098 +#define VMEXIT_CR9_WRITE_TRAP 0x099 +#define VMEXIT_CR10_WRITE_TRAP 0x09a +#define VMEXIT_CR11_WRITE_TRAP 0x09b +#define VMEXIT_CR12_WRITE_TRAP 0x09c +#define VMEXIT_CR13_WRITE_TRAP 0x09d +#define VMEXIT_CR14_WRITE_TRAP 0x09e +#define VMEXIT_CR15_WRITE_TRAP 0x09f +#define VMEXIT_INVLPGB 0x0a0 +#define VMEXIT_INVLPGB_ILLEGAL 0x0a1 +#define VMEXIT_INVPCID 0x0a2 +#define VMEXIT_MCOMMIT 0x0a3 +#define VMEXIT_TLBSYNC 0x0a4 +#define VMEXIT_BUS_LOCK 0x0a5 +#define VMEXIT_IDLE_HLT 0x0a6 + +/* Feature-specific exits. */ +#define VMEXIT_NPF 0x400 +#define VMEXIT_AVIC_INCOMPLETE_IPI 0x401 +#define VMEXIT_AVIC_NOACCEL 0x402 +#define VMEXIT_VMGEXIT 0x403 + +/* Host/software and error exits. */ +#define VMEXIT_SW 0xf0000000ull +#define VMEXIT_INVALID (~0ull) /* -1 */ +#define VMEXIT_BUSY (~1ull) /* -2 */ +#define VMEXIT_IDLE_REQUIRED (~2ull) /* -3 */ +#define VMEXIT_INVALID_PMC (~3ull) /* -4 */ + +/* Get the vmexit reason */ +static const char __used *vmexit_reason(uint64_t exitcode) +{ + switch (exitcode) { + case VMEXIT_READ_CR0: return "READ_CR0"; + case VMEXIT_READ_CR2: return "READ_CR2"; + case VMEXIT_READ_CR3: return "READ_CR3"; + case VMEXIT_READ_CR4: return "READ_CR4"; + case VMEXIT_READ_CR8: return "READ_CR8"; + case VMEXIT_WRITE_CR0: return "WRITE_CR0"; + case VMEXIT_WRITE_CR2: return "WRITE_CR2"; + case VMEXIT_WRITE_CR3: return "WRITE_CR3"; + case VMEXIT_WRITE_CR4: return "WRITE_CR4"; + case VMEXIT_WRITE_CR8: return "WRITE_CR8"; + case VMEXIT_READ_DR0: return "READ_DR0"; + case VMEXIT_READ_DR1: return "READ_DR1"; + case VMEXIT_READ_DR2: return "READ_DR2"; + case VMEXIT_READ_DR3: return "READ_DR3"; + case VMEXIT_READ_DR4: return "READ_DR4"; + case VMEXIT_READ_DR5: return "READ_DR5"; + case VMEXIT_READ_DR6: return "READ_DR6"; + case VMEXIT_READ_DR7: return "READ_DR7"; + case VMEXIT_WRITE_DR0: return "WRITE_DR0"; + case VMEXIT_WRITE_DR1: return "WRITE_DR1"; + case VMEXIT_WRITE_DR2: return "WRITE_DR2"; + case VMEXIT_WRITE_DR3: return "WRITE_DR3"; + case VMEXIT_WRITE_DR4: return "WRITE_DR4"; + case VMEXIT_WRITE_DR5: return "WRITE_DR5"; + case VMEXIT_WRITE_DR6: return "WRITE_DR6"; + case VMEXIT_WRITE_DR7: return "WRITE_DR7"; + case VMEXIT_EXCP_DE: return "EXCP_DE"; + case VMEXIT_EXCP_DB: return "EXCP_DB"; + case VMEXIT_EXCP_BP: return "EXCP_BP"; + case VMEXIT_EXCP_OF: return "EXCP_OF"; + case VMEXIT_EXCP_BR: return "EXCP_BR"; + case VMEXIT_EXCP_UD: return "EXCP_UD"; + case VMEXIT_EXCP_NM: return "EXCP_NM"; + case VMEXIT_EXCP_DF: return "EXCP_DF"; + case VMEXIT_EXCP_TS: return "EXCP_TS"; + case VMEXIT_EXCP_NP: return "EXCP_NP"; + case VMEXIT_EXCP_SS: return "EXCP_SS"; + case VMEXIT_EXCP_GP: return "EXCP_GP"; + case VMEXIT_EXCP_PF: return "EXCP_PF"; + case VMEXIT_EXCP_MF: return "EXCP_MF"; + case VMEXIT_EXCP_AC: return "EXCP_AC"; + case VMEXIT_EXCP_MC: return "EXCP_MC"; + case VMEXIT_EXCP_XF: return "EXCP_XF"; + case VMEXIT_EXCP_CP: return "EXCP_CP"; + case VMEXIT_EXCP_HV: return "EXCP_HV"; + case VMEXIT_EXCP_VC: return "EXCP_VC"; + case VMEXIT_EXCP_SX: return "EXCP_SX"; + case VMEXIT_INTR: return "INTR"; + case VMEXIT_NMI: return "NMI"; + case VMEXIT_SMI: return "SMI"; + case VMEXIT_INIT: return "INIT"; + case VMEXIT_VINTR: return "VINTR"; + case VMEXIT_CR0_SEL_WRITE: return "CR0_SEL_WRITE"; + case VMEXIT_IDTR_READ: return "IDTR_READ"; + case VMEXIT_GDTR_READ: return "GDTR_READ"; + case VMEXIT_LDTR_READ: return "LDTR_READ"; + case VMEXIT_TR_READ: return "TR_READ"; + case VMEXIT_IDTR_WRITE: return "IDTR_WRITE"; + case VMEXIT_GDTR_WRITE: return "GDTR_WRITE"; + case VMEXIT_LDTR_WRITE: return "LDTR_WRITE"; + case VMEXIT_TR_WRITE: return "TR_WRITE"; + case VMEXIT_RDTSC: return "RDTSC"; + case VMEXIT_RDPMC: return "RDPMC"; + case VMEXIT_PUSHF: return "PUSHF"; + case VMEXIT_POPF: return "POPF"; + case VMEXIT_CPUID: return "CPUID"; + case VMEXIT_RSM: return "RSM"; + case VMEXIT_IRET: return "IRET"; + case VMEXIT_SWINT: return "SWINT"; + case VMEXIT_INVD: return "INVD"; + case VMEXIT_PAUSE: return "PAUSE"; + case VMEXIT_HLT: return "HLT"; + case VMEXIT_INVLPG: return "INVLPG"; + case VMEXIT_INVLPGA: return "INVLPGA"; + case VMEXIT_IOIO: return "IOIO"; + case VMEXIT_MSR: return "MSR"; + case VMEXIT_TASK_SWITCH: return "TASK_SWITCH"; + case VMEXIT_FERR_FREEZE: return "FERR_FREEZE"; + case VMEXIT_SHUTDOWN: return "SHUTDOWN"; + case VMEXIT_VMRUN: return "VMRUN"; + case VMEXIT_VMMCALL: return "VMMCALL"; + case VMEXIT_VMLOAD: return "VMLOAD"; + case VMEXIT_VMSAVE: return "VMSAVE"; + case VMEXIT_STGI: return "STGI"; + case VMEXIT_CLGI: return "CLGI"; + case VMEXIT_SKINIT: return "SKINIT"; + case VMEXIT_RDTSCP: return "RDTSCP"; + case VMEXIT_ICEBP: return "ICEBP"; + case VMEXIT_WBINVD: return "WBINVD"; + case VMEXIT_MONITOR: return "MONITOR"; + case VMEXIT_MWAIT: return "MWAIT"; + case VMEXIT_MWAIT_COND: return "MWAIT_COND"; + case VMEXIT_XSETBV: return "XSETBV"; + case VMEXIT_RDPRU: return "RDPRU"; + case VMEXIT_EFER_WRITE_TRAP: return "EFER_WRITE_TRAP"; + case VMEXIT_CR0_WRITE_TRAP: return "CR0_WRITE_TRAP"; + case VMEXIT_CR1_WRITE_TRAP: return "CR1_WRITE_TRAP"; + case VMEXIT_CR2_WRITE_TRAP: return "CR2_WRITE_TRAP"; + case VMEXIT_CR3_WRITE_TRAP: return "CR3_WRITE_TRAP"; + case VMEXIT_CR4_WRITE_TRAP: return "CR4_WRITE_TRAP"; + case VMEXIT_CR5_WRITE_TRAP: return "CR5_WRITE_TRAP"; + case VMEXIT_CR6_WRITE_TRAP: return "CR6_WRITE_TRAP"; + case VMEXIT_CR7_WRITE_TRAP: return "CR7_WRITE_TRAP"; + case VMEXIT_CR8_WRITE_TRAP: return "CR8_WRITE_TRAP"; + case VMEXIT_CR9_WRITE_TRAP: return "CR9_WRITE_TRAP"; + case VMEXIT_CR10_WRITE_TRAP: return "CR10_WRITE_TRAP"; + case VMEXIT_CR11_WRITE_TRAP: return "CR11_WRITE_TRAP"; + case VMEXIT_CR12_WRITE_TRAP: return "CR12_WRITE_TRAP"; + case VMEXIT_CR13_WRITE_TRAP: return "CR13_WRITE_TRAP"; + case VMEXIT_CR14_WRITE_TRAP: return "CR14_WRITE_TRAP"; + case VMEXIT_CR15_WRITE_TRAP: return "CR15_WRITE_TRAP"; + case VMEXIT_INVLPGB: return "INVLPGB"; + case VMEXIT_INVLPGB_ILLEGAL: return "INVLPGB_ILLEGAL"; + case VMEXIT_INVPCID: return "INVPCID"; + case VMEXIT_MCOMMIT: return "MCOMMIT"; + case VMEXIT_TLBSYNC: return "TLBSYNC"; + case VMEXIT_BUS_LOCK: return "BUS_LOCK"; + case VMEXIT_IDLE_HLT: return "IDLE_HLT"; + case VMEXIT_NPF: return "NPF"; + case VMEXIT_AVIC_INCOMPLETE_IPI: return "AVIC_INCOMPLETE_IPI"; + case VMEXIT_AVIC_NOACCEL: return "AVIC_NOACCEL"; + case VMEXIT_VMGEXIT: return "VMGEXIT"; + case VMEXIT_SW: return "SW"; + case VMEXIT_INVALID: return "INVALID"; + case VMEXIT_BUSY: return "BUSY"; + case VMEXIT_IDLE_REQUIRED: return "IDLE_REQUIRED"; + case VMEXIT_INVALID_PMC: return "INVALID_PMC"; + default: return "UNKNOWN"; + } +} + +/* Function to print all bits of v_intr_ctrl_t */ +static inline void print_v_intr_ctrl(const char *file, int line, + const struct vmcb *vmcb, + const char *prefix) +{ + struct v_intr_ctrl_fields ctrl = vmcb->v_intr_ctrl.fields; + + printk("%s:%d: rax:%lx IRQ-ctrl", file, line, vmcb->rax); + if (prefix) + printk(" %s", prefix); + printk(":"); + + /* Print a list of the bits which are set: */ + if (ctrl.vIRQ_prio) + printk(" prio:%u", ctrl.vIRQ_prio); + if (ctrl.vector) + printk(" vec:%x", ctrl.vector); + if (ctrl.vGIF_enabled) + printk(" vGIF:%u", ctrl.vGIF); + if (ctrl.vIRQ_pending) + printk(" IRQ:pending"); + if (ctrl.vNMI_enabled) + printk(" NMI:enabled"); + if (ctrl.vNMI_pending) + printk(" NMI:pending"); + if (ctrl.vNMI_blocking) + printk(" NMI:blocking"); + + if (ctrl.v_tpr) + printk(" tpr=%u", ctrl.v_tpr); + if (ctrl.v_ign_tpr) + printk(" ign_tpr"); + if (ctrl.vIRQ_masking) + printk(" IRQ:masking"); + printk("\n"); +} + +#endif /* XTF_NESTED_SVM_VMCB_H */ From 070c300cb1db716b14442a2800b26536709a38f0 Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Thu, 9 Jul 2026 12:00:00 +0000 Subject: [PATCH 15/19] Add nsvm test nested-svm-run: Smoke test VMRUN, with IF/HLT XFAIL Based on an initial experiment by Ross to run a minimal L2. PS: Use the category nested-svm to run all nested SVM tests: ./xtf-runner nested-svm Signed-off-by: Ross Lagerwall Signed-off-by: Bernhard Kaindl --- docs/all-tests.dox | 2 +- tests/nested-svm-run/Makefile | 12 +++ tests/nested-svm-run/extra.cfg.in | 1 + tests/nested-svm-run/index.rst | 60 +++++++++++++ tests/nested-svm-run/main.c | 142 ++++++++++++++++++++++++++++++ 5 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 tests/nested-svm-run/Makefile create mode 100644 tests/nested-svm-run/extra.cfg.in create mode 100644 tests/nested-svm-run/index.rst create mode 100644 tests/nested-svm-run/main.c diff --git a/docs/all-tests.dox b/docs/all-tests.dox index ff38747..3495a43 100644 --- a/docs/all-tests.dox +++ b/docs/all-tests.dox @@ -203,7 +203,7 @@ states. @subpage test-debug-regs - Debugging facility tests. -@subpage test-nested-svm - Nested SVM tests. +@subpage test-nested-svm-run - Nested SVM VMRUN tests. @subpage test-nested-vmx - Nested VT-x tests. */ diff --git a/tests/nested-svm-run/Makefile b/tests/nested-svm-run/Makefile new file mode 100644 index 0000000..eb1fe7b --- /dev/null +++ b/tests/nested-svm-run/Makefile @@ -0,0 +1,12 @@ +include $(ROOT)/build/common.mk + +NAME := nested-svm-run +CATEGORY := nested-svm +TEST-ENVS := $(SVM_ENVIRONMENTS) + +TEST-EXTRA-CFG := extra.cfg.in + +obj-perenv += main.o +obj-perenv += $(ROOT)/common/nested-svm/setup-l2.o + +include $(ROOT)/build/gen.mk diff --git a/tests/nested-svm-run/extra.cfg.in b/tests/nested-svm-run/extra.cfg.in new file mode 100644 index 0000000..ae494f8 --- /dev/null +++ b/tests/nested-svm-run/extra.cfg.in @@ -0,0 +1 @@ +nestedhvm = 1 diff --git a/tests/nested-svm-run/index.rst b/tests/nested-svm-run/index.rst new file mode 100644 index 0000000..eccb6ef --- /dev/null +++ b/tests/nested-svm-run/index.rst @@ -0,0 +1,60 @@ +Nested SVM VMRUN +================ + +This test exercises a minimal AMD nested-SVM guest entry path on hvm64. + +An L1 guest enables SVM, builds an L2 VMCB that reuses L1's page tables and +descriptor tables, mirrors the active TR and LDTR state into the VMCB, and +enters L2 with VMRUN. The L2 payload runs on its own stack, writes a sentinel +into shared memory, and halts. + +The test passes when L1 observes a HLT VMEXIT from L2 and the expected +sentinel value. + +What It Verifies +---------------- + +The test verifies that Xen's nested-SVM implementation accepts a minimal but +architecturally valid L2 VMCB built by an L1 guest running in long mode. + +More specifically, it verifies that: + +* L1 can enable SVM and program the host-save area needed by VMRUN. +* L1 can populate an L2 VMCB with inherited control state, descriptor-table + state, and system-segment state taken from the live L1 environment. +* VMRUN succeeds in entering L2 rather than failing immediately because of + malformed guest state. +* L2 executes the supplied payload on its own stack, updates shared memory, + and exits through HLT. +* L1 receives a VMEXIT with exit code VMEXIT_HLT and observes the sentinel + value written by L2. + +How The Verification Functions Work +----------------------------------- + +The verification logic is split between the helpers that build a +valid VMCB and the final checks performed after returning from VMRUN. + +``test_main()`` performs the end-to-end verification. It enables SVM, +writes the host-save area address to MSR_VM_HSAVE_PA, builds the VMCB, +and enters L2 through ``svm_vmrun()``. + +When execution returns to L1, the test checks two conditions: the VMEXIT +reason must be ``VMEXIT_HLT``, and the shared handshake value must match +``L2_SENTINEL``. Both checks must pass for the test to report success. + +``l2_entry()`` is the L2 payload. It avoids using VMMCALL, because +in Xen's nested-SVM model that would unconditionally cause a VMEXIT +to L1. Instead, it writes a known sentinel value into shared memory +and halts in a loop so that L1 sees a clean HLT exit reason. + +``build_l2_vmcb()`` prepares the nested guest state. It programs the +required intercepts, reuses L1's paging and descriptor-table state, +assigns the L2 RIP and stack, and copies LDTR and TR from the current +GDT into the VMCB. + +The helper ``vmcb_set_seg_desc()`` translates an L1 selector into the +VMCB segment format, while rejecting selectors that are null, LDT-based, +or out of bounds for the current GDT limit. This matters because VMRUN +consumes the VMCB's segment state directly, including the system +descriptors needed for long-mode execution. diff --git a/tests/nested-svm-run/main.c b/tests/nested-svm-run/main.c new file mode 100644 index 0000000..b23eaa0 --- /dev/null +++ b/tests/nested-svm-run/main.c @@ -0,0 +1,142 @@ +/** + * @file tests/nested-svm-run/main.c + * @ref test-nested-svm-run + * @page test-nested-svm-run Nested SVM VMRUN Smoke Test + * + * An L1 guest: + * 1. enables SVM, + * 2. builds a minimal L2 VMCB that re-uses L1's address space, and + * 3. uses VMRUN to enter an L2 callback. + * + * L2: + * 1. increments %rax + * 2. signals completion with HLT, which causes a #VMEXIT back to L1. + * 3. checks that HLT with L2 IF clear doesn't power off L1 when L1 has the + * INTR intercept armed. + * + * @see tests/nested-svm-run/main.c + */ +#include + +const char test_title[] = "Nested SVM VMRUN Smoke Test"; + +/** + * Run a minimal L2 payload and report success back to L1. + */ +static void __used l2_entry(void) +{ + asm volatile ("inc %rax;hlt"); /* Signal success by incrementing %rax */ +} + +static void __used l2_cli_hlt(void) +{ + asm volatile ("cli;hlt"); +} + +static void run_l2(void) +{ + asm volatile("mov %0, %%rax\n" + "vmload %%rax\n" + "vmrun %%rax\n" + "vmsave %%rax\n" + : + : "r" (_u(&vmcb12)) + : "%rax", "memory"); +} + +/* + * Test that L2 CLI;HLT with L1 INTR intercept armed does not power off L1. + * This is a known Xen bug that is expected to be fixed in the future: + * + * Issue to be fixed: L1 killed if L2 halts while not intercepted: + * (XEN) arch/x86/hvm/hvm.c:1735:d13v0 All CPUs offline -- powering off. + * (XEN) vcpu_runstate_change: d13 has no online vcpus! + * + * Possible cause: + * This code may be wrong since L1 called vmrun with the IF flag + * set and the INTR intercept enabled. It could come from this + * check which only checks eflags from L2: + * + * > * If we halt with interrupts disabled, that's a pretty sure sign that we + * > * want to shut down. In a real processor, NMIs are the only way to break + * > * out of this. + * > + * > if ( unlikely(!(eflags & X86_EFLAGS_IF)) ) + * > return hvm_vcpu_down(curr); + */ +static bool expect_xfail_l2_cli_hlt_with_l1_intr_intercept(void) +{ + unsigned long l1_rflags = read_flags(); + + printk("L1: XFAIL testing L2 CLI;HLT with L1 INTR intercept armed\n"); + + svm_l2_build_vmcb(&vmcb12, NULL); + vmcb12.intercept_insns_vec_00c.fields.hlt = 0; + vmcb12.intercept_insns_vec_00c.fields.intr = 1; + vmcb12.rflags |= X86_EFLAGS_IF; + vmcb12.rip = _u(l2_cli_hlt); + + write_flags(l1_rflags | X86_EFLAGS_IF); + if ( !(read_flags() & X86_EFLAGS_IF) ) + { + xtf_error("L1 IF is clear before VMRUN\n"); + write_flags(l1_rflags); + return false; + } + + xtf_warning("XFAIL: broken Xen powers off L1 after L2 CLI;HLT with " + "HLT intercept clear and INTR intercept set\n"); + printk("L1: entering L2 via VMRUN with HLT intercept clear, INTR intercept set\n"); + run_l2(); + write_flags(l1_rflags); + printk("L1: returned from L2 (exit 0x%lx %s)\n", + vmcb12.exitcode, vmexit_reason(vmcb12.exitcode)); + + if ( vmcb12.exitcode == VMEXIT_INTR || vmcb12.exitcode == VMEXIT_VINTR ) + { + xtf_failure("XPASS: L2 CLI;HLT exited to L1 via %s; " + "update this test to expect the bug fixed\n", + vmexit_reason(vmcb12.exitcode)); + return false; + } + + xtf_warning("XFAIL: L2 CLI;HLT did not exit to L1 via the INTR intercept\n"); + return true; +} + +/** + * Execute the nested-SVM VMRUN smoke test. + * + * L1 enables SVM, prepares a minimal L2 VMCB, enters L2 once with VMRUN + * and verifies that L2 reports success before exiting with HLT. + */ +void test_main(void) +{ + /* Enable SVM, arm the host-save area and build the L2 VMCB. */ + if (!svm_l1_enable_svm()) + return; + + svm_l2_build_vmcb(&vmcb12, NULL); + + /* Set the L2 entry point to this test's l2_entry function. */ + vmcb12.rip = _u(l2_entry); + + print_efer("VMCB12", vmcb12.efer); + printk("L1: entering L2 via VMRUN\n"); + run_l2(); + printk("L1: returned from L2 (rax 0x%lx)\n", vmcb12.rax); + + if ( vmcb12.exitcode != VMEXIT_HLT ) + { + printk("Exit reason: %s\n", vmexit_reason(vmcb12.exitcode)); + return xtf_failure("unexpected L2 exit: 0x%lx\n", vmcb12.exitcode); + } + + if ( vmcb12.rax != 1 ) /* L2 should have incremented %rax from 0 to 1 */ + return xtf_failure("unexpected L2 %%rax: 0x%lx\n", vmcb12.rax); + + if ( !expect_xfail_l2_cli_hlt_with_l1_intr_intercept() ) + return; + + xtf_success(NULL); +} From 4dbfe0e2ef62e944c49f125e64621875ec0f44b0 Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Thu, 9 Jul 2026 12:00:00 +0000 Subject: [PATCH 16/19] Add nested-svm test: nested-svm-loadsave - Test nested VMLOAD and VMSAVE The test assert the error handling of using a test matrix of execution of VMLOAD and VMSAVE in a matrix of contexts: - User and Kernel context - EFER.SVME enabled and disabled - EAX VMCB register aligned, unaligned, or outside of physical range - CPL0 and CPL3 PS: Use the category nested-svm to run all nested SVM tests: ./xtf-runner nested-svm Signed-off-by: Bernhard Kaindl --- tests/nested-svm-loadsave/Makefile | 11 + tests/nested-svm-loadsave/extra.cfg.in | 1 + tests/nested-svm-loadsave/index.rst | 31 ++ tests/nested-svm-loadsave/main.c | 425 +++++++++++++++++++++++++ 4 files changed, 468 insertions(+) create mode 100644 tests/nested-svm-loadsave/Makefile create mode 100644 tests/nested-svm-loadsave/extra.cfg.in create mode 100644 tests/nested-svm-loadsave/index.rst create mode 100644 tests/nested-svm-loadsave/main.c diff --git a/tests/nested-svm-loadsave/Makefile b/tests/nested-svm-loadsave/Makefile new file mode 100644 index 0000000..8883bec --- /dev/null +++ b/tests/nested-svm-loadsave/Makefile @@ -0,0 +1,11 @@ +include $(ROOT)/build/common.mk + +NAME := nested-svm-loadsave +CATEGORY := nested-svm +TEST-ENVS := $(SVM_ENVIRONMENTS) + +TEST-EXTRA-CFG := extra.cfg.in + +obj-perenv += main.o $(ROOT)/common/nested-svm/setup-l2.o + +include $(ROOT)/build/gen.mk diff --git a/tests/nested-svm-loadsave/extra.cfg.in b/tests/nested-svm-loadsave/extra.cfg.in new file mode 100644 index 0000000..ae494f8 --- /dev/null +++ b/tests/nested-svm-loadsave/extra.cfg.in @@ -0,0 +1 @@ +nestedhvm = 1 diff --git a/tests/nested-svm-loadsave/index.rst b/tests/nested-svm-loadsave/index.rst new file mode 100644 index 0000000..a0355fc --- /dev/null +++ b/tests/nested-svm-loadsave/index.rst @@ -0,0 +1,31 @@ +Nested SVM VMLOAD/VMSAVE Test +============================= + +This test exercises the architectural VMLOAD and VMSAVE failure cases +that are reachable from an hvm64 L1 guest using Xen nested SVM. + +What It Verifies +---------------- + +The test verifies the distinct VMLOAD and VMSAVE error classes that are +reachable from this hvm64 harness: + +* VMLOAD/VMSAVE with EFER.SVME clear. +* VMLOAD/VMSAVE executed at CPL > 0. +* VMLOAD/VMSAVE executed with malformed VMCB physical addresses in RAX. + +How The Verification Functions Work +----------------------------------- + +``test_main()`` supplies the VMLOAD and VMSAVE-specific matrices +to the runner and checks that Xen reports the same exceptions that +the AMD architecture defines for the same preconditions, within the +limits of an hvm64 long-mode harness. + +``svm_negative_check_cases()`` toggles EFER.SVME as required +by each subtest, dispatches the instruction in kernel or user +context, and restores the original EFER value afterwards. + +``stub_vmload()`` and ``stub_vmsave()`` execute the instructions +directly in L1 with caller-supplied RAX values and record any +faults via the XTF exception-table helpers. diff --git a/tests/nested-svm-loadsave/main.c b/tests/nested-svm-loadsave/main.c new file mode 100644 index 0000000..a943786 --- /dev/null +++ b/tests/nested-svm-loadsave/main.c @@ -0,0 +1,425 @@ +/** + * @file tests/nested-svm-vmloadsave/main.c + * @ref test-nested-svm-vmloadsave + * + * @page test-nested-svm-vmloadsave nested-svm-vmloadsave + * + * Testing of AMD SVM VMLOAD and VMSAVE from an hvm64 L1 guest. + * + * The test exercises the reachable architectural VMLOAD and VMSAVE + * failure cases in this harness: + * 1. SVM disabled via EFER.SVME clear. + * 2. Instructions executed at CPL > 0. + * 3. Instructions executed with malformed VMCB physical addresses in RAX. + * + * The test passes if it receives the expected exception class for each case. + * + * @include tests/nested-svm-vmloadsave/index.rst + * @see tests/nested-svm-vmloadsave/main.c + */ +#include + +const char test_title[] = "Nested SVM VMLOAD/VMSAVE"; + +enum svm_negative_mode { + SVM_NEGATIVE_KERNEL, + SVM_NEGATIVE_USER, +}; + +enum svm_negative_paddr_kind { + SVM_NEGATIVE_PADDR_ALIGNED, + SVM_NEGATIVE_PADDR_UNALIGNED, + SVM_NEGATIVE_PADDR_TOO_WIDE, +}; + +struct svm_negative_case { + const char *name; + bool svme; + enum svm_negative_mode mode; + enum svm_negative_paddr_kind paddr_kind; + exinfo_t expected; +}; + +struct svm_negative_ops { + exinfo_t (*kernel)(uint64_t paddr); + unsigned long (*user)(unsigned long paddr); +}; + +/* Return false when setup already reported a skip or failure for the case. */ +typedef bool (*svm_negative_setup_fn)(const struct svm_negative_case *t, + uint64_t paddr, void *data); +typedef void (*svm_negative_teardown_fn)(const struct svm_negative_case *t, + uint64_t paddr, void *data); + +/* + * Execution context for a family of SVM negative cases. + * + * Optional setup/teardown hooks let higher-level tests prepare nested + * state such as L2 or L3 control structures outside the fixed case matrix + * before the helper dispatches the actual instruction. + */ +struct svm_negative_runner { + const void *vmcb_page; + const struct svm_negative_ops *ops; + void *data; + svm_negative_setup_fn setup; + svm_negative_teardown_fn teardown; +}; + +/** + * Execute VMLOAD at CPL0 with a caller-supplied VMCB physical address. + * @param paddr Candidate VMCB physical address for RAX. + * @return Recorded exception information, or zero on success. + */ +static exinfo_t stub_vmload(uint64_t paddr) +{ + exinfo_t fault = 0; + + asm volatile ("1: vmload %%rax; 2:" + _ASM_EXTABLE_HANDLER(1b, 2b, %P[rec]) + : "+D" (fault) + : "a" (paddr), [rec] "p" (ex_record_fault_edi) + : "memory"); + + return fault; +} + +/** + * Execute VMLOAD at CPL3 using a caller-supplied VMCB physical address. + * @param paddr Candidate VMCB physical address for RAX. + * @return Recorded exception information, or zero on success. + */ +static unsigned long __user_text user_vmload(unsigned long paddr) +{ + unsigned long fault = 0; + + asm volatile ("mov %[paddr], %%rax;" + "1: vmload %%rax; xor %%eax, %%eax; 2:" + _ASM_EXTABLE_HANDLER(1b, 2b, %P[rec]) + : "+a" (fault) + : [paddr] "r" (paddr), + [rec] "p" (ex_record_fault_eax) + : "memory"); + + return fault; +} + +static const struct svm_negative_ops vmload_ops = { + .kernel = stub_vmload, + .user = user_vmload, +}; + +/* Aligned scratch page used to form candidate VMCB physical addresses. */ +static uint8_t vmcb_page[PAGE_SIZE] __page_aligned_bss; + +static const struct svm_negative_runner vmload_runner = { + .vmcb_page = vmcb_page, + .ops = &vmload_ops, +}; + +/** + * Execute VMSAVE at CPL0 with a caller-supplied VMCB physical address. + * @param paddr Candidate VMCB physical address for RAX. + * @return Recorded exception information, or zero on success. + */ +static exinfo_t stub_vmsave(uint64_t paddr) +{ + exinfo_t fault = 0; + + asm volatile ("1: vmsave %%rax; 2:" + _ASM_EXTABLE_HANDLER(1b, 2b, %P[rec]) + : "+D" (fault) + : "a" (paddr), [rec] "p" (ex_record_fault_edi) + : "memory"); + + return fault; +} + +/** + * Execute VMSAVE at CPL3 using a caller-supplied VMCB physical address. + * @param paddr Candidate VMCB physical address for RAX. + * @return Recorded exception information, or zero on success. + */ +static unsigned long __user_text user_vmsave(unsigned long paddr) +{ + unsigned long fault = 0; + + asm volatile ("mov %[paddr], %%rax;" + "1: vmsave %%rax; xor %%eax, %%eax; 2:" + _ASM_EXTABLE_HANDLER(1b, 2b, %P[rec]) + : "+a" (fault) + : [paddr] "r" (paddr), + [rec] "p" (ex_record_fault_eax) + : "memory"); + + return fault; +} + +static const struct svm_negative_ops vmsave_ops = { + .kernel = stub_vmsave, + .user = user_vmsave, +}; + +static const struct svm_negative_runner vmsave_runner = { + .vmcb_page = vmcb_page, + .ops = &vmsave_ops, +}; + +static uint64_t svm_negative_paddr(const void *vmcb_page, + enum svm_negative_paddr_kind kind) +{ + switch ( kind ) + { + case SVM_NEGATIVE_PADDR_ALIGNED: + return _u(vmcb_page); + + case SVM_NEGATIVE_PADDR_UNALIGNED: + return _u(vmcb_page) | 1ul; + + case SVM_NEGATIVE_PADDR_TOO_WIDE: + return 1ull << maxphysaddr; + } + + unreachable(); +} + +static bool svm_negative_run(const struct svm_negative_case *t, + const struct svm_negative_runner *runner, + exinfo_t *res) +{ + uint64_t paddr = svm_negative_paddr(runner->vmcb_page, t->paddr_kind); + uint64_t orig_efer = 0; + + *res = 0; + + if ( runner->setup && !runner->setup(t, paddr, runner->data) ) + return false; + + orig_efer = update_efer_svme(t->svme); + + switch ( t->mode ) + { + case SVM_NEGATIVE_KERNEL: + *res = runner->ops->kernel(paddr); + break; + + case SVM_NEGATIVE_USER: + *res = exec_user_param(runner->ops->user, paddr); + break; + } + + if ( runner->teardown ) + runner->teardown(t, paddr, runner->data); + + wrmsr(MSR_EFER, orig_efer); + return *res == t->expected; +} + +bool svm_negative_check_cases(const struct svm_negative_case *cases, + unsigned int nr_cases, + const struct svm_negative_runner *runner) +{ + bool result = true; + + for ( unsigned int i = 0; i < nr_cases; ++i ) + { + exinfo_t res; + + printk("Running test %u: %s\n", i + 1, cases[i].name); + + if ( !svm_negative_run(&cases[i], runner, &res) ) + { + xtf_failure("Fail: %s, got %pe, expected %pe\n", + cases[i].name, _p(res), _p(cases[i].expected)); + result = false; + } + } + + return result; +} + +/** + * Execute the reachable VMLOAD and VMSAVE negative-case matrices. + * + * The matrix covers the distinct bare-metal failure classes that can be + * observed from an hvm64 L1 harness: EFER.SVME clear, CPL > 0, and malformed + * VMCB physical addresses in RAX. + */ +void test_main(void) +{ + static const struct svm_negative_case vmload_cases[] = { + { + .name = "vmload with SVME clear at CPL0", + .svme = false, + .mode = SVM_NEGATIVE_KERNEL, + .paddr_kind = SVM_NEGATIVE_PADDR_ALIGNED, + .expected = EXINFO_SYM(UD, 0), + }, + { + .name = "vmload with SVME clear at CPL0 and unaligned paddr", + .svme = false, + .mode = SVM_NEGATIVE_KERNEL, + .paddr_kind = SVM_NEGATIVE_PADDR_UNALIGNED, + .expected = EXINFO_SYM(GP, 0), + }, + { + .name = "vmload with SVME clear at CPL0 and overly wide paddr", + .svme = false, + .mode = SVM_NEGATIVE_KERNEL, + .paddr_kind = SVM_NEGATIVE_PADDR_TOO_WIDE, + .expected = EXINFO_SYM(UD, 0), + }, + { + .name = "vmload with SVME clear at CPL3", + .svme = false, + .mode = SVM_NEGATIVE_USER, + .paddr_kind = SVM_NEGATIVE_PADDR_ALIGNED, + .expected = EXINFO_SYM(GP, 0), + }, + { + .name = "vmload with SVME clear at CPL3 and unaligned paddr", + .svme = false, + .mode = SVM_NEGATIVE_USER, + .paddr_kind = SVM_NEGATIVE_PADDR_UNALIGNED, + .expected = EXINFO_SYM(GP, 0), + }, + { + .name = "vmload with SVME clear at CPL3 and overly wide paddr", + .svme = false, + .mode = SVM_NEGATIVE_USER, + .paddr_kind = SVM_NEGATIVE_PADDR_TOO_WIDE, + .expected = EXINFO_SYM(GP, 0), + }, + { + .name = "vmload with SVME set at CPL3", + .svme = true, + .mode = SVM_NEGATIVE_USER, + .paddr_kind = SVM_NEGATIVE_PADDR_ALIGNED, + .expected = EXINFO_SYM(GP, 0), + }, + { + .name = "vmload with SVME set at CPL3 and unaligned paddr", + .svme = true, + .mode = SVM_NEGATIVE_USER, + .paddr_kind = SVM_NEGATIVE_PADDR_UNALIGNED, + .expected = EXINFO_SYM(GP, 0), + }, + { + .name = "vmload with SVME set at CPL3 and overly wide paddr", + .svme = true, + .mode = SVM_NEGATIVE_USER, + .paddr_kind = SVM_NEGATIVE_PADDR_TOO_WIDE, + .expected = EXINFO_SYM(GP, 0), + }, + { + .name = "vmload with SVME set at CPL0 and unaligned paddr", + .svme = true, + .mode = SVM_NEGATIVE_KERNEL, + .paddr_kind = SVM_NEGATIVE_PADDR_UNALIGNED, + .expected = EXINFO_SYM(GP, 0), + }, + { + .name = "vmload with SVME set at CPL0 and overly wide paddr", + .svme = true, + .mode = SVM_NEGATIVE_KERNEL, + .paddr_kind = SVM_NEGATIVE_PADDR_TOO_WIDE, + .expected = EXINFO_SYM(GP, 0), + }, + }; + + static const struct svm_negative_case vmsave_cases[] = { + { + .name = "vmsave with SVME clear at CPL0", + .svme = false, + .mode = SVM_NEGATIVE_KERNEL, + .paddr_kind = SVM_NEGATIVE_PADDR_ALIGNED, + .expected = EXINFO_SYM(UD, 0), + }, + { + .name = "vmsave with SVME clear at CPL0 and unaligned paddr", + .svme = false, + .mode = SVM_NEGATIVE_KERNEL, + .paddr_kind = SVM_NEGATIVE_PADDR_UNALIGNED, + .expected = EXINFO_SYM(GP, 0), + }, + { + .name = "vmsave with SVME clear at CPL0 and overly wide paddr", + .svme = false, + .mode = SVM_NEGATIVE_KERNEL, + .paddr_kind = SVM_NEGATIVE_PADDR_TOO_WIDE, + .expected = EXINFO_SYM(UD, 0), + }, + { + .name = "vmsave with SVME clear at CPL3", + .svme = false, + .mode = SVM_NEGATIVE_USER, + .paddr_kind = SVM_NEGATIVE_PADDR_ALIGNED, + .expected = EXINFO_SYM(GP, 0), + }, + { + .name = "vmsave with SVME clear at CPL3 and unaligned paddr", + .svme = false, + .mode = SVM_NEGATIVE_USER, + .paddr_kind = SVM_NEGATIVE_PADDR_UNALIGNED, + .expected = EXINFO_SYM(GP, 0), + }, + { + .name = "vmsave with SVME clear at CPL3 and overly wide paddr", + .svme = false, + .mode = SVM_NEGATIVE_USER, + .paddr_kind = SVM_NEGATIVE_PADDR_TOO_WIDE, + .expected = EXINFO_SYM(GP, 0), + }, + { + .name = "vmsave with SVME set at CPL3", + .svme = true, + .mode = SVM_NEGATIVE_USER, + .paddr_kind = SVM_NEGATIVE_PADDR_ALIGNED, + .expected = EXINFO_SYM(GP, 0), + }, + { + .name = "vmsave with SVME set at CPL3 and unaligned paddr", + .svme = true, + .mode = SVM_NEGATIVE_USER, + .paddr_kind = SVM_NEGATIVE_PADDR_UNALIGNED, + .expected = EXINFO_SYM(GP, 0), + }, + { + .name = "vmsave with SVME set at CPL3 and overly wide paddr", + .svme = true, + .mode = SVM_NEGATIVE_USER, + .paddr_kind = SVM_NEGATIVE_PADDR_TOO_WIDE, + .expected = EXINFO_SYM(GP, 0), + }, + { + .name = "vmsave with SVME set at CPL0 and unaligned paddr", + .svme = true, + .mode = SVM_NEGATIVE_KERNEL, + .paddr_kind = SVM_NEGATIVE_PADDR_UNALIGNED, + .expected = EXINFO_SYM(GP, 0), + }, + { + .name = "vmsave with SVME set at CPL0 and overly wide paddr", + .svme = true, + .mode = SVM_NEGATIVE_KERNEL, + .paddr_kind = SVM_NEGATIVE_PADDR_TOO_WIDE, + .expected = EXINFO_SYM(GP, 0), + }, + }; + bool res1, res2; + + if ( !cpu_has_svm ) + { + xtf_skip("Skip: SVM not available\n"); + return; + } + + res1 = svm_negative_check_cases(vmload_cases, ARRAY_SIZE(vmload_cases), + &vmload_runner); + res2 = svm_negative_check_cases(vmsave_cases, ARRAY_SIZE(vmsave_cases), + &vmsave_runner); + if (res1 && res2) + xtf_success(NULL); + else + xtf_failure("One or more negative cases failed\n"); +} From 679edda9355a9b40afc7a14e8f39a2baea896a66 Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Mon, 20 Jul 2026 12:00:00 +0000 Subject: [PATCH 17/19] Add nested-svm test: nested-svm-stgi-clgi - Test STGI & CLGI Signed-off-by: Bernhard Kaindl --- tests/nested-svm-clgi-stgi/Makefile | 12 + tests/nested-svm-clgi-stgi/extra.cfg.in | 1 + tests/nested-svm-clgi-stgi/main.c | 355 ++++++++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 tests/nested-svm-clgi-stgi/Makefile create mode 100644 tests/nested-svm-clgi-stgi/extra.cfg.in create mode 100644 tests/nested-svm-clgi-stgi/main.c diff --git a/tests/nested-svm-clgi-stgi/Makefile b/tests/nested-svm-clgi-stgi/Makefile new file mode 100644 index 0000000..a6f6f1c --- /dev/null +++ b/tests/nested-svm-clgi-stgi/Makefile @@ -0,0 +1,12 @@ +include $(ROOT)/build/common.mk + +NAME := nested-svm-clgi-stgi +CATEGORY := nested-svm +TEST-ENVS := $(SVM_ENVIRONMENTS) + +TEST-EXTRA-CFG := extra.cfg.in + +obj-perenv += main.o +obj-perenv += $(ROOT)/common/nested-svm/setup-l2.o + +include $(ROOT)/build/gen.mk diff --git a/tests/nested-svm-clgi-stgi/extra.cfg.in b/tests/nested-svm-clgi-stgi/extra.cfg.in new file mode 100644 index 0000000..ae494f8 --- /dev/null +++ b/tests/nested-svm-clgi-stgi/extra.cfg.in @@ -0,0 +1 @@ +nestedhvm = 1 diff --git a/tests/nested-svm-clgi-stgi/main.c b/tests/nested-svm-clgi-stgi/main.c new file mode 100644 index 0000000..72d5235 --- /dev/null +++ b/tests/nested-svm-clgi-stgi/main.c @@ -0,0 +1,355 @@ +/** + * @file tests/nested-svm-clgi-stgi/main.c + * @ref test-nested-svm-clgi-stgi + * + * @page test-nested-svm-clgi-stgi Smoke-test STGI/CLGI in AMD SVM + * + * An L1 guest: + * 1. enables SVM, + * 2. builds a minimal L2 VMCB that re-uses L1's address space, and + * 3. prepares a pending virtual hardware interrupt to be taken in L2. + * 4. uses CLGI/STGI to manage interrupts in L2. + * + * L2: + * 1. tests receiving the pending virtual hardware interrupt + * 2. increments or decrements %rax to signal + * whether the interrupt was taken or not, and + * 3. signals completion with HLT, which causes a #VMEXIT back to L1. + * + * @see tests/nested-svm-clgi-stgi/main.c + */ +#include + +const char test_title[] = "Nested SVM CLGI/STGI Smoke Test"; + +/* The VMCB's Virtual Interrupt Control field for by-field access. */ +static struct v_intr_ctrl_fields *intr_ctrl = &vmcb12.v_intr_ctrl.fields; + +/** + * L2 Interrupt Service Routine for Vector 0x30. + * It is used to prove the interrupt was actually taken. + */ +static void __used l2_isr_0x30(void) +{ + /* Decrement rax before exiting to indicate that an interrupt was taken */ + asm volatile ("dec %rax;hlt"); +} + +/** + * Check if the ISR for 0x30 was called by checking if %rax was decremented. + * L2 should have decremented %rax from 2 to 1 if the interrupt was taken. + */ +static bool l2_isr_0x30_called(void) +{ + return vmcb12.rax == 1; /* L2 should have decremented %rax from 2 to 1 */ +} + +static bool l2_isr_0x30_not_called(void) +{ + return vmcb12.rax == 3; /* L2 should have incremented %rax from 2 to 3 */ +} + +static void __used l2_clgi_hlt(void) +{ + asm volatile ("clgi\n" + "hlt"); +} + +static void __used l2_stgi_hlt(void) +{ + asm volatile ("stgi\n" + "hlt"); +} + +/** + * L2 payload with STI to take pending interrupts. In case of a pending + * interrupt, it will be taken after the instruction following STI. + * If interrupts are disabled, the interrupt will not be taken, inc %rax + * will increment %rax to signal that exit L2 with HLT. + */ +static void __used l2_sti_nop_inc_rax_hlt(void) +{ + /* STI to allow interrupts, then increment %rax */ + asm volatile ("sti\n" + "nop\n" /* Ensures STI takes effect before inc %rax */ + "inc %rax\n" /* Signal that no IRQ or ISR returned */ + "hlt"); /* Exit L2 */ +} + +static void __used l2_clgi_sti_nop_inc_rax_hlt(void) +{ + asm volatile ("clgi\n" + "sti\n" + "nop\n" + "inc %rax\n" + "hlt"); +} + +static void __used l2_stgi_sti_nop_inc_rax_hlt(void) +{ + asm volatile ("stgi\n" + "sti\n" + "nop\n" + "inc %rax\n" + "hlt"); +} + +static void run_l2_probe_exit(const char *file, int line, uint64_t rip, + bool virq_pending) +{ + vmcb12.rax = 2; /* Starting value for L2 to inc/decrement before HLT */ + vmcb12.rip = rip; + vmcb12.rflags &= ~X86_EFLAGS_IF; + + /* Inject a pending virtual hardware interrupt using the VMCB */ + intr_ctrl->vIRQ_pending = virq_pending; + + print_v_intr_ctrl(file, line, &vmcb12, "pre-VMRUN "); + asm volatile("mov %0, %%rax\n" + "vmload %%rax\n" + "vmrun %%rax\n" + "vmsave %%rax\n" + : + : "r" (_u(&vmcb12)) + : "%rax", "memory"); + print_v_intr_ctrl(file, line, &vmcb12, "post-VMRUN"); +} + +static bool run_l2_expect_exit(const char *file, int line, uint64_t rip, + uint64_t expected_exitcode, bool virq_pending) +{ + run_l2_probe_exit(file, line, rip, virq_pending); + + if ( vmcb12.exitcode != expected_exitcode ) + { + xtf_failure("%s:%d: unexpected L2 exit: 0x%lx (%s), expected 0x%lx (%s)\n", + file, line, vmcb12.exitcode, + vmexit_reason(vmcb12.exitcode), expected_exitcode, + vmexit_reason(expected_exitcode)); + return false; + } + return true; +} + +static bool expect_xfail_l2_clgi_stgi_intercepts_without_vgif(void) +{ + uint64_t clgi_exitcode, stgi_exitcode; + + printk("L1: XFAIL testing CLGI/STGI intercepts with vGIF disabled\n"); + + intr_ctrl->vGIF_enabled = 0; + intr_ctrl->vGIF = 0; + vmcb12.intercept_insns_vec_010.fields.clgi = 1; + run_l2_probe_exit(__FILE__, __LINE__, _u(l2_clgi_hlt), false); + clgi_exitcode = vmcb12.exitcode; + vmcb12.intercept_insns_vec_010.fields.clgi = 0; + + intr_ctrl->vGIF_enabled = 0; + intr_ctrl->vGIF = 0; + vmcb12.intercept_insns_vec_010.fields.stgi = 1; + run_l2_probe_exit(__FILE__, __LINE__, _u(l2_stgi_hlt), false); + stgi_exitcode = vmcb12.exitcode; + vmcb12.intercept_insns_vec_010.fields.stgi = 0; + + if ( clgi_exitcode == VMEXIT_HLT && stgi_exitcode == VMEXIT_HLT ) + { + xtf_warning("XFAIL: Xen did not intercept CLGI/STGI when L1 disabled vGIF\n"); + return true; + } + + if ( clgi_exitcode == VMEXIT_CLGI && stgi_exitcode == VMEXIT_STGI ) + { + xtf_failure("XPASS: Xen intercepted CLGI/STGI with vGIF disabled; " + "update this test to expect the bug fixed\n"); + return false; + } + + xtf_failure("Fail: unexpected CLGI/STGI exits with vGIF disabled: " + "CLGI 0x%lx (%s), STGI 0x%lx (%s)\n", + clgi_exitcode, vmexit_reason(clgi_exitcode), + stgi_exitcode, vmexit_reason(stgi_exitcode)); + return false; +} + +static bool run_l2(const char *file, int line, uint64_t rip) +{ + return run_l2_expect_exit(file, line, rip, VMEXIT_HLT, true); +} + +#define FAIL(fmt, ...) { \ + xtf_failure("%s:%d: " fmt, __FILE__, __LINE__, ##__VA_ARGS__); \ + return false; \ +} + +/** + * Test that a pending virtual IRQ is not taken when vGIF is disabled. + */ +bool test_virq_without_vgif(void) +{ + printk("L1: testing pending virtual IRQ without vGIF\n"); + intr_ctrl->vGIF_enabled = 0; /* Disable the vGIF feature */ + + if ( !run_l2(__FILE__, __LINE__, _u(l2_sti_nop_inc_rax_hlt)) ) + return false; + + if ( intr_ctrl->vIRQ_pending ) + FAIL("L2 should have cleared the pending virtual IRQ\n"); + if ( !l2_isr_0x30_called() ) + FAIL("L2 0x30 isr was not called as expected\n"); + return true; +} + +/** + * Test that a pending virtual IRQ is not taken when vGIF is enabled but + * interrupts are disabled in L2. + */ +bool test_virq_with_vgif_irqs_disabled(void) +{ + printk("L1: testing pending virtual IRQ with vGIF disabled\n"); + intr_ctrl->vGIF_enabled = 1; /* Enable vGIF */ + intr_ctrl->vGIF = 0; /* disable interrupts */ + + /* Starting value for L2 to inc/decrement before HLT */ + if ( !run_l2(__FILE__, __LINE__, _u(l2_sti_nop_inc_rax_hlt)) ) + return false; + + if ( !intr_ctrl->vIRQ_pending ) + FAIL("L2 should have kept the pending virtual IRQ\n"); + if ( l2_isr_0x30_called() ) + FAIL("L2 0x30 isr called when vGIF:0 should have blocked it\n"); + return true; +} + +/** + * Test that a pending virtual IRQ is taken in L2 when vGIF is enabled and + * interrupts are enabled. + */ +bool test_virq_with_vgif_irqs_enabled(void) +{ + printk("L1: testing pending virtual IRQ with vGIF enabled\n"); + intr_ctrl->vGIF_enabled = 1; /* Enable vGIF */ + intr_ctrl->vGIF = 1; /* enable interrupts */ + + /* Starting value for L2 to inc/decrement before HLT */ + if ( !run_l2(__FILE__, __LINE__, _u(l2_sti_nop_inc_rax_hlt)) ) + return false; + + if ( intr_ctrl->vIRQ_pending ) + FAIL("L2 should have cleared the pending virtual IRQ\n"); + if ( !l2_isr_0x30_called() ) + FAIL("L2 0x30 isr was not called as expected\n"); + return true; +} + +/** + * Test that L2 CLGI and STGI are directly interceptible instructions. + */ +bool test_l2_clgi_stgi_intercepts(void) +{ + printk("L1: testing L2 CLGI/STGI intercepts\n"); + intr_ctrl->vGIF_enabled = 1; + + intr_ctrl->vGIF = 1; + vmcb12.intercept_insns_vec_010.fields.clgi = 1; + if ( !run_l2_expect_exit(__FILE__, __LINE__, _u(l2_clgi_hlt), + VMEXIT_CLGI, false) ) + return false; + vmcb12.intercept_insns_vec_010.fields.clgi = 0; + + intr_ctrl->vGIF = 0; + vmcb12.intercept_insns_vec_010.fields.stgi = 1; + if ( !run_l2_expect_exit(__FILE__, __LINE__, _u(l2_stgi_hlt), + VMEXIT_STGI, false) ) + return false; + vmcb12.intercept_insns_vec_010.fields.stgi = 0; + + return true; +} + +/** + * Test that L2 CLGI clears vGIF and blocks a pending virtual IRQ. + */ +bool test_l2_clgi_blocks_virq(void) +{ + printk("L1: testing L2 CLGI blocks a pending virtual IRQ\n"); + intr_ctrl->vGIF_enabled = 1; + intr_ctrl->vGIF = 1; + + if ( !run_l2(__FILE__, __LINE__, _u(l2_clgi_sti_nop_inc_rax_hlt)) ) + return false; + + if ( !intr_ctrl->vIRQ_pending ) + FAIL("L2 CLGI should have kept the pending virtual IRQ\n"); + if ( intr_ctrl->vGIF ) + FAIL("L2 CLGI should have cleared vGIF\n"); + if ( !l2_isr_0x30_not_called() ) + FAIL("L2 0x30 isr called when CLGI should have blocked it\n"); + return true; +} + +/** + * Test that L2 STGI sets vGIF and allows a pending virtual IRQ. + */ +bool test_l2_stgi_allows_virq(void) +{ + printk("L1: testing L2 STGI allows a pending virtual IRQ\n"); + intr_ctrl->vGIF_enabled = 1; + intr_ctrl->vGIF = 0; + + if ( !run_l2(__FILE__, __LINE__, _u(l2_stgi_sti_nop_inc_rax_hlt)) ) + return false; + + if ( intr_ctrl->vIRQ_pending ) + FAIL("L2 STGI should have cleared the pending virtual IRQ\n"); + if ( !intr_ctrl->vGIF ) + FAIL("L2 STGI should have set vGIF\n"); + if ( !l2_isr_0x30_called() ) + FAIL("L2 0x30 isr was not called after STGI\n"); + return true; +} + +/** + * Execute the nested-SVM CLGI/STGI smoke test. + * + * L1 enables SVM, prepares a minimal L2 VMCB, enters L2 once with VMRUN + * and verifies that L2 reports success before exiting with HLT. + */ +void test_main(void) +{ + /* Enable SVM, arm the host-save area and build the L2 VMCB. */ + + if ( !svm_l1_enable_svm() ) + return; + + /* Setup the vmcb for the test */ + svm_l2_build_vmcb(&vmcb12, NULL); + + /* Set up L2's IDTR and install the interrupt gate used by the test. */ + setup_l2_idt(&vmcb12, 0x30, l2_isr_0x30); + + /* Set the injected virtual hardware interrupt to vector 0x30 */ + intr_ctrl->vector = 0x30; + + if ( !test_virq_without_vgif() ) + return; + + if ( !test_virq_with_vgif_irqs_disabled() ) + return; + + if ( !test_virq_with_vgif_irqs_enabled() ) + return; + + if ( !test_l2_clgi_stgi_intercepts() ) + return; + + if ( !test_l2_clgi_blocks_virq() ) + return; + + if ( !test_l2_stgi_allows_virq() ) + return; + + if ( !expect_xfail_l2_clgi_stgi_intercepts_without_vgif() ) + return; + + xtf_success(NULL); +} From ee60d7d1e196551fa3e4642780291a97f1ea3692 Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Mon, 20 Jul 2026 12:00:00 +0000 Subject: [PATCH 18/19] Add pipeline.yaml to suppress the citrix-copyright-autofix bot Signed-off-by: Bernhard Kaindl --- pipeline.yaml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 pipeline.yaml diff --git a/pipeline.yaml b/pipeline.yaml new file mode 100644 index 0000000..b310e8d --- /dev/null +++ b/pipeline.yaml @@ -0,0 +1,3 @@ +# Pipeline configuration to not run copyright checks on pull requests. +copyright: + enablePRCheck: false From c4defe491f67160f81a0654144862b42ca976da8 Mon Sep 17 00:00:00 2001 From: Bernhard Kaindl Date: Tue, 21 Jul 2026 11:44:17 +0000 Subject: [PATCH 19/19] CI: Only run on pull requests (some orgs may deny push events) --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93f0738..f754b55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,9 @@ name: CI -on: [push, pull_request] +# Some organisations do not allow workflows to run on push events, +# so we only run on pull requests. +on: + - pull_request jobs: python: