Repository: kubernetes-sigs/node-feature-discovery
Pull request #604 (2021-09-21 13:48:42)

source: implement FeatureSource interface
Convert the `cpu`, `kernel`, `pci`, `usb`, `system`, `custom` and `local` sources to implement the `FeatureSource` interface, i.e. do feature discovery and creation of feature labels separately.

Changes the related custom rules to utilize `GetFeatures()` method of the feature sources.

Also moves different "utils" from `source/internal` to the respective sources.

This PR is split from #464, to make review easier. It demonstrates usage of the new `FeatureSource` interface with some of the sources. Other sources will be converted in future PRs.

k8s-ci-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/node-feature-discovery/pull/604#" title="Author self-approved">marquiz</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fnode-feature-discovery).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/kubernetes-sigs/node-feature-discovery/blob/master/OWNERS)~~ [marquiz]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
k8s-ci-robot: @marquiz: The following test **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-node-feature-discovery-verify | f5b245c24599b758a03f2a26bed1386d011a9c4c | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/kubernetes-sigs_node-feature-discovery/604/pull-node-feature-discovery-verify/1440312033974161408) | true | `/test pull-node-feature-discovery-verify`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=node-feature-discovery&pr=604). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Amarquiz). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-sigs/node-feature-discovery/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
marquiz: Rebased. Depends on #642 
/hold
marquiz: /unhold
marquiz: /assign @zvonkok @ArangoGutierrez 
zvonkok: @marquiz This fails for me: 

```
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0xc3c369]

goroutine 1 [running]:
sigs.k8s.io/node-feature-discovery/source/kernel.(*kernelSource).GetLabels(0x157b220)
	/go/node-feature-discovery/source/kernel/kernel.go:108 +0x269
sigs.k8s.io/node-feature-discovery/pkg/nfd-client/worker.getFeatureLabels({0x7ff284548b88, 0x157b220}, {{0x0, 0x0}, 0xc0002e0c60, 0x0, 0x0, 0x15555, {0xc000276fa0, 0x1, ...}, ...})
	/go/node-feature-discovery/pkg/nfd-client/worker/nfd-worker.go:427 +0xb0
sigs.k8s.io/node-feature-discovery/pkg/nfd-client/worker.createFeatureLabels({0xc0001e73f0, 0xb, 0x203000}, {{0x0, 0x0}, 0xc0002e0c60, 0x0, 0x0, 0x15555, {0xc000276fa0, ...}, ...})
	/go/node-feature-discovery/pkg/nfd-client/worker/nfd-worker.go:408 +0x1d9
sigs.k8s.io/node-feature-discovery/pkg/nfd-client/worker.(*nfdWorker).Run(0xc0001a5ce0)
	/go/node-feature-discovery/pkg/nfd-client/worker/nfd-worker.go:187 +0xbb8
main.main()
	/go/node-feature-discovery/cmd/nfd-worker/main.go:63 +0x371
````

zvonkok: I think this comes from line 139 in kernel.go. When SELinux is not enabled there is no entry added. 
marquiz: > I think this comes from line 139 in kernel.go. When SELinux is not enabled there is no entry added.

Argh, my latest change (#642) causes these failures (selinux is not the only possible point of panic 😬 ). I'll fix and add unit tests for this. Thanks for testing @zvonkok!


Patch:
diff --git a/pkg/api/feature/feature.go b/pkg/api/feature/feature.go
index 2c6619e18..d90339e22 100644
--- a/pkg/api/feature/feature.go
+++ b/pkg/api/feature/feature.go
@@ -20,28 +20,28 @@ package feature
 // features to empty values
 func NewDomainFeatures() *DomainFeatures {
 	return &DomainFeatures{
-		Keys:      make(map[string]*KeyFeatureSet),
-		Values:    make(map[string]*ValueFeatureSet),
-		Instances: make(map[string]*InstanceFeatureSet)}
+		Keys:      make(map[string]KeyFeatureSet),
+		Values:    make(map[string]ValueFeatureSet),
+		Instances: make(map[string]InstanceFeatureSet)}
 }
 
-func NewKeyFeatures(keys ...string) *KeyFeatureSet {
+func NewKeyFeatures(keys ...string) KeyFeatureSet {
 	e := make(map[string]Nil, len(keys))
 	for _, k := range keys {
 		e[k] = Nil{}
 	}
-	return &KeyFeatureSet{Elements: e}
+	return KeyFeatureSet{Elements: e}
 }
 
-func NewValueFeatures(values map[string]string) *ValueFeatureSet {
+func NewValueFeatures(values map[string]string) ValueFeatureSet {
 	if values == nil {
 		values = make(map[string]string)
 	}
-	return &ValueFeatureSet{Elements: values}
+	return ValueFeatureSet{Elements: values}
 }
 
-func NewInstanceFeatures(instances []InstanceFeature) *InstanceFeatureSet {
-	return &InstanceFeatureSet{Elements: instances}
+func NewInstanceFeatures(instances []InstanceFeature) InstanceFeatureSet {
+	return InstanceFeatureSet{Elements: instances}
 }
 
 func NewInstanceFeature(attrs map[string]string) *InstanceFeature {
diff --git a/pkg/api/feature/types.go b/pkg/api/feature/types.go
index c514a85ad..dbcf6655a 100644
--- a/pkg/api/feature/types.go
+++ b/pkg/api/feature/types.go
@@ -22,9 +22,9 @@ type Features map[string]*DomainFeatures
 
 // DomainFeatures is the collection of all discovered features of one domain.
 type DomainFeatures struct {
-	Keys      map[string]*KeyFeatureSet      `protobuf:"bytes,1,rep,name=keys"`
-	Values    map[string]*ValueFeatureSet    `protobuf:"bytes,2,rep,name=values"`
-	Instances map[string]*InstanceFeatureSet `protobuf:"bytes,3,rep,name=instances"`
+	Keys      map[string]KeyFeatureSet      `protobuf:"bytes,1,rep,name=keys"`
+	Values    map[string]ValueFeatureSet    `protobuf:"bytes,2,rep,name=values"`
+	Instances map[string]InstanceFeatureSet `protobuf:"bytes,3,rep,name=instances"`
 }
 
 // KeyFeatureSet is a set of simple features only containing names without values.
diff --git a/source/cpu/cpu.go b/source/cpu/cpu.go
index 8fa65f373..ebd2322e8 100644
--- a/source/cpu/cpu.go
+++ b/source/cpu/cpu.go
@@ -18,15 +18,26 @@ package cpu
 
 import (
 	"io/ioutil"
+	"strconv"
 
 	"k8s.io/klog/v2"
 
+	"sigs.k8s.io/node-feature-discovery/pkg/api/feature"
+	"sigs.k8s.io/node-feature-discovery/pkg/utils"
 	"sigs.k8s.io/node-feature-discovery/source"
-	"sigs.k8s.io/node-feature-discovery/source/internal/cpuidutils"
 )
 
 const Name = "cpu"
 
+const (
+	CpuidFeature    = "cpuid"
+	CstateFeature   = "cstate"
+	PstateFeature   = "pstate"
+	RdtFeature      = "rdt"
+	SstFeature      = "sst"
+	TopologyFeature = "topology"
+)
+
 // Configuration file options
 type cpuidConfig struct {
 	AttributeBlacklist []string `json:"attributeBlacklist,omitempty"`
@@ -78,15 +89,17 @@ type keyFilter struct {
 	whitelist bool
 }
 
-// cpuSource implements the LabelSource and ConfigurableSource interfaces.
+// cpuSource implements the FeatureSource, LabelSource and ConfigurableSource interfaces.
 type cpuSource struct {
 	config      *Config
 	cpuidFilter *keyFilter
+	features    *feature.DomainFeatures
 }
 
 // Singleton source instance
 var (
-	src cpuSource
+	src                           = cpuSource{config: newDefaultConfig(), cpuidFilter: &keyFilter{}}
+	_   source.FeatureSource      = &src
 	_   source.LabelSource        = &src
 	_   source.ConfigurableSource = &src
 )
@@ -115,57 +128,98 @@ func (s *cpuSource) Priority() int { return 0 }
 
 // GetLabels method of the LabelSource interface
 func (s *cpuSource) GetLabels() (source.FeatureLabels, error) {
-	features := source.FeatureLabels{}
+	labels := source.FeatureLabels{}
+	features := s.GetFeatures()
 
-	// Check if hyper-threading seems to be enabled
-	found, err := haveThreadSiblings()
-	if err != nil {
-		klog.Errorf("failed to detect hyper-threading: %v", err)
-	} else if found {
-		features["hardware_multithreading"] = true
+	// CPUID
+	for f := range features.Keys[CpuidFeature].Elements {
+		if s.cpuidFilter.unmask(f) {
+			labels["cpuid."+f] = true
+		}
 	}
 
-	// Check SST-BF
-	found, err = discoverSSTBF()
-	if err != nil {
-		klog.Errorf("failed to detect SST-BF: %v", err)
-	} else if found {
-		features["power.sst_bf.enabled"] = true
+	// Cstate
+	for k, v := range features.Values[CstateFeature].Elements {
+		labels["cstate."+k] = v
+	}
+
+	// Pstate
+	for k, v := range features.Values[PstateFeature].Elements {
+		labels["pstate."+k] = v
+	}
+
+	// RDT
+	for k := range features.Keys[RdtFeature].Elements {
+		labels["rdt."+k] = true
+	}
+
+	// SST
+	for k, v := range features.Values[SstFeature].Elements {
+		labels["power.sst_"+k] = v
+	}
+
+	// Hyperthreading
+	if v, ok := features.Values[TopologyFeature].Elements["hardware_multithreading"]; ok {
+		labels["hardware_multithreading"] = v
 	}
 
+	return labels, nil
+}
+
+// Discover method of the FeatureSource Interface
+func (s *cpuSource) Discover() error {
+	s.features = feature.NewDomainFeatures()
+
 	// Detect CPUID
-	cpuidFlags := cpuidutils.GetCpuidFlags()
-	for _, f := range cpuidFlags {
-		if s.cpuidFilter.unmask(f) {
-			features["cpuid."+f] = true
-		}
+	s.features.Keys[CpuidFeature] = feature.NewKeyFeatures(getCpuidFlags()...)
+
+	// Detect cstate configuration
+	cstate, err := detectCstate()
+	if err != nil {
+		klog.Errorf("failed to detect cstate: %v", err)
+	} else {
+		s.features.Values[CstateFeature] = feature.NewValueFeatures(cstate)
 	}
 
 	// Detect pstate features
 	pstate, err := detectPstate()
 	if err != nil {
 		klog.Error(err)
-	} else {
-		for k, v := range pstate {
-			features["pstate."+k] = v
-		}
 	}
+	s.features.Values[PstateFeature] = feature.NewValueFeatures(pstate)
 
 	// Detect RDT features
-	rdt := discoverRDT()
-	for _, f := range rdt {
-		features["rdt."+f] = true
+	s.features.Keys[RdtFeature] = feature.NewKeyFeatures(discoverRDT()...)
+
+	// Detect SST features
+	s.features.Values[SstFeature] = feature.NewValueFeatures(discoverSST())
+
+	// Detect hyper-threading
+	s.features.Values[TopologyFeature] = feature.NewValueFeatures(discoverTopology())
+
+	utils.KlogDump(3, "discovered cpu features:", "  ", s.features)
+
+	return nil
+}
+
+// GetFeatures method of the FeatureSource Interface
+func (s *cpuSource) GetFeatures() *feature.DomainFeatures {
+	if s.features == nil {
+		s.features = feature.NewDomainFeatures()
 	}
+	return s.features
+}
 
-	// Detect cstate configuration
-	cstate, ok, err := detectCstate()
-	if err != nil {
-		klog.Error(err)
-	} else if ok {
-		features["cstate.enabled"] = cstate
+func discoverTopology() map[string]string {
+	features := make(map[string]string)
+
+	if ht, err := haveThreadSiblings(); err != nil {
+		klog.Errorf("failed to detect hyper-threading: %v", err)
+	} else {
+		features["hardware_multithreading"] = strconv.FormatBool(ht)
 	}
 
-	return features, nil
+	return features
 }
 
 // Check if any (online) CPUs have thread siblings
diff --git a/source/internal/kernelutils/kernel_version.go b/source/cpu/cpu_test.go
similarity index 58%
rename from source/internal/kernelutils/kernel_version.go
rename to source/cpu/cpu_test.go
index 2da110e0d..cfb6384c6 100644
--- a/source/internal/kernelutils/kernel_version.go
+++ b/source/cpu/cpu_test.go
@@ -1,5 +1,5 @@
 /*
-Copyright 2018-2020 The Kubernetes Authors.
+Copyright 2021 The Kubernetes Authors.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -14,17 +14,23 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package kernelutils
+package cpu
 
 import (
-	"io/ioutil"
-	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"sigs.k8s.io/node-feature-discovery/pkg/api/feature"
 )
 
-func GetKernelVersion() (string, error) {
-	unameRaw, err := ioutil.ReadFile("/proc/sys/kernel/osrelease")
-	if err != nil {
-		return "", err
-	}
-	return strings.TrimSpace(string(unameRaw)), nil
+func TestCpuSource(t *testing.T) {
+	assert.Equal(t, src.Name(), Name)
+
+	// Check that GetLabels works with empty features
+	src.features = feature.NewDomainFeatures()
+	l, err := src.GetLabels()
+
+	assert.Nil(t, err, err)
+	assert.Empty(t, l)
+
 }
diff --git a/source/internal/cpuidutils/cpuid_amd64.go b/source/cpu/cpuid_amd64.go
similarity index 85%
rename from source/internal/cpuidutils/cpuid_amd64.go
rename to source/cpu/cpuid_amd64.go
index 49d1b6dd7..cdc635e72 100644
--- a/source/internal/cpuidutils/cpuid_amd64.go
+++ b/source/cpu/cpuid_amd64.go
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package cpuidutils
+package cpu
 
 import (
 	"github.com/klauspost/cpuid/v2"
 )
 
-// GetCpuidFlags returns feature names for all the supported CPU features.
-func GetCpuidFlags() []string {
+// getCpuidFlags returns feature names for all the supported CPU features.
+func getCpuidFlags() []string {
 	return cpuid.CPU.FeatureSet()
 }
diff --git a/source/internal/cpuidutils/cpuid_arm.go b/source/cpu/cpuid_arm.go
similarity index 98%
rename from source/internal/cpuidutils/cpuid_arm.go
rename to source/cpu/cpuid_arm.go
index bc9a6632d..6a142e913 100644
--- a/source/internal/cpuidutils/cpuid_arm.go
+++ b/source/cpu/cpuid_arm.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package cpuidutils
+package cpu
 
 /*
 #include <sys/auxv.h>
@@ -90,7 +90,7 @@ var flagNames_arm = map[uint64]string{
 	CPU_ARM_FEATURE_CRC32:    "CRC32",
 }
 
-func GetCpuidFlags() []string {
+func getCpuidFlags() []string {
 	r := make([]string, 0, 20)
 	hwcap := uint64(C.gethwcap())
 	for i := uint(0); i < 64; i++ {
diff --git a/source/internal/cpuidutils/cpuid_arm64.go b/source/cpu/cpuid_arm64.go
similarity index 98%
rename from source/internal/cpuidutils/cpuid_arm64.go
rename to source/cpu/cpuid_arm64.go
index 0d307b82a..e4175ae49 100644
--- a/source/internal/cpuidutils/cpuid_arm64.go
+++ b/source/cpu/cpuid_arm64.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package cpuidutils
+package cpu
 
 /*
 #include <sys/auxv.h>
@@ -80,7 +80,7 @@ var flagNames_arm64 = map[uint64]string{
 	CPU_ARM64_FEATURE_SVE:      "SVE",
 }
 
-func GetCpuidFlags() []string {
+func getCpuidFlags() []string {
 	r := make([]string, 0, 20)
 	hwcap := uint64(C.gethwcap())
 	for i := uint(0); i < 64; i++ {
diff --git a/source/internal/cpuidutils/cpuid_ppc64le.go b/source/cpu/cpuid_ppc64le.go
similarity index 99%
rename from source/internal/cpuidutils/cpuid_ppc64le.go
rename to source/cpu/cpuid_ppc64le.go
index d56152a22..307739717 100644
--- a/source/internal/cpuidutils/cpuid_ppc64le.go
+++ b/source/cpu/cpuid_ppc64le.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package cpuidutils
+package cpu
 
 /*
 #include <sys/auxv.h>
@@ -126,7 +126,7 @@ var flag2Names_ppc64le = map[uint64]string{
 	PPC_FEATURE2_HTM_NO_SUSPEND: "HTM-NO-SUSPEND",
 }
 
-func GetCpuidFlags() []string {
+func getCpuidFlags() []string {
 	r := make([]string, 0, 30)
 	hwcap := uint64(C.gethwcap())
 	hwcap2 := uint64(C.gethwcap2())
diff --git a/source/internal/cpuidutils/cpuid_s390x.go b/source/cpu/cpuid_s390x.go
similarity index 97%
rename from source/internal/cpuidutils/cpuid_s390x.go
rename to source/cpu/cpuid_s390x.go
index 6a4a02ea8..fbd47098d 100644
--- a/source/internal/cpuidutils/cpuid_s390x.go
+++ b/source/cpu/cpuid_s390x.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package cpuidutils
+package cpu
 
 /*
 #include <sys/auxv.h>
@@ -71,7 +71,7 @@ var flagNames_s390x = map[uint64]string{
 	HWCAP_S390_DFLT:      "DFLT",
 }
 
-func GetCpuidFlags() []string {
+func getCpuidFlags() []string {
 	r := make([]string, 0, 20)
 	hwcap := uint64(C.gethwcap())
 	for i := uint(0); i < 64; i++ {
diff --git a/source/cpu/cstate_amd64.go b/source/cpu/cstate_amd64.go
index 10e475a0f..edd00d3b2 100644
--- a/source/cpu/cstate_amd64.go
+++ b/source/cpu/cstate_amd64.go
@@ -30,38 +30,42 @@ import (
 )
 
 // Discover if c-states are enabled
-func detectCstate() (bool, bool, error) {
+func detectCstate() (map[string]string, error) {
+	cstate := make(map[string]string)
+
 	// Check that sysfs is available
 	sysfsBase := source.SysfsDir.Path("devices/system/cpu")
 	if _, err := os.Stat(sysfsBase); err != nil {
-		return false, false, fmt.Errorf("unable to detect cstate status: %w", err)
+		return cstate, fmt.Errorf("unable to detect cstate status: %w", err)
 	}
 	cpuidleDir := filepath.Join(sysfsBase, "cpuidle")
 	if _, err := os.Stat(cpuidleDir); os.IsNotExist(err) {
 		klog.V(1).Info("cpuidle disabled in the kernel")
-		return false, false, nil
+		return cstate, nil
 	}
 
 	// When the intel_idle driver is in use (default), check setting of max_cstates
 	driver, err := ioutil.ReadFile(filepath.Join(cpuidleDir, "current_driver"))
 	if err != nil {
-		return false, false, fmt.Errorf("cannot get driver for cpuidle: %w", err)
+		return cstate, fmt.Errorf("cannot get driver for cpuidle: %w", err)
 	}
 
 	if d := strings.TrimSpace(string(driver)); d != "intel_idle" {
 		// Currently only checking intel_idle driver for cstates
 		klog.V(1).Infof("intel_idle driver is not in use (%s is active)", d)
-		return false, false, nil
+		return cstate, nil
 	}
 
 	data, err := ioutil.ReadFile(source.SysfsDir.Path("module/intel_idle/parameters/max_cstate"))
 	if err != nil {
-		return false, false, fmt.Errorf("cannot determine cstate from max_cstates: %w", err)
+		return cstate, fmt.Errorf("cannot determine cstate from max_cstates: %w", err)
 	}
 	cstates, err := strconv.Atoi(strings.TrimSpace(string(data)))
 	if err != nil {
-		return false, false, fmt.Errorf("non-integer value of cstates: %w", err)
+		return cstate, fmt.Errorf("non-integer value of cstates: %w", err)
+	} else {
+		cstate["enabled"] = strconv.FormatBool(cstates > 0)
 	}
 
-	return cstates > 0, true, nil
+	return cstate, nil
 }
diff --git a/source/cpu/cstate_stub.go b/source/cpu/cstate_stub.go
index 7c6e4dce5..71fdf8dc0 100644
--- a/source/cpu/cstate_stub.go
+++ b/source/cpu/cstate_stub.go
@@ -20,6 +20,6 @@ limitations under the License.
 package cpu
 
 // Discover if c-states are enabled
-func detectCstate() (bool, bool, error) {
-	return false, false, nil
+func detectCstate() (map[string]string, error) {
+	return nil, nil
 }
diff --git a/source/cpu/power_amd64.go b/source/cpu/power_amd64.go
index 94005fc9f..16bd5a82f 100644
--- a/source/cpu/power_amd64.go
+++ b/source/cpu/power_amd64.go
@@ -23,6 +23,8 @@ import (
 	"strconv"
 	"strings"
 
+	"k8s.io/klog/v2"
+
 	"sigs.k8s.io/node-feature-discovery/pkg/cpuid"
 	"sigs.k8s.io/node-feature-discovery/source"
 )
@@ -32,6 +34,18 @@ const (
 	LEAF_PROCESSOR_FREQUENCY_INFORMATION = 0x16
 )
 
+func discoverSST() map[string]string {
+	features := make(map[string]string)
+
+	if bf, err := discoverSSTBF(); err != nil {
+		klog.Errorf("failed to detect SST-BF: %v", err)
+	} else if bf {
+		features["bf.enabled"] = strconv.FormatBool(bf)
+	}
+
+	return features
+}
+
 func discoverSSTBF() (bool, error) {
 	// Get processor's "nominal base frequency" (in MHz) from CPUID
 	freqInfo := cpuid.Cpuid(LEAF_PROCESSOR_FREQUENCY_INFORMATION, 0)
diff --git a/source/custom/custom.go b/source/custom/custom.go
index fdaf76269..33abb7c7f 100644
--- a/source/custom/custom.go
+++ b/source/custom/custom.go
@@ -58,7 +58,7 @@ type customSource struct {
 
 // Singleton source instance
 var (
-	src customSource
+	src                           = customSource{config: newDefaultConfig()}
 	_   source.LabelSource        = &src
 	_   source.ConfigurableSource = &src
 )
diff --git a/source/custom/rules/cpuid_rule.go b/source/custom/rules/cpuid_rule.go
index c7a070887..40117e965 100644
--- a/source/custom/rules/cpuid_rule.go
+++ b/source/custom/rules/cpuid_rule.go
@@ -1,5 +1,5 @@
 /*
-Copyright 2020 The Kubernetes Authors.
+Copyright 2020-2021 The Kubernetes Authors.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -17,26 +17,25 @@ limitations under the License.
 package rules
 
 import (
-	"sigs.k8s.io/node-feature-discovery/source/internal/cpuidutils"
+	"fmt"
+
+	"sigs.k8s.io/node-feature-discovery/source"
+	"sigs.k8s.io/node-feature-discovery/source/cpu"
 )
 
-// CpuIDRule implements Rule
+// CpuIDRule implements Rule for the custom source
 type CpuIDRule []string
 
-var cpuIdFlags map[string]struct{}
-
 func (cpuids *CpuIDRule) Match() (bool, error) {
+	flags, ok := source.GetFeatureSource("cpu").GetFeatures().Keys[cpu.CpuidFeature]
+	if !ok {
+		return false, fmt.Errorf("cpuid information not available")
+	}
+
 	for _, f := range *cpuids {
-		if _, ok := cpuIdFlags[f]; !ok {
+		if _, ok := flags.Elements[f]; !ok {
 			return false, nil
 		}
 	}
 	return true, nil
 }
-
-func init() {
-	cpuIdFlags = make(map[string]struct{})
-	for _, f := range cpuidutils.GetCpuidFlags() {
-		cpuIdFlags[f] = struct{}{}
-	}
-}
diff --git a/source/custom/rules/kconfig_rule.go b/source/custom/rules/kconfig_rule.go
index 9ef65c480..d7cd3d9ce 100644
--- a/source/custom/rules/kconfig_rule.go
+++ b/source/custom/rules/kconfig_rule.go
@@ -18,9 +18,11 @@ package rules
 
 import (
 	"encoding/json"
+	"fmt"
 	"strings"
 
-	"sigs.k8s.io/node-feature-discovery/source/internal/kernelutils"
+	"sigs.k8s.io/node-feature-discovery/source"
+	"sigs.k8s.io/node-feature-discovery/source/kernel"
 )
 
 // KconfigRule implements Rule
@@ -31,11 +33,14 @@ type kconfig struct {
 	Value string
 }
 
-var kConfigs map[string]string
-
 func (kconfigs *KconfigRule) Match() (bool, error) {
+	options, ok := source.GetFeatureSource("kernel").GetFeatures().Values[kernel.ConfigFeature]
+	if !ok {
+		return false, fmt.Errorf("kernel config options not available")
+	}
+
 	for _, f := range *kconfigs {
-		if v, ok := kConfigs[f.Name]; !ok || f.Value != v {
+		if v, ok := options.Elements[f.Name]; !ok || f.Value != v {
 			return false, nil
 		}
 	}
@@ -57,14 +62,3 @@ func (c *kconfig) UnmarshalJSON(data []byte) error {
 	}
 	return nil
 }
-
-func init() {
-	kConfigs = make(map[string]string)
-
-	kconfig, err := kernelutils.ParseKconfig("")
-	if err == nil {
-		for k, v := range kconfig {
-			kConfigs[k] = v
-		}
-	}
-}
diff --git a/source/custom/rules/loaded_kmod_rule.go b/source/custom/rules/loaded_kmod_rule.go
index f2a9c84d2..1fdb546f8 100644
--- a/source/custom/rules/loaded_kmod_rule.go
+++ b/source/custom/rules/loaded_kmod_rule.go
@@ -1,5 +1,5 @@
 /*
-Copyright 2020 The Kubernetes Authors.
+Copyright 2020-2021 The Kubernetes Authors.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -18,44 +18,26 @@ package rules
 
 import (
 	"fmt"
-	"io/ioutil"
-	"strings"
+
+	"sigs.k8s.io/node-feature-discovery/source"
+	"sigs.k8s.io/node-feature-discovery/source/kernel"
 )
 
 // LoadedKModRule matches loaded kernel modules in the system
 type LoadedKModRule []string
 
-const kmodProcfsPath = "/proc/modules"
-
 // Match loaded kernel modules on provided list of kernel modules
 func (kmods *LoadedKModRule) Match() (bool, error) {
-	loadedModules, err := kmods.getLoadedModules()
-	if err != nil {
-		return false, fmt.Errorf("failed to get loaded kernel modules. %s", err.Error())
+	modules, ok := source.GetFeatureSource("kernel").GetFeatures().Keys[kernel.LoadedModuleFeature]
+	if !ok {
+		return false, fmt.Errorf("info about loaded modules not available")
 	}
+
 	for _, kmod := range *kmods {
-		if _, ok := loadedModules[kmod]; !ok {
+		if _, ok := modules.Elements[kmod]; !ok {
 			// kernel module not loaded
 			return false, nil
 		}
 	}
 	return true, nil
 }
-
-func (kmods *LoadedKModRule) getLoadedModules() (map[string]struct{}, error) {
-	out, err := ioutil.ReadFile(kmodProcfsPath)
-	if err != nil {
-		return nil, fmt.Errorf("failed to read file %s: %s", kmodProcfsPath, err.Error())
-	}
-
-	loadedMods := make(map[string]struct{})
-	for _, line := range strings.Split(string(out), "\n") {
-		// skip empty lines
-		if len(line) == 0 {
-			continue
-		}
-		// append loaded module
-		loadedMods[strings.Fields(line)[0]] = struct{}{}
-	}
-	return loadedMods, nil
-}
diff --git a/source/custom/rules/nodename_rule.go b/source/custom/rules/nodename_rule.go
index 02199a095..f1a9af18a 100644
--- a/source/custom/rules/nodename_rule.go
+++ b/source/custom/rules/nodename_rule.go
@@ -17,14 +17,13 @@ limitations under the License.
 package rules
 
 import (
-	"os"
+	"fmt"
 	"regexp"
 
 	"k8s.io/klog/v2"
-)
 
-var (
-	nodeName = os.Getenv("NODE_NAME")
+	"sigs.k8s.io/node-feature-discovery/source"
+	"sigs.k8s.io/node-feature-discovery/source/system"
 )
 
 // NodenameRule matches on nodenames configured in a ConfigMap
@@ -34,6 +33,11 @@ type NodenameRule []string
 var _ Rule = NodenameRule{}
 
 func (n NodenameRule) Match() (bool, error) {
+	nodeName, ok := source.GetFeatureSource("system").GetFeatures().Values[system.NameFeature].Elements["nodename"]
+	if !ok {
+		return false, fmt.Errorf("node name not available")
+	}
+
 	for _, nodenamePattern := range n {
 		klog.V(1).Infof("matchNodename %s", nodenamePattern)
 		match, err := regexp.MatchString(nodenamePattern, nodeName)
diff --git a/source/custom/rules/pci_id_rule.go b/source/custom/rules/pci_id_rule.go
index 46496b42b..4ce7e97e4 100644
--- a/source/custom/rules/pci_id_rule.go
+++ b/source/custom/rules/pci_id_rule.go
@@ -1,5 +1,5 @@
 /*
-Copyright 2020 The Kubernetes Authors.
+Copyright 2020-2021 The Kubernetes Authors.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -18,7 +18,10 @@ package rules
 
 import (
 	"fmt"
-	pciutils "sigs.k8s.io/node-feature-discovery/source/internal"
+
+	"sigs.k8s.io/node-feature-discovery/pkg/api/feature"
+	"sigs.k8s.io/node-feature-discovery/source"
+	"sigs.k8s.io/node-feature-discovery/source/pci"
 )
 
 // Rule that matches on the following PCI device attributes: <class, vendor, device>
@@ -37,40 +40,40 @@ type PciIDRule struct {
 
 // Match PCI devices on provided PCI device attributes
 func (r *PciIDRule) Match() (bool, error) {
+	devs, ok := source.GetFeatureSource("pci").GetFeatures().Instances[pci.DeviceFeature]
+	if !ok {
+		return false, fmt.Errorf("cpuid information not available")
+	}
+
 	devAttr := map[string]bool{}
 	for _, attr := range []string{"class", "vendor", "device"} {
 		devAttr[attr] = true
 	}
-	allDevs, err := pciutils.DetectPci(devAttr)
-	if err != nil {
-		return false, fmt.Errorf("failed to detect PCI devices: %s", err.Error())
-	}
 
-	for _, classDevs := range allDevs {
-		for _, dev := range classDevs {
-			// match rule on a single device
-			if r.matchDevOnRule(dev) {
-				return true, nil
-			}
+	for _, dev := range devs.Elements {
+		// match rule on a single device
+		if r.matchDevOnRule(dev) {
+			return true, nil
 		}
 	}
 	return false, nil
 }
 
-func (r *PciIDRule) matchDevOnRule(dev pciutils.PciDeviceInfo) bool {
+func (r *PciIDRule) matchDevOnRule(dev feature.InstanceFeature) bool {
 	if len(r.Class) == 0 && len(r.Vendor) == 0 && len(r.Device) == 0 {
 		return false
 	}
 
-	if len(r.Class) > 0 && !in(dev["class"], r.Class) {
+	attrs := dev.Attributes
+	if len(r.Class) > 0 && !in(attrs["class"], r.Class) {
 		return false
 	}
 
-	if len(r.Vendor) > 0 && !in(dev["vendor"], r.Vendor) {
+	if len(r.Vendor) > 0 && !in(attrs["vendor"], r.Vendor) {
 		return false
 	}
 
-	if len(r.Device) > 0 && !in(dev["device"], r.Device) {
+	if len(r.Device) > 0 && !in(attrs["device"], r.Device) {
 		return false
 	}
 
diff --git a/source/custom/rules/usb_id_rule.go b/source/custom/rules/usb_id_rule.go
index 104ec594e..058ec357b 100644
--- a/source/custom/rules/usb_id_rule.go
+++ b/source/custom/rules/usb_id_rule.go
@@ -18,7 +18,10 @@ package rules
 
 import (
 	"fmt"
-	usbutils "sigs.k8s.io/node-feature-discovery/source/internal"
+
+	"sigs.k8s.io/node-feature-discovery/pkg/api/feature"
+	"sigs.k8s.io/node-feature-discovery/source"
+	"sigs.k8s.io/node-feature-discovery/source/usb"
 )
 
 // Rule that matches on the following USB device attributes: <class, vendor, device>
@@ -38,44 +41,39 @@ type UsbIDRule struct {
 
 // Match USB devices on provided USB device attributes
 func (r *UsbIDRule) Match() (bool, error) {
-	devAttr := map[string]bool{}
-	for _, attr := range []string{"class", "vendor", "device", "serial"} {
-		devAttr[attr] = true
-	}
-	allDevs, err := usbutils.DetectUsb(devAttr)
-	if err != nil {
-		return false, fmt.Errorf("failed to detect USB devices: %s", err.Error())
+	devs, ok := source.GetFeatureSource("usb").GetFeatures().Instances[usb.DeviceFeature]
+	if !ok {
+		return false, fmt.Errorf("usb device information not available")
 	}
 
-	for _, classDevs := range allDevs {
-		for _, dev := range classDevs {
-			// match rule on a single device
-			if r.matchDevOnRule(dev) {
-				return true, nil
-			}
+	for _, dev := range devs.Elements {
+		// match rule on a single device
+		if r.matchDevOnRule(dev) {
+			return true, nil
 		}
 	}
 	return false, nil
 }
 
-func (r *UsbIDRule) matchDevOnRule(dev usbutils.UsbDeviceInfo) bool {
+func (r *UsbIDRule) matchDevOnRule(dev feature.InstanceFeature) bool {
 	if len(r.Class) == 0 && len(r.Vendor) == 0 && len(r.Device) == 0 {
 		return false
 	}
 
-	if len(r.Class) > 0 && !in(dev["class"], r.Class) {
+	attrs := dev.Attributes
+	if len(r.Class) > 0 && !in(attrs["class"], r.Class) {
 		return false
 	}
 
-	if len(r.Vendor) > 0 && !in(dev["vendor"], r.Vendor) {
+	if len(r.Vendor) > 0 && !in(attrs["vendor"], r.Vendor) {
 		return false
 	}
 
-	if len(r.Device) > 0 && !in(dev["device"], r.Device) {
+	if len(r.Device) > 0 && !in(attrs["device"], r.Device) {
 		return false
 	}
 
-	if len(r.Serial) > 0 && !in(dev["serial"], r.Serial) {
+	if len(r.Serial) > 0 && !in(attrs["serial"], r.Serial) {
 		return false
 	}
 
diff --git a/source/internal/kernelutils/kernel_kconfig.go b/source/kernel/kconfig.go
similarity index 96%
rename from source/internal/kernelutils/kernel_kconfig.go
rename to source/kernel/kconfig.go
index 98451abf7..b77d304b5 100644
--- a/source/internal/kernelutils/kernel_kconfig.go
+++ b/source/kernel/kconfig.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package kernelutils
+package kernel
 
 import (
 	"bytes"
@@ -26,9 +26,9 @@ import (
 	"regexp"
 	"strings"
 
+	"k8s.io/apimachinery/pkg/util/validation"
 	"k8s.io/klog/v2"
 
-	"k8s.io/apimachinery/pkg/util/validation"
 	"sigs.k8s.io/node-feature-discovery/source"
 )
 
@@ -52,13 +52,13 @@ func readKconfigGzip(filename string) ([]byte, error) {
 }
 
 // ParseKconfig reads kconfig and return a map
-func ParseKconfig(configPath string) (map[string]string, error) {
+func parseKconfig(configPath string) (map[string]string, error) {
 	kconfig := map[string]string{}
 	raw := []byte(nil)
 	var err error
 	var searchPaths []string
 
-	kVer, err := GetKernelVersion()
+	kVer, err := getVersion()
 	if err != nil {
 		searchPaths = []string{
 			"/proc/config.gz",
diff --git a/source/kernel/kernel.go b/source/kernel/kernel.go
index 5e0de7a4a..a24fba650 100644
--- a/source/kernel/kernel.go
+++ b/source/kernel/kernel.go
@@ -17,17 +17,24 @@ limitations under the License.
 package kernel
 
 import (
-	"regexp"
-	"strings"
+	"strconv"
 
 	"k8s.io/klog/v2"
 
+	"sigs.k8s.io/node-feature-discovery/pkg/api/feature"
+	"sigs.k8s.io/node-feature-discovery/pkg/utils"
 	"sigs.k8s.io/node-feature-discovery/source"
-	"sigs.k8s.io/node-feature-discovery/source/internal/kernelutils"
 )
 
 const Name = "kernel"
 
+const (
+	ConfigFeature       = "config"
+	LoadedModuleFeature = "loadedmodule"
+	SelinuxFeature      = "selinux"
+	VersionFeature      = "version"
+)
+
 // Configuration file options
 type Config struct {
 	KconfigFile string
@@ -47,14 +54,16 @@ func newDefaultConfig() *Config {
 	}
 }
 
-// kernelSource implements the LabelSource and ConfigurableSource interfaces.
+// kernelSource implements the FeatureSource, LabelSource and ConfigurableSource interfaces.
 type kernelSource struct {
-	config *Config
+	config   *Config
+	features *feature.DomainFeatures
 }
 
 // Singleton source instance
 var (
-	src kernelSource
+	src                           = kernelSource{config: newDefaultConfig()}
+	_   source.FeatureSource      = &src
 	_   source.LabelSource        = &src
 	_   source.ConfigurableSource = &src
 )
@@ -82,69 +91,68 @@ func (s *kernelSource) Priority() int { return 0 }
 
 // GetLabels method of the LabelSource interface
 func (s *kernelSource) GetLabels() (source.FeatureLabels, error) {
-	features := source.FeatureLabels{}
+	labels := source.FeatureLabels{}
+	features := s.GetFeatures()
+
+	for k, v := range features.Values[VersionFeature].Elements {
+		labels[VersionFeature+"."+k] = v
+	}
+
+	// Check flags
+	for _, opt := range s.config.ConfigOpts {
+		if val, ok := features.Values[ConfigFeature].Elements[opt]; ok {
+			labels[ConfigFeature+"."+opt] = val
+		}
+	}
+
+	for k, v := range features.Values[SelinuxFeature].Elements {
+		labels[SelinuxFeature+"."+k] = v
+	}
+
+	return labels, nil
+}
+
+// Discover method of the FeatureSource interface
+func (s *kernelSource) Discover() error {
+	s.features = feature.NewDomainFeatures()
 
 	// Read kernel version
-	version, err := parseVersion()
-	if err != nil {
+	if version, err := parseVersion(); err != nil {
 		klog.Errorf("failed to get kernel version: %s", err)
 	} else {
-		for key := range version {
-			features["version."+key] = version[key]
-		}
+		s.features.Values[VersionFeature] = feature.NewValueFeatures(version)
 	}
 
 	// Read kconfig
-	kconfig, err := kernelutils.ParseKconfig(s.config.KconfigFile)
-	if err != nil {
+	if kconfig, err := parseKconfig(s.config.KconfigFile); err != nil {
 		klog.Errorf("failed to read kconfig: %s", err)
+	} else {
+		s.features.Values[ConfigFeature] = feature.NewValueFeatures(kconfig)
 	}
 
-	// Check flags
-	for _, opt := range s.config.ConfigOpts {
-		if val, ok := kconfig[opt]; ok {
-			features["config."+opt] = val
-		}
+	if kmods, err := getLoadedModules(); err != nil {
+		klog.Errorf("failed to get loaded kernel modules: %v", err)
+	} else {
+		s.features.Keys[LoadedModuleFeature] = feature.NewKeyFeatures(kmods...)
 	}
 
-	selinux, err := SelinuxEnabled()
-	if err != nil {
+	if selinux, err := SelinuxEnabled(); err != nil {
 		klog.Warning(err)
-	} else if selinux {
-		features["selinux.enabled"] = true
+	} else {
+		s.features.Values[SelinuxFeature] = feature.NewValueFeatures(nil)
+		s.features.Values[SelinuxFeature].Elements["enabled"] = strconv.FormatBool(selinux)
 	}
 
-	return features, nil
-}
-
-// Read and parse kernel version
-func parseVersion() (map[string]string, error) {
-	version := map[string]string{}
+	utils.KlogDump(3, "discovered kernel features:", "  ", s.features)
 
-	full, err := kernelutils.GetKernelVersion()
-	if err != nil {
-		return nil, err
-	}
+	return nil
+}
 
-	// Replace forbidden symbols
-	fullRegex := regexp.MustCompile("[^-A-Za-z0-9_.]")
-	full = fullRegex.ReplaceAllString(full, "_")
-	// Label values must start and end with an alphanumeric
-	full = strings.Trim(full, "-_.")
-
-	version["full"] = full
-
-	// Regexp for parsing version components
-	re := regexp.MustCompile(`^(?P<major>\d+)(\.(?P<minor>\d+))?(\.(?P<revision>\d+))?(-.*)?$`)
-	if m := re.FindStringSubmatch(full); m != nil {
-		for i, name := range re.SubexpNames() {
-			if i != 0 && name != "" {
-				version[name] = m[i]
-			}
-		}
+func (s *kernelSource) GetFeatures() *feature.DomainFeatures {
+	if s.features == nil {
+		s.features = feature.NewDomainFeatures()
 	}
-
-	return version, nil
+	return s.features
 }
 
 func init() {
diff --git a/source/kernel/modules.go b/source/kernel/modules.go
new file mode 100644
index 000000000..54dfdf4e1
--- /dev/null
+++ b/source/kernel/modules.go
@@ -0,0 +1,44 @@
+/*
+Copyright 2021 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package kernel
+
+import (
+	"fmt"
+	"io/ioutil"
+	"strings"
+)
+
+const kmodProcfsPath = "/proc/modules"
+
+func getLoadedModules() ([]string, error) {
+	out, err := ioutil.ReadFile(kmodProcfsPath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read file %s: %s", kmodProcfsPath, err.Error())
+	}
+
+	lines := strings.Split(string(out), "\n")
+	loadedMods := make([]string, len(lines))
+	for _, line := range lines {
+		// skip empty lines
+		if len(line) == 0 {
+			continue
+		}
+		// append loaded module
+		loadedMods = append(loadedMods, strings.Fields(line)[0])
+	}
+	return loadedMods, nil
+}
diff --git a/source/kernel/version.go b/source/kernel/version.go
new file mode 100644
index 000000000..4cac1eaf8
--- /dev/null
+++ b/source/kernel/version.go
@@ -0,0 +1,61 @@
+/*
+Copyright 2018-2021 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package kernel
+
+import (
+	"io/ioutil"
+	"regexp"
+	"strings"
+)
+
+// Read and parse kernel version
+func parseVersion() (map[string]string, error) {
+	version := map[string]string{}
+
+	full, err := getVersion()
+	if err != nil {
+		return nil, err
+	}
+
+	// Replace forbidden symbols
+	fullRegex := regexp.MustCompile("[^-A-Za-z0-9_.]")
+	full = fullRegex.ReplaceAllString(full, "_")
+	// Label values must start and end with an alphanumeric
+	full = strings.Trim(full, "-_.")
+
+	version["full"] = full
+
+	// Regexp for parsing version components
+	re := regexp.MustCompile(`^(?P<major>\d+)(\.(?P<minor>\d+))?(\.(?P<revision>\d+))?(-.*)?$`)
+	if m := re.FindStringSubmatch(full); m != nil {
+		for i, name := range re.SubexpNames() {
+			if i != 0 && name != "" {
+				version[name] = m[i]
+			}
+		}
+	}
+
+	return version, nil
+}
+
+func getVersion() (string, error) {
+	unameRaw, err := ioutil.ReadFile("/proc/sys/kernel/osrelease")
+	if err != nil {
+		return "", err
+	}
+	return strings.TrimSpace(string(unameRaw)), nil
+}
diff --git a/source/local/local.go b/source/local/local.go
index 7c77db26a..dd755890d 100644
--- a/source/local/local.go
+++ b/source/local/local.go
@@ -27,24 +27,31 @@ import (
 
 	"k8s.io/klog/v2"
 
+	"sigs.k8s.io/node-feature-discovery/pkg/api/feature"
+	"sigs.k8s.io/node-feature-discovery/pkg/utils"
 	"sigs.k8s.io/node-feature-discovery/source"
 )
 
 const Name = "local"
 
+const LabelFeature = "label"
+
 // Config
 var (
 	featureFilesDir = "/etc/kubernetes/node-feature-discovery/features.d/"
 	hookDir         = "/etc/kubernetes/node-feature-discovery/source.d/"
 )
 
-// localSource implements the LabelSource interface.
-type localSource struct{}
+// localSource implements the FeatureSource and LabelSource interfaces.
+type localSource struct {
+	features *feature.DomainFeatures
+}
 
 // Singleton source instance
 var (
 	src localSource
-	_   source.LabelSource = &src
+	_   source.FeatureSource = &src
+	_   source.LabelSource   = &src
 )
 
 // Name method of the LabelSource interface
@@ -55,6 +62,19 @@ func (s *localSource) Priority() int { return 20 }
 
 // GetLabels method of the LabelSource interface
 func (s *localSource) GetLabels() (source.FeatureLabels, error) {
+	labels := make(source.FeatureLabels)
+	features := s.GetFeatures()
+
+	for k, v := range features.Values[LabelFeature].Elements {
+		labels[k] = v
+	}
+	return labels, nil
+}
+
+// Discover method of the FeatureSource interface
+func (s *localSource) Discover() error {
+	s.features = feature.NewDomainFeatures()
+
 	featuresFromHooks, err := getFeaturesFromHooks()
 	if err != nil {
 		klog.Error(err)
@@ -68,17 +88,28 @@ func (s *localSource) GetLabels() (source.FeatureLabels, error) {
 	// Merge features from hooks and files
 	for k, v := range featuresFromHooks {
 		if old, ok := featuresFromFiles[k]; ok {
-			klog.Warningf("overriding label '%s': value changed from '%s' to '%s'",
+			klog.Warningf("overriding '%s': value changed from '%s' to '%s'",
 				k, old, v)
 		}
 		featuresFromFiles[k] = v
 	}
+	s.features.Values[LabelFeature] = feature.NewValueFeatures(featuresFromFiles)
+
+	utils.KlogDump(3, "discovered local features:", "  ", s.features)
 
-	return featuresFromFiles, nil
+	return nil
+}
+
+// GetFeatures method of the FeatureSource Interface
+func (s *localSource) GetFeatures() *feature.DomainFeatures {
+	if s.features == nil {
+		s.features = feature.NewDomainFeatures()
+	}
+	return s.features
 }
 
-func parseFeatures(lines [][]byte, prefix string) source.FeatureLabels {
-	features := source.FeatureLabels{}
+func parseFeatures(lines [][]byte, prefix string) map[string]string {
+	features := make(map[string]string)
 
 	for _, line := range lines {
 		if len(line) > 0 {
@@ -109,8 +140,8 @@ func parseFeatures(lines [][]byte, prefix string) source.FeatureLabels {
 }
 
 // Run all hooks and get features
-func getFeaturesFromHooks() (source.FeatureLabels, error) {
-	features := source.FeatureLabels{}
+func getFeaturesFromHooks() (map[string]string, error) {
+	features := make(map[string]string)
 
 	files, err := ioutil.ReadDir(hookDir)
 	if err != nil {
@@ -184,8 +215,8 @@ func runHook(file string) ([][]byte, error) {
 }
 
 // Read all files to get features
-func getFeaturesFromFiles() (source.FeatureLabels, error) {
-	features := source.FeatureLabels{}
+func getFeaturesFromFiles() (map[string]string, error) {
+	features := make(map[string]string)
 
 	files, err := ioutil.ReadDir(featureFilesDir)
 	if err != nil {
diff --git a/source/pci/pci.go b/source/pci/pci.go
index de7a9f71a..987a1c934 100644
--- a/source/pci/pci.go
+++ b/source/pci/pci.go
@@ -22,12 +22,15 @@ import (
 
 	"k8s.io/klog/v2"
 
+	"sigs.k8s.io/node-feature-discovery/pkg/api/feature"
+	"sigs.k8s.io/node-feature-discovery/pkg/utils"
 	"sigs.k8s.io/node-feature-discovery/source"
-	pciutils "sigs.k8s.io/node-feature-discovery/source/internal"
 )
 
 const Name = "pci"
 
+const DeviceFeature = "device"
+
 type Config struct {
 	DeviceClassWhitelist []string `json:"deviceClassWhitelist,omitempty"`
 	DeviceLabelFields    []string `json:"deviceLabelFields,omitempty"`
@@ -41,14 +44,16 @@ func newDefaultConfig() *Config {
 	}
 }
 
-// pciSource implements the LabelSource and ConfigurableSource interfaces.
+// pciSource implements the FeatureSource, LabelSource and ConfigurableSource interfaces.
 type pciSource struct {
-	config *Config
+	config   *Config
+	features *feature.DomainFeatures
 }
 
 // Singleton source instance
 var (
-	src pciSource
+	src                           = pciSource{config: newDefaultConfig()}
+	_   source.FeatureSource      = &src
 	_   source.LabelSource        = &src
 	_   source.ConfigurableSource = &src
 )
@@ -77,16 +82,17 @@ func (s *pciSource) Priority() int { return 0 }
 
 // GetLabels method of the LabelSource interface
 func (s *pciSource) GetLabels() (source.FeatureLabels, error) {
-	features := source.FeatureLabels{}
+	labels := source.FeatureLabels{}
+	features := s.GetFeatures()
 
 	// Construct a device label format, a sorted list of valid attributes
-	deviceLabelFields := []string{}
-	configLabelFields := map[string]bool{}
+	deviceLabelFields := make([]string, 0)
+	configLabelFields := make(map[string]struct{}, len(s.config.DeviceLabelFields))
 	for _, field := range s.config.DeviceLabelFields {
-		configLabelFields[field] = true
+		configLabelFields[field] = struct{}{}
 	}
 
-	for _, attr := range pciutils.DefaultPciDevAttrs {
+	for _, attr := range mandatoryDevAttrs {
 		if _, ok := configLabelFields[attr]; ok {
 			deviceLabelFields = append(deviceLabelFields, attr)
 			delete(configLabelFields, attr)
@@ -97,50 +103,59 @@ func (s *pciSource) GetLabels() (source.FeatureLabels, error) {
 		for key := range configLabelFields {
 			keys = append(keys, key)
 		}
-		klog.Warningf("invalid fields '%v' in deviceLabelFields, ignoring...", keys)
+		klog.Warningf("invalid fields (%s) in deviceLabelFields, ignoring...", strings.Join(keys, ", "))
 	}
 	if len(deviceLabelFields) == 0 {
 		klog.Warningf("no valid fields in deviceLabelFields defined, using the defaults")
 		deviceLabelFields = []string{"class", "vendor"}
 	}
 
-	// Read extraDevAttrs + configured or default labels. Attributes
-	// set to 'true' are considered must-have.
-	deviceAttrs := map[string]bool{}
-	for _, label := range pciutils.ExtraPciDevAttrs {
-		deviceAttrs[label] = false
-	}
-	for _, label := range deviceLabelFields {
-		deviceAttrs[label] = true
-	}
-
-	devs, err := pciutils.DetectPci(deviceAttrs)
-	if err != nil {
-		return nil, fmt.Errorf("failed to detect PCI devices: %s", err.Error())
-	}
-
 	// Iterate over all device classes
-	for class, classDevs := range devs {
+	for _, dev := range features.Instances[DeviceFeature].Elements {
+		attrs := dev.Attributes
+		class := attrs["class"]
 		for _, white := range s.config.DeviceClassWhitelist {
-			if strings.HasPrefix(class, strings.ToLower(white)) {
-				for _, dev := range classDevs {
-					devLabel := ""
-					for i, attr := range deviceLabelFields {
-						devLabel += dev[attr]
-						if i < len(deviceLabelFields)-1 {
-							devLabel += "_"
-						}
+			if strings.HasPrefix(string(class), strings.ToLower(white)) {
+				devLabel := ""
+				for i, attr := range deviceLabelFields {
+					devLabel += attrs[attr]
+					if i < len(deviceLabelFields)-1 {
+						devLabel += "_"
 					}
-					features[devLabel+".present"] = true
+				}
+				labels[devLabel+".present"] = true
 
-					if _, ok := dev["sriov_totalvfs"]; ok {
-						features[devLabel+".sriov.capable"] = true
-					}
+				if _, ok := attrs["sriov_totalvfs"]; ok {
+					labels[devLabel+".sriov.capable"] = true
 				}
+				break
 			}
 		}
 	}
-	return features, nil
+	return labels, nil
+}
+
+// Discover method of the FeatureSource interface
+func (s *pciSource) Discover() error {
+	s.features = feature.NewDomainFeatures()
+
+	devs, err := detectPci()
+	if err != nil {
+		return fmt.Errorf("failed to detect PCI devices: %s", err.Error())
+	}
+	s.features.Instances[DeviceFeature] = feature.NewInstanceFeatures(devs)
+
+	utils.KlogDump(3, "discovered pci features:", "  ", s.features)
+
+	return nil
+}
+
+// GetFeatures method of the FeatureSource Interface
+func (s *pciSource) GetFeatures() *feature.DomainFeatures {
+	if s.features == nil {
+		s.features = feature.NewDomainFeatures()
+	}
+	return s.features
 }
 
 func init() {
diff --git a/source/internal/pci_utils.go b/source/pci/utils.go
similarity index 55%
rename from source/internal/pci_utils.go
rename to source/pci/utils.go
index a0c11743a..5bc05a674 100644
--- a/source/internal/pci_utils.go
+++ b/source/pci/utils.go
@@ -14,28 +14,27 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package busutils
+package pci
 
 import (
 	"fmt"
 	"io/ioutil"
-	"path"
+	"path/filepath"
 	"strings"
 
 	"k8s.io/klog/v2"
 
+	"sigs.k8s.io/node-feature-discovery/pkg/api/feature"
 	"sigs.k8s.io/node-feature-discovery/source"
 )
 
-type PciDeviceInfo map[string]string
-
-var DefaultPciDevAttrs = []string{"class", "vendor", "device", "subsystem_vendor", "subsystem_device"}
-var ExtraPciDevAttrs = []string{"sriov_totalvfs"}
+var mandatoryDevAttrs = []string{"class", "vendor", "device", "subsystem_vendor", "subsystem_device"}
+var optionalDevAttrs = []string{"sriov_totalvfs"}
 
 // Read a single PCI device attribute
 // A PCI attribute in this context, maps to the corresponding sysfs file
 func readSinglePciAttribute(devPath string, attrName string) (string, error) {
-	data, err := ioutil.ReadFile(path.Join(devPath, attrName))
+	data, err := ioutil.ReadFile(filepath.Join(devPath, attrName))
 	if err != nil {
 		return "", fmt.Errorf("failed to read device attribute %s: %v", attrName, err)
 	}
@@ -51,49 +50,43 @@ func readSinglePciAttribute(devPath string, attrName string) (string, error) {
 }
 
 // Read information of one PCI device
-func readPciDevInfo(devPath string, deviceAttrSpec map[string]bool) (PciDeviceInfo, error) {
-	info := PciDeviceInfo{}
-
-	for attr, must := range deviceAttrSpec {
+func readPciDevInfo(devPath string) (*feature.InstanceFeature, error) {
+	attrs := make(map[string]string)
+	for _, attr := range mandatoryDevAttrs {
 		attrVal, err := readSinglePciAttribute(devPath, attr)
 		if err != nil {
-			if must {
-				return info, fmt.Errorf("failed to read device %s: %s", attr, err)
-			}
-			continue
-
+			return nil, fmt.Errorf("failed to read device %s: %s", attr, err)
+		}
+		attrs[attr] = attrVal
+	}
+	for _, attr := range optionalDevAttrs {
+		attrVal, err := readSinglePciAttribute(devPath, attr)
+		if err == nil {
+			attrs[attr] = attrVal
 		}
-		info[attr] = attrVal
 	}
-	return info, nil
+	return feature.NewInstanceFeature(attrs), nil
 }
 
-// DetectPci lists available PCI devices and retrieve device attributes.
-// deviceAttrSpec is a map which specifies which attributes to retrieve.
-// a false value for a specific attribute marks the attribute as optional.
-// a true value for a specific attribute marks the attribute as mandatory.
-// "class" attribute is considered mandatory.
-// will fail if the retrieval of a mandatory attribute fails.
-func DetectPci(deviceAttrSpec map[string]bool) (map[string][]PciDeviceInfo, error) {
+// detectPci detects available PCI devices and retrieves their device attributes.
+// An error is returned if reading any of the mandatory attributes fails.
+func detectPci() ([]feature.InstanceFeature, error) {
 	sysfsBasePath := source.SysfsDir.Path("bus/pci/devices")
-	devInfo := make(map[string][]PciDeviceInfo)
 
 	devices, err := ioutil.ReadDir(sysfsBasePath)
 	if err != nil {
 		return nil, err
 	}
-	// "class" is a mandatory attribute, inject it to spec if needed.
-	deviceAttrSpec["class"] = true
 
 	// Iterate over devices
+	devInfo := make([]feature.InstanceFeature, 0, len(devices))
 	for _, device := range devices {
-		info, err := readPciDevInfo(path.Join(sysfsBasePath, device.Name()), deviceAttrSpec)
+		info, err := readPciDevInfo(filepath.Join(sysfsBasePath, device.Name()))
 		if err != nil {
 			klog.Error(err)
 			continue
 		}
-		class := info["class"]
-		devInfo[class] = append(devInfo[class], info)
+		devInfo = append(devInfo, *info)
 	}
 
 	return devInfo, nil
diff --git a/source/system/system.go b/source/system/system.go
index 44939720e..4404c8ab2 100644
--- a/source/system/system.go
+++ b/source/system/system.go
@@ -24,23 +24,35 @@ import (
 
 	"k8s.io/klog/v2"
 
+	"sigs.k8s.io/node-feature-discovery/pkg/api/feature"
+	"sigs.k8s.io/node-feature-discovery/pkg/utils"
 	"sigs.k8s.io/node-feature-discovery/source"
 )
 
 var osReleaseFields = [...]string{
 	"ID",
 	"VERSION_ID",
+	"VERSION_ID.major",
+	"VERSION_ID.minor",
 }
 
 const Name = "system"
 
-// systemSource implements the LabelSource interface.
-type systemSource struct{}
+const (
+	OsReleaseFeature = "osrelease"
+	NameFeature      = "name"
+)
+
+// systemSource implements the FeatureSource and LabelSource interfaces.
+type systemSource struct {
+	features *feature.DomainFeatures
+}
 
 // Singleton source instance
 var (
 	src systemSource
-	_   source.LabelSource = &src
+	_   source.FeatureSource = &src
+	_   source.LabelSource   = &src
 )
 
 func (s *systemSource) Name() string { return Name }
@@ -50,29 +62,54 @@ func (s *systemSource) Priority() int { return 0 }
 
 // GetLabels method of the LabelSource interface
 func (s *systemSource) GetLabels() (source.FeatureLabels, error) {
-	features := source.FeatureLabels{}
+	labels := source.FeatureLabels{}
+	features := s.GetFeatures()
+
+	for _, key := range osReleaseFields {
+		if value, exists := features.Values[OsReleaseFeature].Elements[key]; exists {
+			feature := "os_release." + key
+			labels[feature] = value
+		}
+	}
+	return labels, nil
+}
+
+// Discover method of the FeatureSource interface
+func (s *systemSource) Discover() error {
+	s.features = feature.NewDomainFeatures()
 
+	// Get node name
+	s.features.Values[NameFeature] = feature.NewValueFeatures(nil)
+	s.features.Values[NameFeature].Elements["nodename"] = os.Getenv("NODE_NAME")
+
+	// Get os-release information
 	release, err := parseOSRelease()
 	if err != nil {
 		klog.Errorf("failed to get os-release: %s", err)
 	} else {
-		for _, key := range osReleaseFields {
-			if value, exists := release[key]; exists {
-				feature := "os_release." + key
-				features[feature] = value
-
-				if key == "VERSION_ID" {
-					versionComponents := splitVersion(value)
-					for subKey, subValue := range versionComponents {
-						if subValue != "" {
-							features[feature+"."+subKey] = subValue
-						}
-					}
+		s.features.Values[OsReleaseFeature] = feature.NewValueFeatures(release)
+
+		if v, ok := release["VERSION_ID"]; ok {
+			versionComponents := splitVersion(v)
+			for subKey, subValue := range versionComponents {
+				if subValue != "" {
+					s.features.Values[OsReleaseFeature].Elements["VERSION_ID."+subKey] = subValue
 				}
 			}
 		}
 	}
-	return features, nil
+
+	utils.KlogDump(3, "discovered system features:", "  ", s.features)
+
+	return nil
+}
+
+// GetFeatures method of the FeatureSource Interface
+func (s *systemSource) GetFeatures() *feature.DomainFeatures {
+	if s.features == nil {
+		s.features = feature.NewDomainFeatures()
+	}
+	return s.features
 }
 
 // Read and parse os-release file
diff --git a/source/usb/usb.go b/source/usb/usb.go
index a86cf15d4..036409967 100644
--- a/source/usb/usb.go
+++ b/source/usb/usb.go
@@ -22,12 +22,15 @@ import (
 
 	"k8s.io/klog/v2"
 
+	"sigs.k8s.io/node-feature-discovery/pkg/api/feature"
+	"sigs.k8s.io/node-feature-discovery/pkg/utils"
 	"sigs.k8s.io/node-feature-discovery/source"
-	usbutils "sigs.k8s.io/node-feature-discovery/source/internal"
 )
 
 const Name = "usb"
 
+const DeviceFeature = "device"
+
 type Config struct {
 	DeviceClassWhitelist []string `json:"deviceClassWhitelist,omitempty"`
 	DeviceLabelFields    []string `json:"deviceLabelFields,omitempty"`
@@ -46,12 +49,14 @@ func newDefaultConfig() *Config {
 
 // usbSource implements the LabelSource and ConfigurableSource interfaces.
 type usbSource struct {
-	config *Config
+	config   *Config
+	features *feature.DomainFeatures
 }
 
 // Singleton source instance
 var (
-	src usbSource
+	src                           = usbSource{config: newDefaultConfig()}
+	_   source.FeatureSource      = &src
 	_   source.LabelSource        = &src
 	_   source.ConfigurableSource = &src
 )
@@ -80,7 +85,8 @@ func (s *usbSource) Priority() int { return 0 }
 
 // GetLabels method of the LabelSource interface
 func (s *usbSource) GetLabels() (source.FeatureLabels, error) {
-	features := source.FeatureLabels{}
+	labels := source.FeatureLabels{}
+	features := s.GetFeatures()
 
 	// Construct a device label format, a sorted list of valid attributes
 	deviceLabelFields := []string{}
@@ -89,7 +95,7 @@ func (s *usbSource) GetLabels() (source.FeatureLabels, error) {
 		configLabelFields[field] = true
 	}
 
-	for _, attr := range usbutils.DefaultUsbDevAttrs {
+	for _, attr := range devAttrs {
 		if _, ok := configLabelFields[attr]; ok {
 			deviceLabelFields = append(deviceLabelFields, attr)
 			delete(configLabelFields, attr)
@@ -100,42 +106,55 @@ func (s *usbSource) GetLabels() (source.FeatureLabels, error) {
 		for key := range configLabelFields {
 			keys = append(keys, key)
 		}
-		klog.Warningf("invalid fields '%v' in deviceLabelFields, ignoring...", keys)
+		klog.Warningf("invalid fields (%s) in deviceLabelFields, ignoring...", strings.Join(keys, ", "))
 	}
 	if len(deviceLabelFields) == 0 {
 		klog.Warningf("no valid fields in deviceLabelFields defined, using the defaults")
 		deviceLabelFields = []string{"vendor", "device"}
 	}
 
-	// Read configured or default labels. Attributes set to 'true' are considered must-have.
-	deviceAttrs := map[string]bool{}
-	for _, label := range deviceLabelFields {
-		deviceAttrs[label] = true
-	}
-
-	devs, err := usbutils.DetectUsb(deviceAttrs)
-	if err != nil {
-		return nil, fmt.Errorf("failed to detect USB devices: %s", err.Error())
-	}
-
 	// Iterate over all device classes
-	for class, classDevs := range devs {
+	for _, dev := range features.Instances[DeviceFeature].Elements {
+		attrs := dev.Attributes
+		class := attrs["class"]
 		for _, white := range s.config.DeviceClassWhitelist {
-			if strings.HasPrefix(class, strings.ToLower(white)) {
-				for _, dev := range classDevs {
-					devLabel := ""
-					for i, attr := range deviceLabelFields {
-						devLabel += dev[attr]
-						if i < len(deviceLabelFields)-1 {
-							devLabel += "_"
-						}
+			if strings.HasPrefix(string(class), strings.ToLower(white)) {
+				devLabel := ""
+				for i, attr := range deviceLabelFields {
+					devLabel += attrs[attr]
+					if i < len(deviceLabelFields)-1 {
+						devLabel += "_"
 					}
-					features[devLabel+".present"] = true
 				}
+				labels[devLabel+".present"] = true
+				break
 			}
 		}
 	}
-	return features, nil
+	return labels, nil
+}
+
+// Discover method of the FeatureSource interface
+func (s *usbSource) Discover() error {
+	s.features = feature.NewDomainFeatures()
+
+	devs, err := detectUsb()
+	if err != nil {
+		return fmt.Errorf("failed to detect USB devices: %s", err.Error())
+	}
+	s.features.Instances[DeviceFeature] = feature.NewInstanceFeatures(devs)
+
+	utils.KlogDump(3, "discovered usb features:", "  ", s.features)
+
+	return nil
+}
+
+// GetFeatures method of the FeatureSource Interface
+func (s *usbSource) GetFeatures() *feature.DomainFeatures {
+	if s.features == nil {
+		s.features = feature.NewDomainFeatures()
+	}
+	return s.features
 }
 
 func init() {
diff --git a/source/internal/usb_utils.go b/source/usb/utils.go
similarity index 62%
rename from source/internal/usb_utils.go
rename to source/usb/utils.go
index 2b80178fb..cce6674a1 100644
--- a/source/internal/usb_utils.go
+++ b/source/usb/utils.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package busutils
+package usb
 
 import (
 	"fmt"
@@ -24,12 +24,11 @@ import (
 	"strings"
 
 	"k8s.io/klog/v2"
-)
 
-type UsbDeviceInfo map[string]string
-type UsbClassMap map[string]UsbDeviceInfo
+	"sigs.k8s.io/node-feature-discovery/pkg/api/feature"
+)
 
-var DefaultUsbDevAttrs = []string{"class", "vendor", "device", "serial"}
+var devAttrs = []string{"class", "vendor", "device", "serial"}
 
 // The USB device sysfs files do not have terribly user friendly names, map
 // these for consistency with the PCI matcher.
@@ -58,26 +57,26 @@ func readSingleUsbAttribute(devPath string, attrName string) (string, error) {
 }
 
 // Read information of one USB device
-func readUsbDevInfo(devPath string, deviceAttrSpec map[string]bool) (UsbClassMap, error) {
-	classmap := UsbClassMap{}
-	info := UsbDeviceInfo{}
+func readUsbDevInfo(devPath string) ([]feature.InstanceFeature, error) {
+	instances := make([]feature.InstanceFeature, 0)
+	attrs := make(map[string]string)
 
-	for attr := range deviceAttrSpec {
+	for _, attr := range devAttrs {
 		attrVal, _ := readSingleUsbAttribute(devPath, attr)
 		if len(attrVal) > 0 {
-			info[attr] = attrVal
+			attrs[attr] = attrVal
 		}
 	}
 
 	// USB devices encode their class information either at the device or the interface level. If the device class
 	// is set, return as-is.
-	if info["class"] != "00" {
-		classmap[info["class"]] = info
+	if attrs["class"] != "00" {
+		instances = append(instances, *feature.NewInstanceFeature(attrs))
 	} else {
 		// Otherwise, if a 00 is presented at the device level, descend to the interface level.
 		interfaces, err := filepath.Glob(devPath + "/*/bInterfaceClass")
 		if err != nil {
-			return classmap, err
+			return nil, err
 		}
 
 		// A device may, notably, have multiple interfaces with mixed classes, so we create a unique device for each
@@ -86,54 +85,43 @@ func readUsbDevInfo(devPath string, deviceAttrSpec map[string]bool) (UsbClassMap
 			// Determine the interface class
 			attrVal, err := readSingleUsbSysfsAttribute(intf)
 			if err != nil {
-				return classmap, err
+				return nil, err
 			}
 
-			attr := UsbDeviceInfo{}
-			for k, v := range info {
-				attr[k] = v
+			subdevAttrs := make(map[string]string, len(attrs))
+			for k, v := range attrs {
+				subdevAttrs[k] = v
 			}
+			subdevAttrs["class"] = attrVal
 
-			attr["class"] = attrVal
-			classmap[attrVal] = attr
+			instances = append(instances, *feature.NewInstanceFeature(subdevAttrs))
 		}
 	}
 
-	return classmap, nil
+	return instances, nil
 }
 
-// List available USB devices and retrieve device attributes.
-// deviceAttrSpec is a map which specifies which attributes to retrieve.
-// a false value for a specific attribute marks the attribute as optional.
-// a true value for a specific attribute marks the attribute as mandatory.
-// "class" attribute is considered mandatory.
-// DetectUsb() will fail if the retrieval of a mandatory attribute fails.
-func DetectUsb(deviceAttrSpec map[string]bool) (map[string][]UsbDeviceInfo, error) {
+// detectUsb detects available USB devices and retrieves their device attributes.
+func detectUsb() ([]feature.InstanceFeature, error) {
 	// Unlike PCI, the USB sysfs interface includes entries not just for
 	// devices. We work around this by globbing anything that includes a
 	// valid product ID.
-	const devicePath = "/sys/bus/usb/devices/*/idProduct"
-	devInfo := make(map[string][]UsbDeviceInfo)
-
-	devices, err := filepath.Glob(devicePath)
+	const devPathGlob = "/sys/bus/usb/devices/*/idProduct"
+	devPaths, err := filepath.Glob(devPathGlob)
 	if err != nil {
 		return nil, err
 	}
 
-	// "class" is a mandatory attribute, inject it to spec if needed.
-	deviceAttrSpec["class"] = true
-
 	// Iterate over devices
-	for _, device := range devices {
-		devMap, err := readUsbDevInfo(filepath.Dir(device), deviceAttrSpec)
+	devInfo := make([]feature.InstanceFeature, 0)
+	for _, devPath := range devPaths {
+		devs, err := readUsbDevInfo(filepath.Dir(devPath))
 		if err != nil {
 			klog.Error(err)
 			continue
 		}
 
-		for class, info := range devMap {
-			devInfo[class] = append(devInfo[class], info)
-		}
+		devInfo = append(devInfo, devs...)
 	}
 
 	return devInfo, nil


Test patch:
diff --git a/source/kernel/kernel_test.go b/source/kernel/kernel_test.go
new file mode 100644
index 000000000..eef3064d6
--- /dev/null
+++ b/source/kernel/kernel_test.go
@@ -0,0 +1,36 @@
+/*
+Copyright 2021 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package kernel
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"sigs.k8s.io/node-feature-discovery/pkg/api/feature"
+)
+
+func TestKernelSource(t *testing.T) {
+	assert.Equal(t, src.Name(), Name)
+
+	// Check that GetLabels works with empty features
+	src.features = feature.NewDomainFeatures()
+	l, err := src.GetLabels()
+
+	assert.Nil(t, err, err)
+	assert.Empty(t, l)
+
+}
diff --git a/source/local/local_test.go b/source/local/local_test.go
new file mode 100644
index 000000000..004492035
--- /dev/null
+++ b/source/local/local_test.go
@@ -0,0 +1,36 @@
+/*
+Copyright 2021 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package local
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"sigs.k8s.io/node-feature-discovery/pkg/api/feature"
+)
+
+func TestLocalSource(t *testing.T) {
+	assert.Equal(t, src.Name(), Name)
+
+	// Check that GetLabels works with empty features
+	src.features = feature.NewDomainFeatures()
+	l, err := src.GetLabels()
+
+	assert.Nil(t, err, err)
+	assert.Empty(t, l)
+
+}
diff --git a/source/pci/pci_test.go b/source/pci/pci_test.go
new file mode 100644
index 000000000..e419a4018
--- /dev/null
+++ b/source/pci/pci_test.go
@@ -0,0 +1,36 @@
+/*
+Copyright 2021 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package pci
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"sigs.k8s.io/node-feature-discovery/pkg/api/feature"
+)
+
+func TestPciSource(t *testing.T) {
+	assert.Equal(t, src.Name(), Name)
+
+	// Check that GetLabels works with empty features
+	src.features = feature.NewDomainFeatures()
+	l, err := src.GetLabels()
+
+	assert.Nil(t, err, err)
+	assert.Empty(t, l)
+
+}
diff --git a/source/source_test.go b/source/source_test.go
index fb1da297e..1d4c11006 100644
--- a/source/source_test.go
+++ b/source/source_test.go
@@ -65,6 +65,7 @@ func TestConfigurableSources(t *testing.T) {
 
 func TestFeatureSources(t *testing.T) {
 	sources := source.GetAllFeatureSources()
+	assert.NotZero(t, len(sources))
 
 	for n, s := range sources {
 		msg := fmt.Sprintf("testing FeatureSource %q failed", n)
diff --git a/source/system/system_test.go b/source/system/system_test.go
new file mode 100644
index 000000000..58b0c1a1d
--- /dev/null
+++ b/source/system/system_test.go
@@ -0,0 +1,36 @@
+/*
+Copyright 2021 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package system
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"sigs.k8s.io/node-feature-discovery/pkg/api/feature"
+)
+
+func TestSystemSource(t *testing.T) {
+	assert.Equal(t, src.Name(), Name)
+
+	// Check that GetLabels works with empty features
+	src.features = feature.NewDomainFeatures()
+	l, err := src.GetLabels()
+
+	assert.Nil(t, err, err)
+	assert.Empty(t, l)
+
+}
diff --git a/source/usb/usb_test.go b/source/usb/usb_test.go
new file mode 100644
index 000000000..460402536
--- /dev/null
+++ b/source/usb/usb_test.go
@@ -0,0 +1,36 @@
+/*
+Copyright 2021 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package usb
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"sigs.k8s.io/node-feature-discovery/pkg/api/feature"
+)
+
+func TestUsbSource(t *testing.T) {
+	assert.Equal(t, src.Name(), Name)
+
+	// Check that GetLabels works with empty features
+	src.features = feature.NewDomainFeatures()
+	l, err := src.GetLabels()
+
+	assert.Nil(t, err, err)
+	assert.Empty(t, l)
+
+}