Repository: cilium/ebpf
Pull request #1597 (2024-10-24 09:53:24)

internal: add a way to write portable feature tests
Feature tests are used to enable new features in a backwards compatible manner. They exercise very specific code paths in the kernel. It makes sense to assume that the existing feature tests will not work as intended when porting to a new platform.

Change the NewFeatureTest constructor to take a list of versions, which can be used to specify which operating systems are supported.


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 33ef404..300223b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -155,7 +155,7 @@ jobs:
     needs: build-and-lint
     timeout-minutes: 15
     env:
-      EBPF_TEST_IGNORE_KERNEL_VERSION: 'TestKprobeMulti,TestKprobeMultiErrors,TestKprobeMultiCookie,TestKprobeMultiProgramCall,TestHaveBPFLinkKprobeMulti'
+      EBPF_TEST_IGNORE_VERSION: 'TestKprobeMulti,TestKprobeMultiErrors,TestKprobeMultiCookie,TestKprobeMultiProgramCall,TestHaveBPFLinkKprobeMulti'
     steps:
       - uses: actions/checkout@v4
 
diff --git a/btf/feature.go b/btf/feature.go
index 6feb08d..d33f9fc 100644
--- a/btf/feature.go
+++ b/btf/feature.go
@@ -11,19 +11,19 @@ import (
 
 // haveBTF attempts to load a BTF blob containing an Int. It should pass on any
 // kernel that supports BPF_BTF_LOAD.
-var haveBTF = internal.NewFeatureTest("BTF", "4.18", func() error {
+var haveBTF = internal.NewFeatureTest("BTF", func() error {
 	// 0-length anonymous integer
 	err := probeBTF(&Int{})
 	if errors.Is(err, unix.EINVAL) || errors.Is(err, unix.EPERM) {
 		return internal.ErrNotSupported
 	}
 	return err
-})
+}, "4.18")
 
 // haveMapBTF attempts to load a minimal BTF blob containing a Var. It is
 // used as a proxy for .bss, .data and .rodata map support, which generally
 // come with a Var and Datasec. These were introduced in Linux 5.2.
-var haveMapBTF = internal.NewFeatureTest("Map BTF (Var/Datasec)", "5.2", func() error {
+var haveMapBTF = internal.NewFeatureTest("Map BTF (Var/Datasec)", func() error {
 	if err := haveBTF(); err != nil {
 		return err
 	}
@@ -40,12 +40,12 @@ var haveMapBTF = internal.NewFeatureTest("Map BTF (Var/Datasec)", "5.2", func()
 		return internal.ErrNotSupported
 	}
 	return err
-})
+}, "5.2")
 
 // haveProgBTF attempts to load a BTF blob containing a Func and FuncProto. It
 // is used as a proxy for ext_info (func_info) support, which depends on
 // Func(Proto) by definition.
-var haveProgBTF = internal.NewFeatureTest("Program BTF (func/line_info)", "5.0", func() error {
+var haveProgBTF = internal.NewFeatureTest("Program BTF (func/line_info)", func() error {
 	if err := haveBTF(); err != nil {
 		return err
 	}
@@ -60,9 +60,9 @@ var haveProgBTF = internal.NewFeatureTest("Program BTF (func/line_info)", "5.0",
 		return internal.ErrNotSupported
 	}
 	return err
-})
+}, "5.0")
 
-var haveFuncLinkage = internal.NewFeatureTest("BTF func linkage", "5.6", func() error {
+var haveFuncLinkage = internal.NewFeatureTest("BTF func linkage", func() error {
 	if err := haveProgBTF(); err != nil {
 		return err
 	}
@@ -78,9 +78,9 @@ var haveFuncLinkage = internal.NewFeatureTest("BTF func linkage", "5.6", func()
 		return internal.ErrNotSupported
 	}
 	return err
-})
+}, "5.6")
 
-var haveEnum64 = internal.NewFeatureTest("ENUM64", "6.0", func() error {
+var haveEnum64 = internal.NewFeatureTest("ENUM64", func() error {
 	if err := haveBTF(); err != nil {
 		return err
 	}
@@ -97,7 +97,7 @@ var haveEnum64 = internal.NewFeatureTest("ENUM64", "6.0", func() error {
 		return internal.ErrNotSupported
 	}
 	return err
-})
+}, "6.0")
 
 func probeBTF(typ Type) error {
 	b, err := NewBuilder([]Type{typ})
diff --git a/features/misc.go b/features/misc.go
index 6bd8df9..0c4e1a2 100644
--- a/features/misc.go
+++ b/features/misc.go
@@ -16,7 +16,7 @@ func HaveLargeInstructions() error {
 	return haveLargeInstructions()
 }
 
-var haveLargeInstructions = internal.NewFeatureTest(">4096 instructions", "5.2", func() error {
+var haveLargeInstructions = internal.NewFeatureTest(">4096 instructions", func() error {
 	const maxInsns = 4096
 
 	insns := make(asm.Instructions, maxInsns, maxInsns+1)
@@ -29,7 +29,7 @@ var haveLargeInstructions = internal.NewFeatureTest(">4096 instructions", "5.2",
 		Type:         ebpf.SocketFilter,
 		Instructions: insns,
 	})
-})
+}, "5.2")
 
 // HaveBoundedLoops probes the running kernel if bounded loops are supported.
 //
@@ -40,7 +40,7 @@ func HaveBoundedLoops() error {
 	return haveBoundedLoops()
 }
 
-var haveBoundedLoops = internal.NewFeatureTest("bounded loops", "5.3", func() error {
+var haveBoundedLoops = internal.NewFeatureTest("bounded loops", func() error {
 	return probeProgram(&ebpf.ProgramSpec{
 		Type: ebpf.SocketFilter,
 		Instructions: asm.Instructions{
@@ -50,7 +50,7 @@ var haveBoundedLoops = internal.NewFeatureTest("bounded loops", "5.3", func() er
 			asm.Return(),
 		},
 	})
-})
+}, "5.3")
 
 // HaveV2ISA probes the running kernel if instructions of the v2 ISA are supported.
 //
@@ -61,7 +61,7 @@ func HaveV2ISA() error {
 	return haveV2ISA()
 }
 
-var haveV2ISA = internal.NewFeatureTest("v2 ISA", "4.14", func() error {
+var haveV2ISA = internal.NewFeatureTest("v2 ISA", func() error {
 	return probeProgram(&ebpf.ProgramSpec{
 		Type: ebpf.SocketFilter,
 		Instructions: asm.Instructions{
@@ -71,7 +71,7 @@ var haveV2ISA = internal.NewFeatureTest("v2 ISA", "4.14", func() error {
 			asm.Return().WithSymbol("exit"),
 		},
 	})
-})
+}, "4.14")
 
 // HaveV3ISA probes the running kernel if instructions of the v3 ISA are supported.
 //
@@ -82,7 +82,7 @@ func HaveV3ISA() error {
 	return haveV3ISA()
 }
 
-var haveV3ISA = internal.NewFeatureTest("v3 ISA", "5.1", func() error {
+var haveV3ISA = internal.NewFeatureTest("v3 ISA", func() error {
 	return probeProgram(&ebpf.ProgramSpec{
 		Type: ebpf.SocketFilter,
 		Instructions: asm.Instructions{
@@ -92,4 +92,4 @@ var haveV3ISA = internal.NewFeatureTest("v3 ISA", "5.1", func() error {
 			asm.Return().WithSymbol("exit"),
 		},
 	})
-})
+}, "5.1")
diff --git a/info.go b/info.go
index 9bb67b1..3652fd7 100644
--- a/info.go
+++ b/info.go
@@ -644,7 +644,7 @@ func EnableStats(which uint32) (io.Closer, error) {
 	return fd, nil
 }
 
-var haveProgramInfoMapIDs = internal.NewFeatureTest("map IDs in program info", "4.15", func() error {
+var haveProgramInfoMapIDs = internal.NewFeatureTest("map IDs in program info", func() error {
 	prog, err := progLoad(asm.Instructions{
 		asm.LoadImm(asm.R0, 0, asm.DWord),
 		asm.Return(),
@@ -669,4 +669,4 @@ var haveProgramInfoMapIDs = internal.NewFeatureTest("map IDs in program info", "
 	}
 
 	return err
-})
+}, "4.15")
diff --git a/internal/feature.go b/internal/feature.go
index 2b856c7..6399be0 100644
--- a/internal/feature.go
+++ b/internal/feature.go
@@ -3,15 +3,25 @@ package internal
 import (
 	"errors"
 	"fmt"
+	"runtime"
+	"strings"
 	"sync"
 )
 
-// ErrNotSupported indicates that a feature is not supported by the current kernel.
+// ErrNotSupported indicates that a feature is not supported.
 var ErrNotSupported = errors.New("not supported")
 
+// ErrNotSupportedOnOS indicates that a feature is not supported on the current
+// operating system.
+var ErrNotSupportedOnOS = fmt.Errorf("%w on %s", ErrNotSupported, runtime.GOOS)
+
 // UnsupportedFeatureError is returned by FeatureTest() functions.
 type UnsupportedFeatureError struct {
-	// The minimum Linux mainline version required for this feature.
+	// The minimum version required for this feature.
+	//
+	// On Linux this refers to the mainline kernel version, on other platforms
+	// to the version of the runtime.
+	//
 	// Used for the error string, and for sanity checking during testing.
 	MinimumVersion Version
 
@@ -58,11 +68,44 @@ type FeatureTest struct {
 type FeatureTestFn func() error
 
 // NewFeatureTest is a convenient way to create a single [FeatureTest].
-func NewFeatureTest(name, version string, fn FeatureTestFn) func() error {
+//
+// versions specifies in which version of a BPF runtime a feature appeared.
+// The format is "GOOS:Major.Minor[.Patch]". GOOS may be omitted when targeting
+// Linux. Returns [ErrNotSupportedOnOS] if there is no version specified for the
+// current OS.
+func NewFeatureTest(name string, fn FeatureTestFn, versions ...string) func() error {
+	const nativePrefix = runtime.GOOS + ":"
+
+	if len(versions) == 0 {
+		return func() error {
+			return fmt.Errorf("feature test %q: no versions specified", name)
+		}
+	}
+
 	ft := &FeatureTest{
-		Name:    name,
-		Version: version,
-		Fn:      fn,
+		Name: name,
+		Fn:   fn,
+	}
+
+	for _, version := range versions {
+		if strings.HasPrefix(version, nativePrefix) {
+			ft.Version = strings.TrimPrefix(version, nativePrefix)
+			break
+		}
+
+		if runtime.GOOS == "linux" && !strings.ContainsRune(version, ':') {
+			// Allow version numbers without a GOOS prefix on Linux.
+			ft.Version = version
+			break
+		}
+	}
+
+	if ft.Version == "" {
+		return func() error {
+			// We don't return an UnsupportedFeatureError here, since that will
+			// trigger version checks which don't make sense.
+			return fmt.Errorf("%s: %w", name, ErrNotSupportedOnOS)
+		}
 	}
 
 	return ft.execute
diff --git a/link/kprobe_multi.go b/link/kprobe_multi.go
index 77aa586..094cb05 100644
--- a/link/kprobe_multi.go
+++ b/link/kprobe_multi.go
@@ -149,7 +149,7 @@ func (kml *kprobeMultiLink) Info() (*Info, error) {
 	}, nil
 }
 
-var haveBPFLinkKprobeMulti = internal.NewFeatureTest("bpf_link_kprobe_multi", "5.18", func() error {
+var haveBPFLinkKprobeMulti = internal.NewFeatureTest("bpf_link_kprobe_multi", func() error {
 	prog, err := ebpf.NewProgram(&ebpf.ProgramSpec{
 		Name: "probe_kpm_link",
 		Type: ebpf.Kprobe,
@@ -188,4 +188,4 @@ var haveBPFLinkKprobeMulti = internal.NewFeatureTest("bpf_link_kprobe_multi", "5
 	fd.Close()
 
 	return nil
-})
+}, "5.18")
diff --git a/link/perf_event.go b/link/perf_event.go
index 675ddf9..7440e8b 100644
--- a/link/perf_event.go
+++ b/link/perf_event.go
@@ -303,7 +303,7 @@ func openTracepointPerfEvent(tid uint64, pid int) (*sys.FD, error) {
 //
 // https://elixir.bootlin.com/linux/v5.16.8/source/kernel/bpf/syscall.c#L4307
 // https://github.com/torvalds/linux/commit/b89fbfbb854c9afc3047e8273cc3a694650b802e
-var haveBPFLinkPerfEvent = internal.NewFeatureTest("bpf_link_perf_event", "5.15", func() error {
+var haveBPFLinkPerfEvent = internal.NewFeatureTest("bpf_link_perf_event", func() error {
 	prog, err := ebpf.NewProgram(&ebpf.ProgramSpec{
 		Name: "probe_bpf_perf_link",
 		Type: ebpf.Kprobe,
@@ -329,4 +329,4 @@ var haveBPFLinkPerfEvent = internal.NewFeatureTest("bpf_link_perf_event", "5.15"
 		return nil
 	}
 	return err
-})
+}, "5.15")
diff --git a/link/syscalls.go b/link/syscalls.go
index d09b5ac..25951b0 100644
--- a/link/syscalls.go
+++ b/link/syscalls.go
@@ -30,7 +30,7 @@ const (
 	NetkitType        = sys.BPF_LINK_TYPE_NETKIT
 )
 
-var haveProgAttach = internal.NewFeatureTest("BPF_PROG_ATTACH", "4.10", func() error {
+var haveProgAttach = internal.NewFeatureTest("BPF_PROG_ATTACH", func() error {
 	prog, err := ebpf.NewProgram(&ebpf.ProgramSpec{
 		Type:    ebpf.CGroupSKB,
 		License: "MIT",
@@ -48,9 +48,9 @@ var haveProgAttach = internal.NewFeatureTest("BPF_PROG_ATTACH", "4.10", func() e
 	// have the syscall.
 	prog.Close()
 	return nil
-})
+}, "4.10")
 
-var haveProgAttachReplace = internal.NewFeatureTest("BPF_PROG_ATTACH atomic replacement of MULTI progs", "5.5", func() error {
+var haveProgAttachReplace = internal.NewFeatureTest("BPF_PROG_ATTACH atomic replacement of MULTI progs", func() error {
 	if err := haveProgAttach(); err != nil {
 		return err
 	}
@@ -90,9 +90,9 @@ var haveProgAttachReplace = internal.NewFeatureTest("BPF_PROG_ATTACH atomic repl
 		return nil
 	}
 	return err
-})
+}, "5.5")
 
-var haveBPFLink = internal.NewFeatureTest("bpf_link", "5.7", func() error {
+var haveBPFLink = internal.NewFeatureTest("bpf_link", func() error {
 	attr := sys.LinkCreateAttr{
 		// This is a hopefully invalid file descriptor, which triggers EBADF.
 		TargetFd:   ^uint32(0),
@@ -107,9 +107,9 @@ var haveBPFLink = internal.NewFeatureTest("bpf_link", "5.7", func() error {
 		return nil
 	}
 	return err
-})
+}, "5.7")
 
-var haveProgQuery = internal.NewFeatureTest("BPF_PROG_QUERY", "4.15", func() error {
+var haveProgQuery = internal.NewFeatureTest("BPF_PROG_QUERY", func() error {
 	attr := sys.ProgQueryAttr{
 		// We rely on this being checked during the syscall.
 		// With an otherwise correct payload we expect EBADF here
@@ -127,9 +127,9 @@ var haveProgQuery = internal.NewFeatureTest("BPF_PROG_QUERY", "4.15", func() err
 		return ErrNotSupported
 	}
 	return errors.New("syscall succeeded unexpectedly")
-})
+}, "4.15")
 
-var haveTCX = internal.NewFeatureTest("tcx", "6.6", func() error {
+var haveTCX = internal.NewFeatureTest("tcx", func() error {
 	prog, err := ebpf.NewProgram(&ebpf.ProgramSpec{
 		Type:    ebpf.SchedCLS,
 		License: "MIT",
@@ -162,9 +162,9 @@ var haveTCX = internal.NewFeatureTest("tcx", "6.6", func() error {
 		return ErrNotSupported
 	}
 	return errors.New("syscall succeeded unexpectedly")
-})
+}, "6.6")
 
-var haveNetkit = internal.NewFeatureTest("netkit", "6.7", func() error {
+var haveNetkit = internal.NewFeatureTest("netkit", func() error {
 	prog, err := ebpf.NewProgram(&ebpf.ProgramSpec{
 		Type:    ebpf.SchedCLS,
 		License: "MIT",
@@ -197,4 +197,4 @@ var haveNetkit = internal.NewFeatureTest("netkit", "6.7", func() error {
 		return ErrNotSupported
 	}
 	return errors.New("syscall succeeded unexpectedly")
-})
+}, "6.7")
diff --git a/link/uprobe.go b/link/uprobe.go
index 2cb1852..1852a3f 100644
--- a/link/uprobe.go
+++ b/link/uprobe.go
@@ -16,7 +16,7 @@ var (
 	uprobeRefCtrOffsetPMUPath = "/sys/bus/event_source/devices/uprobe/format/ref_ctr_offset"
 	// elixir.bootlin.com/linux/v5.15-rc7/source/kernel/events/core.c#L9799
 	uprobeRefCtrOffsetShift = 32
-	haveRefCtrOffsetPMU     = internal.NewFeatureTest("RefCtrOffsetPMU", "4.20", func() error {
+	haveRefCtrOffsetPMU     = internal.NewFeatureTest("RefCtrOffsetPMU", func() error {
 		_, err := os.Stat(uprobeRefCtrOffsetPMUPath)
 		if errors.Is(err, os.ErrNotExist) {
 			return internal.ErrNotSupported
@@ -25,7 +25,7 @@ var (
 			return err
 		}
 		return nil
-	})
+	}, "4.20")
 
 	// ErrNoSymbol indicates that the given symbol was not found
 	// in the ELF symbols table.
diff --git a/link/uprobe_multi.go b/link/uprobe_multi.go
index 53f463f..49dc18b 100644
--- a/link/uprobe_multi.go
+++ b/link/uprobe_multi.go
@@ -175,7 +175,7 @@ func (kml *uprobeMultiLink) Update(_ *ebpf.Program) error {
 	return fmt.Errorf("update uprobe_multi: %w", ErrNotSupported)
 }
 
-var haveBPFLinkUprobeMulti = internal.NewFeatureTest("bpf_link_uprobe_multi", "6.6", func() error {
+var haveBPFLinkUprobeMulti = internal.NewFeatureTest("bpf_link_uprobe_multi", func() error {
 	prog, err := ebpf.NewProgram(&ebpf.ProgramSpec{
 		Name: "probe_upm_link",
 		Type: ebpf.Kprobe,
@@ -216,4 +216,4 @@ var haveBPFLinkUprobeMulti = internal.NewFeatureTest("bpf_link_uprobe_multi", "6
 	// should not happen
 	fd.Close()
 	return errors.New("successfully attached uprobe_multi to /, kernel bug?")
-})
+}, "6.6")
diff --git a/prog.go b/prog.go
index 6a5f0fd..468beb6 100644
--- a/prog.go
+++ b/prog.go
@@ -742,7 +742,7 @@ func (p *Program) Benchmark(in []byte, repeat int, reset func()) (uint32, time.D
 	return ret, total, nil
 }
 
-var haveProgRun = internal.NewFeatureTest("BPF_PROG_RUN", "4.12", func() error {
+var haveProgRun = internal.NewFeatureTest("BPF_PROG_RUN", func() error {
 	prog, err := NewProgram(&ProgramSpec{
 		// SocketFilter does not require privileges on newer kernels.
 		Type: SocketFilter,
@@ -784,7 +784,7 @@ var haveProgRun = internal.NewFeatureTest("BPF_PROG_RUN", "4.12", func() error {
 	}
 
 	return err
-})
+}, "4.12")
 
 func (p *Program) run(opts *RunOptions) (uint32, time.Duration, error) {
 	if uint(len(opts.Data)) > math.MaxUint32 {
diff --git a/syscalls.go b/syscalls.go
index 0766e7d..9eac661 100644
--- a/syscalls.go
+++ b/syscalls.go
@@ -60,7 +60,7 @@ func progLoad(insns asm.Instructions, typ ProgramType, license string) (*sys.FD,
 	})
 }
 
-var haveNestedMaps = internal.NewFeatureTest("nested maps", "4.12", func() error {
+var haveNestedMaps = internal.NewFeatureTest("nested maps", func() error {
 	_, err := sys.MapCreate(&sys.MapCreateAttr{
 		MapType:    sys.MapType(ArrayOfMaps),
 		KeySize:    4,
@@ -76,9 +76,9 @@ var haveNestedMaps = internal.NewFeatureTest("nested maps", "4.12", func() error
 		return nil
 	}
 	return err
-})
+}, "4.12")
 
-var haveMapMutabilityModifiers = internal.NewFeatureTest("read- and write-only maps", "5.2", func() error {
+var haveMapMutabilityModifiers = internal.NewFeatureTest("read- and write-only maps", func() error {
 	// This checks BPF_F_RDONLY_PROG and BPF_F_WRONLY_PROG. Since
 	// BPF_MAP_FREEZE appeared in 5.2 as well we don't do a separate check.
 	m, err := sys.MapCreate(&sys.MapCreateAttr{
@@ -93,9 +93,9 @@ var haveMapMutabilityModifiers = internal.NewFeatureTest("read- and write-only m
 	}
 	_ = m.Close()
 	return nil
-})
+}, "5.2")
 
-var haveMmapableMaps = internal.NewFeatureTest("mmapable maps", "5.5", func() error {
+var haveMmapableMaps = internal.NewFeatureTest("mmapable maps", func() error {
 	// This checks BPF_F_MMAPABLE, which appeared in 5.5 for array maps.
 	m, err := sys.MapCreate(&sys.MapCreateAttr{
 		MapType:    sys.MapType(Array),
@@ -109,9 +109,9 @@ var haveMmapableMaps = internal.NewFeatureTest("mmapable maps", "5.5", func() er
 	}
 	_ = m.Close()
 	return nil
-})
+}, "5.5")
 
-var haveInnerMaps = internal.NewFeatureTest("inner maps", "5.10", func() error {
+var haveInnerMaps = internal.NewFeatureTest("inner maps", func() error {
 	// This checks BPF_F_INNER_MAP, which appeared in 5.10.
 	m, err := sys.MapCreate(&sys.MapCreateAttr{
 		MapType:    sys.MapType(Array),
@@ -126,9 +126,9 @@ var haveInnerMaps = internal.NewFeatureTest("inner maps", "5.10", func() error {
 	}
 	_ = m.Close()
 	return nil
-})
+}, "5.10")
 
-var haveNoPreallocMaps = internal.NewFeatureTest("prealloc maps", "4.6", func() error {
+var haveNoPreallocMaps = internal.NewFeatureTest("prealloc maps", func() error {
 	// This checks BPF_F_NO_PREALLOC, which appeared in 4.6.
 	m, err := sys.MapCreate(&sys.MapCreateAttr{
 		MapType:    sys.MapType(Hash),
@@ -143,7 +143,7 @@ var haveNoPreallocMaps = internal.NewFeatureTest("prealloc maps", "4.6", func()
 	}
 	_ = m.Close()
 	return nil
-})
+}, "4.6")
 
 func wrapMapError(err error) error {
 	if err == nil {
@@ -169,7 +169,7 @@ func wrapMapError(err error) error {
 	return err
 }
 
-var haveObjName = internal.NewFeatureTest("object names", "4.15", func() error {
+var haveObjName = internal.NewFeatureTest("object names", func() error {
 	attr := sys.MapCreateAttr{
 		MapType:    sys.MapType(Array),
 		KeySize:    4,
@@ -185,9 +185,9 @@ var haveObjName = internal.NewFeatureTest("object names", "4.15", func() error {
 
 	_ = fd.Close()
 	return nil
-})
+}, "4.15")
 
-var objNameAllowsDot = internal.NewFeatureTest("dot in object names", "5.2", func() error {
+var objNameAllowsDot = internal.NewFeatureTest("dot in object names", func() error {
 	if err := haveObjName(); err != nil {
 		return err
 	}
@@ -207,9 +207,9 @@ var objNameAllowsDot = internal.NewFeatureTest("dot in object names", "5.2", fun
 
 	_ = fd.Close()
 	return nil
-})
+}, "5.2")
 
-var haveBatchAPI = internal.NewFeatureTest("map batch api", "5.6", func() error {
+var haveBatchAPI = internal.NewFeatureTest("map batch api", func() error {
 	var maxEntries uint32 = 2
 	attr := sys.MapCreateAttr{
 		MapType:    sys.MapType(Hash),
@@ -239,9 +239,9 @@ var haveBatchAPI = internal.NewFeatureTest("map batch api", "5.6", func() error
 		return internal.ErrNotSupported
 	}
 	return nil
-})
+}, "5.6")
 
-var haveProbeReadKernel = internal.NewFeatureTest("bpf_probe_read_kernel", "5.5", func() error {
+var haveProbeReadKernel = internal.NewFeatureTest("bpf_probe_read_kernel", func() error {
 	insns := asm.Instructions{
 		asm.Mov.Reg(asm.R1, asm.R10),
 		asm.Add.Imm(asm.R1, -8),
@@ -257,9 +257,9 @@ var haveProbeReadKernel = internal.NewFeatureTest("bpf_probe_read_kernel", "5.5"
 	}
 	_ = fd.Close()
 	return nil
-})
+}, "5.5")
 
-var haveBPFToBPFCalls = internal.NewFeatureTest("bpf2bpf calls", "4.16", func() error {
+var haveBPFToBPFCalls = internal.NewFeatureTest("bpf2bpf calls", func() error {
 	insns := asm.Instructions{
 		asm.Call.Label("prog2").WithSymbol("prog1"),
 		asm.Return(),
@@ -273,9 +273,9 @@ var haveBPFToBPFCalls = internal.NewFeatureTest("bpf2bpf calls", "4.16", func()
 	}
 	_ = fd.Close()
 	return nil
-})
+}, "4.16")
 
-var haveSyscallWrapper = internal.NewFeatureTest("syscall wrapper", "4.17", func() error {
+var haveSyscallWrapper = internal.NewFeatureTest("syscall wrapper", func() error {
 	prefix := internal.PlatformPrefix()
 	if prefix == "" {
 		return fmt.Errorf("unable to find the platform prefix for (%s)", runtime.GOARCH)
@@ -302,9 +302,9 @@ var haveSyscallWrapper = internal.NewFeatureTest("syscall wrapper", "4.17", func
 	}
 
 	return evt.Close()
-})
+}, "4.17")
 
-var haveProgramExtInfos = internal.NewFeatureTest("program ext_infos", "5.0", func() error {
+var haveProgramExtInfos = internal.NewFeatureTest("program ext_infos", func() error {
 	insns := asm.Instructions{
 		asm.Mov.Imm(asm.R0, 0),
 		asm.Return(),
@@ -334,4 +334,4 @@ var haveProgramExtInfos = internal.NewFeatureTest("program ext_infos", "5.0", fu
 	}
 
 	return err
-})
+}, "5.0")


Test patch:
diff --git a/internal/feature_test.go b/internal/feature_test.go
index f477819..cd57eb4 100644
--- a/internal/feature_test.go
+++ b/internal/feature_test.go
@@ -2,9 +2,12 @@ package internal
 
 import (
 	"errors"
+	"runtime"
 	"strings"
 	"testing"
 
+	"github.com/go-quicktest/qt"
+
 	"github.com/cilium/ebpf/internal/testutils/fdtrace"
 )
 
@@ -15,27 +18,30 @@ func TestMain(m *testing.M) {
 func TestFeatureTest(t *testing.T) {
 	var called bool
 
-	fn := NewFeatureTest("foo", "1.0", func() error {
+	fn := NewFeatureTest("foo", func() error {
 		called = true
 		return nil
-	})
+	}, "1.0")
 
 	if called {
 		t.Error("Function was called too early")
 	}
 
 	err := fn()
-	if !called {
-		t.Error("Function wasn't called")
+	if errors.Is(err, ErrNotSupportedOnOS) {
+		qt.Assert(t, qt.IsFalse(called))
+		return
 	}
 
+	qt.Assert(t, qt.IsTrue(called), qt.Commentf("function should be invoked"))
+
 	if err != nil {
 		t.Error("Unexpected negative result:", err)
 	}
 
-	fn = NewFeatureTest("bar", "2.1.1", func() error {
+	fn = NewFeatureTest("bar", func() error {
 		return ErrNotSupported
-	})
+	}, "2.1.1")
 
 	err = fn()
 	if err == nil {
@@ -60,12 +66,24 @@ func TestFeatureTest(t *testing.T) {
 		t.Error("Didn't cache an error wrapping ErrNotSupported")
 	}
 
-	fn = NewFeatureTest("bar", "2.1.1", func() error {
+	fn = NewFeatureTest("bar", func() error {
 		return errors.New("foo")
-	})
+	}, "2.1.1")
 
 	err1, err2 := fn(), fn()
 	if err1 == err2 {
 		t.Error("Cached result of unsuccessful execution")
 	}
 }
+
+func TestFeatureTestNotSupportedOnOS(t *testing.T) {
+	sentinel := errors.New("quux")
+	fn := func() error { return sentinel }
+
+	qt.Assert(t, qt.IsNotNil(NewFeatureTest("foo", fn)()))
+	qt.Assert(t, qt.ErrorIs(NewFeatureTest("foo", fn, "froz:1.0.0")(), ErrNotSupportedOnOS))
+	qt.Assert(t, qt.ErrorIs(NewFeatureTest("foo", fn, runtime.GOOS+":1.0")(), sentinel))
+	if runtime.GOOS == "linux" {
+		qt.Assert(t, qt.ErrorIs(NewFeatureTest("foo", fn, "1.0")(), sentinel))
+	}
+}
diff --git a/internal/testutils/feature.go b/internal/testutils/feature.go
index 75edcd1..e3da039 100644
--- a/internal/testutils/feature.go
+++ b/internal/testutils/feature.go
@@ -11,7 +11,7 @@ import (
 )
 
 const (
-	ignoreKernelVersionEnvVar = "EBPF_TEST_IGNORE_KERNEL_VERSION"
+	ignoreVersionEnvVar = "EBPF_TEST_IGNORE_VERSION"
 )
 
 func CheckFeatureTest(t *testing.T, fn func() error) {
@@ -25,7 +25,7 @@ func checkFeatureTestError(t *testing.T, err error) {
 
 	var ufe *internal.UnsupportedFeatureError
 	if errors.As(err, &ufe) {
-		checkKernelVersion(t, ufe)
+		checkVersion(t, ufe)
 	} else {
 		t.Error("Feature test failed:", err)
 	}
@@ -50,7 +50,7 @@ func SkipIfNotSupported(tb testing.TB, err error) {
 
 	var ufe *internal.UnsupportedFeatureError
 	if errors.As(err, &ufe) {
-		checkKernelVersion(tb, ufe)
+		checkVersion(tb, ufe)
 		tb.Skip(ufe.Error())
 	}
 	if errors.Is(err, internal.ErrNotSupported) {
@@ -58,15 +58,28 @@ func SkipIfNotSupported(tb testing.TB, err error) {
 	}
 }
 
-func checkKernelVersion(tb testing.TB, ufe *internal.UnsupportedFeatureError) {
+func SkipIfNotSupportedOnOS(tb testing.TB, err error) {
+	tb.Helper()
+
+	if err == internal.ErrNotSupportedOnOS {
+		tb.Fatal("Unwrapped ErrNotSupportedOnOS")
+	}
+
+	if errors.Is(err, internal.ErrNotSupportedOnOS) {
+		tb.Skip(err.Error())
+	}
+}
+
+func checkVersion(tb testing.TB, ufe *internal.UnsupportedFeatureError) {
 	if ufe.MinimumVersion.Unspecified() {
 		return
 	}
 
 	tb.Helper()
 
-	if ignoreKernelVersionCheck(tb.Name()) {
-		tb.Skipf("Ignoring error due to %s: %s", ignoreKernelVersionEnvVar, ufe.Error())
+	if ignoreVersionCheck(tb.Name()) {
+		tb.Logf("Ignoring error due to %s: %s", ignoreVersionEnvVar, ufe.Error())
+		return
 	}
 
 	if !isKernelLessThan(tb, ufe.MinimumVersion) {
@@ -121,12 +134,15 @@ func kernelVersion(tb testing.TB) internal.Version {
 	return v
 }
 
-// ignoreKernelVersionCheck checks if test name should be ignored for kernel version check by checking against environment var EBPF_TEST_IGNORE_KERNEL_VERSION.
-// EBPF_TEST_IGNORE_KERNEL_VERSION is a comma (,) separated list of test names for which kernel version check should be ignored.
+// ignoreVersionCheck checks whether to omit the version check for a test.
+//
+// It reads a comma separated list of test names from an environment variable.
+//
+// For example:
 //
-// eg: EBPF_TEST_IGNORE_KERNEL_VERSION=TestABC,TestXYZ
-func ignoreKernelVersionCheck(tName string) bool {
-	tNames := os.Getenv(ignoreKernelVersionEnvVar)
+//	EBPF_TEST_IGNORE_VERSION=TestABC,TestXYZ go test ...
+func ignoreVersionCheck(tName string) bool {
+	tNames := os.Getenv(ignoreVersionEnvVar)
 	if tNames == "" {
 		return false
 	}
diff --git a/internal/testutils/feature_test.go b/internal/testutils/feature_test.go
index 71432cc..d2e0a6d 100644
--- a/internal/testutils/feature_test.go
+++ b/internal/testutils/feature_test.go
@@ -44,9 +44,9 @@ func TestIgnoreKernelVersionCheckWhenEnvVarIsSet(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			t.Setenv(ignoreKernelVersionEnvVar, tt.toIgnoreNamesEnvValue)
+			t.Setenv(ignoreVersionEnvVar, tt.toIgnoreNamesEnvValue)
 
-			if got := ignoreKernelVersionCheck(tt.testName); got != tt.ignoreKernelVersionCheck {
+			if got := ignoreVersionCheck(tt.testName); got != tt.ignoreKernelVersionCheck {
 				t.Errorf("ignoreKernelVersionCheck() = %v, want %v", got, tt.ignoreKernelVersionCheck)
 			}
 		})