Repository: kubernetes-sigs/node-feature-discovery
Pull request #550 (2021-07-07 09:22:31)

Templating of custom label names
Support templating of label names in feature rules. It is available both
in NodeFeatureRule CRs and in custom rule configuration of nfd-worker.

This patch adds a new 'labelsTemplate' field to the rule spec, making it
possible to dynamically generate multiple labels per rule based on the
matched features. The feature relies on the golang "text/template"
package.  When expanded, the template must contain labels in a raw
<key>[=<value>] format (where 'value' defaults to "true"), separated by
newlines i.e.:

```
  - name: <rule-name>
    labelsTemplate: |
      <label-1>[=<value-1>]
      <label-2>[=<value-2>]
      ...
```

All the matched features of 'matchFeatures' directives are available for
templating engine in a nested data structure that can be described in
yaml as:

```
.
  <domain-1>:
      <key-feature-1>:
        - Name: <matched-key>
        - ...

      <value-feature-1:
        - Name: <matched-key>
          Value: <matched-value>
        - ...

      <instance-feature-1>:
        - <attribute-1-name>: <attribute-1-value>
          <attribute-2-name>: <attribute-2-value>
          ...
        - ...

  <domain-2>:
     ...
```

That is, the per-feature data available for matching depends on the type
of feature that was matched:

- "key features": only 'Name' is available
- "value features": 'Name' and 'Value' can be used
- "instance features": all attributes of the matched instance are
   available

NOTE: In case of matchAny is specified, the template is executed
separately against each individual matchFeatures matcher and the
eventual set of labels is a superset of all these expansions.  Consider
the following:

```
  - name: <name>
    labelsTemplate: <template>
    matchAny:
      - matchFeatures: <matcher#1>
      - matchFeatures: <matcher#2>
    matchFeatures: <matcher#3>
```

In the example above (assuming the overall result is a match) the
template would be executed on matcher#1 and/or matcher#2 (depending on
whether both or only one of them match), and finally on matcher#3, and
all the labels from these separate expansions would be created (i.e. the
end result would be a union of all the individual expansions).

NOTE 2: The 'labels' field has priority over 'labelsTemplate', i.e.
labels specified in the 'labels' field will override any labels
originating from the 'labelsTemplate' field.

A special case of an empty match expression set matches everything (i.e.
matches/returns all existing keys/values). This makes it simpler to
write templates that run over all values. Also, makes it possible to
later implement support for templates that run over all _keys_ of a
feature.

Some example configurations:

```
  - name: "my-pci-template-features"
    labelsTemplate: |
      {{ range .pci.device }}intel-{{ .class }}-{{ .device }}=present
      {{ end }}
    matchFeatures:
      - feature: pci.device
        matchExpressions:
          class: {op: InRegexp, value: ["^06"]}
          vendor: ["8086"]
```

```
  - name: "my-system-template-features"
    labelsTemplate: |
      {{ range .system.osrelease }}system-{{ .Name }}={{ .Value }}
      {{ end }}
    matchFeatures:
      - feature: system.osRelease
        matchExpressions:
          ID: {op: Exists}
          VERSION_ID.major: {op: Exists}
```

Imaginative template pipelines are possible, of course, but care must be
taken in order to produce understandable and maintainable rule sets.


k8s-ci-robot: Skipping CI for Draft Pull Request.
If you want CI signal for your change, please convert it to an actual PR.
You can still manually trigger a test run with `/test all`
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/550#" 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: PR needs rebase.

<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.
</details>
k8s-ci-robot: @marquiz: PR needs rebase.

<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.
</details>
k8s-ci-robot: @marquiz: PR needs rebase.

<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.
</details>
k8s-ci-robot: @marquiz: PR needs rebase.

<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.
</details>
marquiz: I feel that this would be ready for review, too. Documentation will be addressed in a follow-up PR.

Depending on #464 
/hold
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 | Rerun command
--- | --- | --- | ---
pull-node-feature-discovery-verify | c43aa85671c92dddcfc66c6ee09a5964ba50cb29 | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/kubernetes-sigs_node-feature-discovery/550/pull-node-feature-discovery-verify/1438820225460998144) | `/test pull-node-feature-discovery-verify`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=node-feature-discovery&pr=550). [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 -->
k8s-ci-robot: @marquiz: PR needs rebase.

<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.
</details>
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-build-image | 463701da028b96b2b1e00bedc587879b4ac5a8b6 | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/kubernetes-sigs_node-feature-discovery/550/pull-node-feature-discovery-build-image/1458908343824289792) | true | `/test pull-node-feature-discovery-build-image`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=node-feature-discovery&pr=550). [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 -->
k8s-ci-robot: @marquiz: The following tests **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-build-image | 463701da028b96b2b1e00bedc587879b4ac5a8b6 | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/kubernetes-sigs_node-feature-discovery/550/pull-node-feature-discovery-build-image/1458908343824289792) | true | `/test pull-node-feature-discovery-build-image`
pull-node-feature-discovery-verify | 463701da028b96b2b1e00bedc587879b4ac5a8b6 | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/kubernetes-sigs_node-feature-discovery/550/pull-node-feature-discovery-verify/1458908343732015104) | 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=550). [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 -->
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 | 9a65fe2d0d4a8866e2718710c54dfe3bbb1b89e7 | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/kubernetes-sigs_node-feature-discovery/550/pull-node-feature-discovery-verify/1461013458341335040) | 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=550). [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: /unhold

Patch:
diff --git a/deployment/base/nfd-crds/cr-sample.yaml b/deployment/base/nfd-crds/cr-sample.yaml
index ac33c8068..2c74eb8bc 100644
--- a/deployment/base/nfd-crds/cr-sample.yaml
+++ b/deployment/base/nfd-crds/cr-sample.yaml
@@ -56,14 +56,6 @@ spec:
             operstate: {op: In, value: ["up"]}
             speed: {op: Gt, value: ["100"]}
 
-        - feature: memory.numa
-          matchExpressions:
-            node_count: {op: Gt, value: ["2"]}
-        - feature: memory.nv
-          matchExpressions:
-            devtype: {op: In, value: ["nd_dax"]}
-            mode: {op: In, value: ["memory"]}
-
         - feature: system.osrelease
           matchExpressions:
             ID: {op: In, value: ["fedora", "centos"]}
@@ -87,3 +79,27 @@ spec:
             matchExpressions:
               vendor: {op: In, value: ["8086"]}
               class: {op: In, value: ["02"]}
+
+    # The following features demonstreate label templating capabilities
+    - name: "my system template feature"
+      labelsTemplate: |
+        {{ range .system.osrelease }}my-system-feature.{{ .Name }}={{ .Value }}
+        {{ end }}
+      matchFeatures:
+        - feature: system.osrelease
+          matchExpressions:
+            ID: {op: InRegexp, value: ["^open.*"]}
+            VERSION_ID.major: {op: In, value: ["13", "15"]}
+
+    - name: "my pci template feature"
+      labelsTemplate: |
+        {{ range .pci.device }}my-pci-device.{{ .class }}-{{ .device }}=with-cpuid
+        {{ end }}
+      matchFeatures:
+        - feature: pci.device
+          matchExpressions:
+            class: {op: InRegexp, value: ["^06"]}
+            vendor: {op: In, value: ["8086"]}
+        - feature: cpu.cpuid
+          matchExpressions:
+            AVX: {op: Exists}
diff --git a/deployment/base/nfd-crds/nodefeaturerule-crd.yaml b/deployment/base/nfd-crds/nodefeaturerule-crd.yaml
index 1f273e6c7..277a1508f 100644
--- a/deployment/base/nfd-crds/nodefeaturerule-crd.yaml
+++ b/deployment/base/nfd-crds/nodefeaturerule-crd.yaml
@@ -48,6 +48,12 @@ spec:
                         type: string
                       description: Labels to create if the rule matches.
                       type: object
+                    labelsTemplate:
+                      description: LabelsTemplate specifies a template to expand for
+                        dynamically generating multiple labels. Data (after template
+                        expansion) must be keys with an optional value (<key>[=<value>])
+                        separated by newlines.
+                      type: string
                     matchAny:
                       description: MatchAny specifies a list of matchers one of which
                         must match.
diff --git a/deployment/components/worker-config/nfd-worker.conf.example b/deployment/components/worker-config/nfd-worker.conf.example
index 11d43450d..39dad5929 100644
--- a/deployment/components/worker-config/nfd-worker.conf.example
+++ b/deployment/components/worker-config/nfd-worker.conf.example
@@ -160,14 +160,6 @@
 #            operstate: {op: In, value: ["up"]}
 #            speed: {op: Gt, value: ["100"]}
 #
-#        - feature: memory.numa
-#          matchExpressions:
-#            node_count: {op: Gt, value: ["2"]}
-#        - feature: memory.nv
-#          matchExpressions:
-#            devtype: {op: In, value: ["nd_dax"]}
-#            mode: {op: In, value: ["memory"]}
-#
 #        - feature: system.osrelease
 #          matchExpressions:
 #            ID: {op: In, value: ["fedora", "centos"]}
@@ -202,3 +194,27 @@
 #              matchExpressions:
 #                vendor: {op: In, value: ["8086"]}
 #                class: {op: In, value: ["02"]}
+#
+#    # The following features demonstreate label templating capabilities
+#    - name: "my-template-test"
+#      labelsTemplate: |
+#        {{ range .system.osrelease }}my-system-feature.{{ .Name }}={{ .Value }}
+#        {{ end }}
+#      matchFeatures:
+#        - feature: system.osrelease
+#          matchExpressions:
+#            ID: {op: InRegexp, value: ["^open.*"]}
+#            VERSION_ID.major: {op: In, value: ["13", "15"]}
+#
+#    - name: "my-template-test-2"
+#      labelsTemplate: |
+#        {{ range .pci.device }}my-pci-device.{{ .class }}-{{ .device }}=with-cpuid
+#        {{ end }}
+#      matchFeatures:
+#        - feature: pci.device
+#          matchExpressions:
+#            class: {op: InRegexp, value: ["^06"]}
+#            vendor: ["8086"]
+#        - feature: cpu.cpuid
+#          matchExpressions:
+#            AVX: {op: Exists}
diff --git a/deployment/helm/node-feature-discovery/manifests/nodefeaturerule-crd.yaml b/deployment/helm/node-feature-discovery/manifests/nodefeaturerule-crd.yaml
index 1f273e6c7..277a1508f 100644
--- a/deployment/helm/node-feature-discovery/manifests/nodefeaturerule-crd.yaml
+++ b/deployment/helm/node-feature-discovery/manifests/nodefeaturerule-crd.yaml
@@ -48,6 +48,12 @@ spec:
                         type: string
                       description: Labels to create if the rule matches.
                       type: object
+                    labelsTemplate:
+                      description: LabelsTemplate specifies a template to expand for
+                        dynamically generating multiple labels. Data (after template
+                        expansion) must be keys with an optional value (<key>[=<value>])
+                        separated by newlines.
+                      type: string
                     matchAny:
                       description: MatchAny specifies a list of matchers one of which
                         must match.
diff --git a/deployment/helm/node-feature-discovery/values.yaml b/deployment/helm/node-feature-discovery/values.yaml
index 7474ebdd6..48b5c0539 100644
--- a/deployment/helm/node-feature-discovery/values.yaml
+++ b/deployment/helm/node-feature-discovery/values.yaml
@@ -249,14 +249,6 @@ worker:
     #            operstate: {op: In, value: ["up"]}
     #            speed: {op: Gt, value: ["100"]}
     #
-    #        - feature: memory.numa
-    #          matchExpressions:
-    #            node_count: {op: Gt, value: ["2"]}
-    #        - feature: memory.nv
-    #          matchExpressions:
-    #            devtype: {op: In, value: ["nd_dax"]}
-    #            mode: {op: In, value: ["memory"]}
-    #
     #        - feature: system.osrelease
     #          matchExpressions:
     #            ID: {op: In, value: ["fedora", "centos"]}
@@ -291,6 +283,30 @@ worker:
     #              matchExpressions:
     #                vendor: {op: In, value: ["8086"]}
     #                class: {op: In, value: ["02"]}
+    #
+    #    # The following features demonstreate label templating capabilities
+    #    - name: "my-template-test"
+    #      labelsTemplate: |
+    #        {{ range .system.osrelease }}my-system-feature.{{ .Name }}={{ .Value }}
+    #        {{ end }}
+    #      matchFeatures:
+    #        - feature: system.osrelease
+    #          matchExpressions:
+    #            ID: {op: InRegexp, value: ["^open.*"]}
+    #            VERSION_ID.major: {op: In, value: ["13", "15"]}
+    #
+    #    - name: "my-template-test-2"
+    #      labelsTemplate: |
+    #        {{ range .pci.device }}my-pci-device.{{ .class }}-{{ .device }}=with-cpuid
+    #        {{ end }}
+    #      matchFeatures:
+    #        - feature: pci.device
+    #          matchExpressions:
+    #            class: {op: InRegexp, value: ["^06"]}
+    #            vendor: ["8086"]
+    #        - feature: cpu.cpuid
+    #          matchExpressions:
+    #            AVX: {op: Exists}
 ### <NFD-WORKER-CONF-END-DO-NOT-REMOVE>
 
   podSecurityContext: {}
diff --git a/pkg/apis/nfd/v1alpha1/expression.go b/pkg/apis/nfd/v1alpha1/expression.go
index 1591b88d2..0eaf49c0d 100644
--- a/pkg/apis/nfd/v1alpha1/expression.go
+++ b/pkg/apis/nfd/v1alpha1/expression.go
@@ -314,44 +314,113 @@ func (m *MatchExpression) UnmarshalJSON(data []byte) error {
 
 // MatchKeys evaluates the MatchExpressionSet against a set of keys.
 func (m *MatchExpressionSet) MatchKeys(keys map[string]feature.Nil) (bool, error) {
+	v, err := m.MatchGetKeys(keys)
+	return v != nil, err
+}
+
+// MatchedKey holds one matched key.
+type MatchedKey struct {
+	Name string
+}
+
+// MatchGetKeys evaluates the MatchExpressionSet against a set of keys and
+// returns all matched keys or nil if no match was found. Special case of an
+// empty MatchExpressionSet returns all existing keys are returned. Note that
+// an empty MatchExpressionSet and an empty set of keys returns an empty slice
+// which is not nil and is treated as a match.
+func (m *MatchExpressionSet) MatchGetKeys(keys map[string]feature.Nil) ([]MatchedKey, error) {
+	ret := make([]MatchedKey, 0, m.Len())
+
+	// An empty rule matches all existing keys
+	if m.Len() == 0 {
+		for n := range keys {
+			ret = append(ret, MatchedKey{Name: n})
+		}
+	}
+
 	for n, e := range (*m).Expressions {
 		match, err := e.MatchKeys(n, keys)
 		if err != nil {
-			return false, err
+			return nil, err
 		}
 		if !match {
-			return false, nil
+			return nil, nil
 		}
+		ret = append(ret, MatchedKey{Name: n})
 	}
-	return true, nil
+	// Sort for reproducible output
+	sort.Slice(ret, func(i, j int) bool { return ret[i].Name < ret[j].Name })
+	return ret, nil
 }
 
 // MatchValues evaluates the MatchExpressionSet against a set of key-value pairs.
 func (m *MatchExpressionSet) MatchValues(values map[string]string) (bool, error) {
+	v, err := m.MatchGetValues(values)
+	return v != nil, err
+}
+
+// MatchedValue holds one matched key-value pair.
+type MatchedValue struct {
+	Name  string
+	Value string
+}
+
+// MatchGetValues evaluates the MatchExpressionSet against a set of key-value
+// pairs and returns all matched key-value pairs. Special case of an empty
+// MatchExpressionSet returns all existing key-value pairs. Note that an empty
+// MatchExpressionSet and an empty set of values returns an empty non-nil map
+// which is treated as a match.
+func (m *MatchExpressionSet) MatchGetValues(values map[string]string) ([]MatchedValue, error) {
+	ret := make([]MatchedValue, 0, m.Len())
+
+	// An empty rule matches all existing values
+	if m.Len() == 0 {
+		for n, v := range values {
+			ret = append(ret, MatchedValue{Name: n, Value: v})
+		}
+	}
+
 	for n, e := range (*m).Expressions {
 		match, err := e.MatchValues(n, values)
 		if err != nil {
-			return false, err
+			return nil, err
 		}
 		if !match {
-			return false, nil
+			return nil, nil
 		}
+		ret = append(ret, MatchedValue{Name: n, Value: values[n]})
 	}
-	return true, nil
+	// Sort for reproducible output
+	sort.Slice(ret, func(i, j int) bool { return ret[i].Name < ret[j].Name })
+	return ret, nil
 }
 
 // MatchInstances evaluates the MatchExpressionSet against a set of instance
 // features, each of which is an individual set of key-value pairs
 // (attributes).
 func (m *MatchExpressionSet) MatchInstances(instances []feature.InstanceFeature) (bool, error) {
+	v, err := m.MatchGetInstances(instances)
+	return len(v) > 0, err
+}
+
+// MatchedInstance holds one matched Instance.
+type MatchedInstance map[string]string
+
+// MatchGetInstances evaluates the MatchExpressionSet against a set of instance
+// features, each of which is an individual set of key-value pairs
+// (attributes). A slice containing all matching instances is returned. An
+// empty (non-nil) slice is returned if no matching instances were found.
+func (m *MatchExpressionSet) MatchGetInstances(instances []feature.InstanceFeature) ([]MatchedInstance, error) {
+	ret := []MatchedInstance{}
+
 	for _, i := range instances {
 		if match, err := m.MatchValues(i.Attributes); err != nil {
-			return false, err
+			return nil, err
 		} else if match {
-			return true, nil
+			ret = append(ret, i.Attributes)
 		}
 	}
-	return false, nil
+	return ret, nil
 }
 
 // UnmarshalJSON implements the Unmarshaler interface of "encoding/json".
diff --git a/pkg/apis/nfd/v1alpha1/rule.go b/pkg/apis/nfd/v1alpha1/rule.go
index fcb07a13a..40aad3714 100644
--- a/pkg/apis/nfd/v1alpha1/rule.go
+++ b/pkg/apis/nfd/v1alpha1/rule.go
@@ -18,23 +18,38 @@ package v1alpha1
 
 import (
 	"strings"
+	"text/template"
 
+	"bytes"
 	"fmt"
 
 	"sigs.k8s.io/node-feature-discovery/pkg/api/feature"
+	"sigs.k8s.io/node-feature-discovery/pkg/utils"
 )
 
 // Execute the rule against a set of input features.
 func (r *Rule) Execute(features map[string]*feature.DomainFeatures) (map[string]string, error) {
+	ret := make(map[string]string)
+
 	if len(r.MatchAny) > 0 {
 		// Logical OR over the matchAny matchers
 		matched := false
 		for _, matcher := range r.MatchAny {
 			if m, err := matcher.match(features); err != nil {
 				return nil, err
-			} else if m {
+			} else if m != nil {
 				matched = true
-				break
+				utils.KlogDump(4, "matches for matchAny "+r.Name, "  ", m)
+
+				if r.labelsTemplate == nil {
+					// No templating so we stop here (further matches would just
+					// produce the same labels)
+					break
+				}
+				if err := r.executeLabelsTemplate(m, ret); err != nil {
+					return nil, err
+				}
+
 			}
 		}
 		if !matched {
@@ -45,29 +60,62 @@ func (r *Rule) Execute(features map[string]*feature.DomainFeatures) (map[string]
 	if len(r.MatchFeatures) > 0 {
 		if m, err := r.MatchFeatures.match(features); err != nil {
 			return nil, err
-		} else if !m {
+		} else if m == nil {
 			return nil, nil
+		} else {
+			utils.KlogDump(4, "matches for matchFeatures "+r.Name, "  ", m)
+			if err := r.executeLabelsTemplate(m, ret); err != nil {
+				return nil, err
+			}
 		}
 	}
 
-	labels := make(map[string]string, len(r.Labels))
 	for k, v := range r.Labels {
-		labels[k] = v
+		ret[k] = v
+	}
+
+	return ret, nil
+}
+
+func (r *Rule) executeLabelsTemplate(in matchedFeatures, out map[string]string) error {
+	if r.LabelsTemplate == "" {
+		return nil
 	}
 
-	return labels, nil
+	if r.labelsTemplate == nil {
+		t, err := newTemplateHelper(r.LabelsTemplate)
+		if err != nil {
+			return err
+		}
+		r.labelsTemplate = t
+	}
+
+	labels, err := r.labelsTemplate.expandMap(in)
+	if err != nil {
+		return err
+	}
+	for k, v := range labels {
+		out[k] = v
+	}
+	return nil
 }
 
-func (e *MatchAnyElem) match(features map[string]*feature.DomainFeatures) (bool, error) {
+type matchedFeatures map[string]domainMatchedFeatures
+
+type domainMatchedFeatures map[string]interface{}
+
+func (e *MatchAnyElem) match(features map[string]*feature.DomainFeatures) (matchedFeatures, error) {
 	return e.MatchFeatures.match(features)
 }
 
-func (m *FeatureMatcher) match(features map[string]*feature.DomainFeatures) (bool, error) {
+func (m *FeatureMatcher) match(features map[string]*feature.DomainFeatures) (matchedFeatures, error) {
+	ret := make(matchedFeatures, len(*m))
+
 	// Logical AND over the terms
 	for _, term := range *m {
 		split := strings.SplitN(term.Feature, ".", 2)
 		if len(split) != 2 {
-			return false, fmt.Errorf("invalid selector %q: must be <domain>.<feature>", term.Feature)
+			return nil, fmt.Errorf("invalid feature %q: must be <domain>.<feature>", term.Feature)
 		}
 		domain := split[0]
 		// Ignore case
@@ -75,26 +123,100 @@ func (m *FeatureMatcher) match(features map[string]*feature.DomainFeatures) (boo
 
 		domainFeatures, ok := features[domain]
 		if !ok {
-			return false, fmt.Errorf("unknown feature source/domain %q", domain)
+			return nil, fmt.Errorf("unknown feature source/domain %q", domain)
+		}
+
+		if _, ok := ret[domain]; !ok {
+			ret[domain] = make(domainMatchedFeatures)
 		}
 
 		var m bool
-		var err error
+		var e error
 		if f, ok := domainFeatures.Keys[featureName]; ok {
-			m, err = term.MatchExpressions.MatchKeys(f.Elements)
+			v, err := term.MatchExpressions.MatchGetKeys(f.Elements)
+			m = len(v) > 0
+			e = err
+			ret[domain][featureName] = v
 		} else if f, ok := domainFeatures.Values[featureName]; ok {
-			m, err = term.MatchExpressions.MatchValues(f.Elements)
+			v, err := term.MatchExpressions.MatchGetValues(f.Elements)
+			m = len(v) > 0
+			e = err
+			ret[domain][featureName] = v
 		} else if f, ok := domainFeatures.Instances[featureName]; ok {
-			m, err = term.MatchExpressions.MatchInstances(f.Elements)
+			v, err := term.MatchExpressions.MatchGetInstances(f.Elements)
+			m = len(v) > 0
+			e = err
+			ret[domain][featureName] = v
 		} else {
-			return false, fmt.Errorf("%q feature of source/domain %q not available", featureName, domain)
+			return nil, fmt.Errorf("%q feature of source/domain %q not available", featureName, domain)
 		}
 
-		if err != nil {
-			return false, err
+		if e != nil {
+			return nil, e
 		} else if !m {
-			return false, nil
+			return nil, nil
+		}
+	}
+	return ret, nil
+}
+
+type templateHelper struct {
+	template *template.Template
+}
+
+func newTemplateHelper(name string) (*templateHelper, error) {
+	tmpl, err := template.New("").Option("missingkey=error").Parse(name)
+	if err != nil {
+		return nil, fmt.Errorf("invalid template: %w", err)
+	}
+	return &templateHelper{template: tmpl}, nil
+}
+
+// DeepCopy is a stub to augment the auto-generated code
+func (in *templateHelper) DeepCopy() *templateHelper {
+	if in == nil {
+		return nil
+	}
+	out := new(templateHelper)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is a stub to augment the auto-generated code
+func (in *templateHelper) DeepCopyInto(out *templateHelper) {
+	// HACK: just re-use the template
+	out.template = in.template
+}
+
+func (h *templateHelper) execute(data interface{}) (string, error) {
+	var tmp bytes.Buffer
+	if err := h.template.Execute(&tmp, data); err != nil {
+		return "", err
+	}
+	return tmp.String(), nil
+}
+
+// expandMap is a helper for expanding a template in to a map of strings. Data
+// after executing the template is expexted to be key=value pairs separated by
+// newlines.
+func (h *templateHelper) expandMap(data interface{}) (map[string]string, error) {
+	expanded, err := h.execute(data)
+	if err != nil {
+		return nil, err
+	}
+
+	// Split out individual key-value pairs
+	out := make(map[string]string)
+	for _, item := range strings.Split(expanded, "\n") {
+		// Remove leading/trailing whitespace and skip empty lines
+		if trimmed := strings.TrimSpace(item); trimmed != "" {
+			split := strings.SplitN(trimmed, "=", 2)
+			if len(split) == 1 {
+				out[split[0]] = ""
+			} else {
+				out[split[0]] = split[1]
+			}
 		}
 	}
-	return true, nil
+	return out, nil
 }
diff --git a/pkg/apis/nfd/v1alpha1/types.go b/pkg/apis/nfd/v1alpha1/types.go
index b5f59c90e..2a6223eb6 100644
--- a/pkg/apis/nfd/v1alpha1/types.go
+++ b/pkg/apis/nfd/v1alpha1/types.go
@@ -59,6 +59,12 @@ type Rule struct {
 	// +optional
 	Labels map[string]string `json:"labels"`
 
+	// LabelsTemplate specifies a template to expand for dynamically generating
+	// multiple labels. Data (after template expansion) must be keys with an
+	// optional value (<key>[=<value>]) separated by newlines.
+	// +optional
+	LabelsTemplate string `json:"labelsTemplate"`
+
 	// MatchFeatures specifies a set of matcher terms all of which must match.
 	// +optional
 	MatchFeatures FeatureMatcher `json:"matchFeatures"`
@@ -66,6 +72,10 @@ type Rule struct {
 	// MatchAny specifies a list of matchers one of which must match.
 	// +optional
 	MatchAny []MatchAnyElem `json:"matchAny"`
+
+	// private helpers/cache for handling golang templates
+	labelsTemplate *templateHelper `json:"-"`
+	varsTemplate   *templateHelper `json:"-"`
 }
 
 // MatchAnyElem specifies one sub-matcher of MatchAny.
diff --git a/pkg/apis/nfd/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/nfd/v1alpha1/zz_generated.deepcopy.go
index 6166a118e..95c87c0d3 100644
--- a/pkg/apis/nfd/v1alpha1/zz_generated.deepcopy.go
+++ b/pkg/apis/nfd/v1alpha1/zz_generated.deepcopy.go
@@ -167,6 +167,57 @@ func (in MatchValue) DeepCopy() MatchValue {
 	return *out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in MatchedInstance) DeepCopyInto(out *MatchedInstance) {
+	{
+		in := &in
+		*out = make(MatchedInstance, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MatchedInstance.
+func (in MatchedInstance) DeepCopy() MatchedInstance {
+	if in == nil {
+		return nil
+	}
+	out := new(MatchedInstance)
+	in.DeepCopyInto(out)
+	return *out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *MatchedKey) DeepCopyInto(out *MatchedKey) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MatchedKey.
+func (in *MatchedKey) DeepCopy() *MatchedKey {
+	if in == nil {
+		return nil
+	}
+	out := new(MatchedKey)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *MatchedValue) DeepCopyInto(out *MatchedValue) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MatchedValue.
+func (in *MatchedValue) DeepCopy() *MatchedValue {
+	if in == nil {
+		return nil
+	}
+	out := new(MatchedValue)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *NodeFeatureRule) DeepCopyInto(out *NodeFeatureRule) {
 	*out = *in
@@ -271,6 +322,14 @@ func (in *Rule) DeepCopyInto(out *Rule) {
 			(*in)[i].DeepCopyInto(&(*out)[i])
 		}
 	}
+	if in.labelsTemplate != nil {
+		in, out := &in.labelsTemplate, &out.labelsTemplate
+		*out = (*in).DeepCopy()
+	}
+	if in.varsTemplate != nil {
+		in, out := &in.varsTemplate, &out.varsTemplate
+		*out = (*in).DeepCopy()
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Rule.
diff --git a/source/memory/memory.go b/source/memory/memory.go
index 823ad41fb..244503887 100644
--- a/source/memory/memory.go
+++ b/source/memory/memory.go
@@ -17,35 +17,24 @@ limitations under the License.
 package memory
 
 import (
-	"fmt"
 	"io/ioutil"
 	"os"
-	"path/filepath"
-	"strconv"
 	"strings"
 
 	"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 = "memory"
 
-const NvFeature = "nv"
-const NumaFeature = "numa"
-
-// memorySource implements the FeatureSource and LabelSource interfaces.
-type memorySource struct {
-	features *feature.DomainFeatures
-}
+// memorySource implements the LabelSource interface.
+type memorySource struct{}
 
 // Singleton source instance
 var (
 	src memorySource
-	_   source.FeatureSource = &src
-	_   source.LabelSource   = &src
+	_   source.LabelSource = &src
 )
 
 // Name returns an identifier string for this feature source.
@@ -56,107 +45,78 @@ func (s *memorySource) Priority() int { return 0 }
 
 // GetLabels method of the LabelSource interface
 func (s *memorySource) GetLabels() (source.FeatureLabels, error) {
-	labels := source.FeatureLabels{}
-	features := s.GetFeatures()
-
-	// NUMA
-	if len(features.Values[NumaFeature].Elements) > 0 {
-		labels["numa"] = true
-	}
-
-	// NVDIMM
-	if len(features.Instances[NvFeature].Elements) > 0 {
-		labels["nv.present"] = true
-	}
-	for _, dev := range features.Instances[NvFeature].Elements {
-		if dev.Attributes["devtype"] == "nd_dax" {
-			labels["nv.dax"] = true
-			break
-		}
-	}
-
-	return labels, nil
-}
-
-// Discover method of the FeatureSource interface
-func (s *memorySource) Discover() error {
-	s.features = feature.NewDomainFeatures()
+	features := source.FeatureLabels{}
 
 	// Detect NUMA
-	if numa, err := detectNuma(); err != nil {
-		klog.Errorf("failed to detect NUMA nodes: %v", err)
-	} else {
-		s.features.Values[NumaFeature] = feature.ValueFeatureSet{Elements: numa}
+	numa, err := isNuma()
+	if err != nil {
+		klog.Errorf("failed to detect NUMA topology: %s", err)
+	} else if numa {
+		features["numa"] = true
 	}
 
 	// Detect NVDIMM
-	if nv, err := detectNv(); err != nil {
-		klog.Errorf("failed to detect nvdimm devices: %v", err)
+	nv, err := detectNvdimm()
+	if err != nil {
+		klog.Errorf("NVDIMM detection failed: %s", err)
 	} else {
-		s.features.Instances[NvFeature] = feature.InstanceFeatureSet{Elements: nv}
+		for k, v := range nv {
+			features["nv."+k] = v
+		}
 	}
 
-	utils.KlogDump(3, "discovered memory features:", "  ", s.features)
-
-	return nil
-}
-
-// GetFeatures method of the FeatureSource Interface.
-func (s *memorySource) GetFeatures() *feature.DomainFeatures {
-	if s.features == nil {
-		s.features = feature.NewDomainFeatures()
-	}
-	return s.features
+	return features, nil
 }
 
-// detectNuma detects NUMA node information
-func detectNuma() (map[string]string, error) {
-	sysfsBasePath := source.SysfsDir.Path("bus/node/devices")
-
-	nodes, err := ioutil.ReadDir(sysfsBasePath)
+// Detect if the platform has NUMA topology
+func isNuma() (bool, error) {
+	// Find out how many nodes are online
+	// Multiple nodes is a sign of NUMA
+	bytes, err := ioutil.ReadFile(source.SysfsDir.Path("devices/system/node/online"))
 	if err != nil {
-		return nil, fmt.Errorf("failed to list numa nodes: %w", err)
+		return false, err
 	}
 
-	return map[string]string{"node_count": strconv.Itoa(len(nodes))}, nil
+	// File content is expected to be:
+	//   "0\n" in one-node case
+	//   "0-K\n" in N-node case where K=N-1
+	// presence of newline requires TrimSpace
+	if strings.TrimSpace(string(bytes)) != "0" {
+		// more than one node means NUMA
+		return true, nil
+	}
+	return false, nil
 }
 
-// detectNv detects NVDIMM devices
-func detectNv() ([]feature.InstanceFeature, error) {
-	sysfsBasePath := source.SysfsDir.Path("bus/nd/devices")
-	info := make([]feature.InstanceFeature, 0)
-
-	devices, err := ioutil.ReadDir(sysfsBasePath)
-	if os.IsNotExist(err) {
-		klog.V(1).Info("No NVDIMM devices present")
-		return info, nil
-	} else if err != nil {
-		return nil, fmt.Errorf("failed to list nvdimm devices: %w", err)
-	}
+// Detect NVDIMM devices and configuration
+func detectNvdimm() (map[string]bool, error) {
+	features := make(map[string]bool)
 
-	// Iterate over devices
-	for _, device := range devices {
-		i := readNdDeviceInfo(filepath.Join(sysfsBasePath, device.Name()))
-		info = append(info, i)
+	// Check presence of physical devices
+	devices, err := ioutil.ReadDir(source.SysfsDir.Path("class/nd"))
+	if err == nil {
+		if len(devices) > 0 {
+			features["present"] = true
+		}
+	} else if os.IsNotExist(err) {
+		return nil, nil
+	} else {
+		return nil, err
 	}
 
-	return info, nil
-}
-
-// ndDevAttrs is the list of sysfs files (under each nd device) that we're trying to read
-var ndDevAttrs = []string{"devtype", "mode"}
-
-func readNdDeviceInfo(path string) feature.InstanceFeature {
-	attrs := map[string]string{"name": filepath.Base(path)}
-	for _, attrName := range ndDevAttrs {
-		data, err := ioutil.ReadFile(filepath.Join(path, attrName))
-		if err != nil {
-			klog.V(3).Infof("failed to read nd device attribute %s: %w", attrName, err)
-			continue
+	// Check presence of DAX-configured regions
+	devices, err = ioutil.ReadDir(source.SysfsDir.Path("bus/nd/devices"))
+	if err == nil {
+		for _, d := range devices {
+			if strings.HasPrefix(d.Name(), "dax") {
+				features["dax"] = true
+			}
 		}
-		attrs[attrName] = strings.TrimSpace(string(data))
+	} else {
+		klog.Warningf("failed to detect NVDIMM configuration: %s", err)
 	}
-	return *feature.NewInstanceFeature(attrs)
+
+	return features, nil
 }
 
 func init() {


Test patch:
diff --git a/pkg/apis/nfd/v1alpha1/expression_test.go b/pkg/apis/nfd/v1alpha1/expression_test.go
index 33682a3b1..c84a5f3c7 100644
--- a/pkg/apis/nfd/v1alpha1/expression_test.go
+++ b/pkg/apis/nfd/v1alpha1/expression_test.go
@@ -291,23 +291,29 @@ func TestMatchValues(t *testing.T) {
 
 func TestMESMatchKeys(t *testing.T) {
 	type I = map[string]feature.Nil
+	type MK = api.MatchedKey
+	type O = []MK
 	type TC struct {
 		mes    string
 		input  I
+		output O
 		result BoolAssertionFuncf
 		err    ValueAssertionFuncf
 	}
 
 	tcs := []TC{
-		{result: assert.Truef, err: assert.Nilf},
+		{output: O{}, result: assert.Truef, err: assert.Nilf},
 
-		{input: I{"foo": {}}, result: assert.Truef, err: assert.Nilf},
+		{input: I{}, output: O{}, result: assert.Truef, err: assert.Nilf},
+
+		{input: I{"foo": {}}, output: O{MK{Name: "foo"}}, result: assert.Truef, err: assert.Nilf},
 
 		{mes: `
 foo: { op: DoesNotExist }
 bar: { op: Exists }
 `,
-			input:  I{"bar": {}, "baz": {}},
+			input:  I{"bar": {}, "baz": {}, "buzz": {}},
+			output: O{MK{Name: "bar"}, MK{Name: "foo"}},
 			result: assert.Truef, err: assert.Nilf},
 
 		{mes: `
@@ -315,6 +321,7 @@ foo: { op: DoesNotExist }
 bar: { op: Exists }
 `,
 			input:  I{"foo": {}, "bar": {}, "baz": {}},
+			output: nil,
 			result: assert.Falsef, err: assert.Nilf},
 
 		{mes: `
@@ -322,6 +329,7 @@ foo: { op: In, value: ["bar"] }
 bar: { op: Exists }
 `,
 			input:  I{"bar": {}, "baz": {}},
+			output: nil,
 			result: assert.Falsef, err: assert.NotNilf},
 	}
 
@@ -331,6 +339,10 @@ bar: { op: Exists }
 			t.Fatalf("failed to parse data of test case #%d (%v): %v", i, tc, err)
 		}
 
+		out, err := mes.MatchGetKeys(tc.input)
+		assert.Equalf(t, tc.output, out, "test case #%d (%v) failed", i, tc)
+		tc.err(t, err, "test case #%d (%v) failed", i, tc)
+
 		res, err := mes.MatchKeys(tc.input)
 		tc.result(t, res, "test case #%d (%v) failed", i, tc)
 		tc.err(t, err, "test case #%d (%v) failed", i, tc)
@@ -339,17 +351,22 @@ bar: { op: Exists }
 
 func TestMESMatchValues(t *testing.T) {
 	type I = map[string]string
+	type MV = api.MatchedValue
+	type O = []MV
 	type TC struct {
 		mes    string
 		input  I
+		output O
 		result BoolAssertionFuncf
 		err    ValueAssertionFuncf
 	}
 
 	tcs := []TC{
-		{result: assert.Truef, err: assert.Nilf},
+		{output: O{}, result: assert.Truef, err: assert.Nilf},
+
+		{input: I{}, output: O{}, result: assert.Truef, err: assert.Nilf},
 
-		{input: I{"foo": "bar"}, result: assert.Truef, err: assert.Nilf},
+		{input: I{"foo": "bar"}, output: O{MV{Name: "foo", Value: "bar"}}, result: assert.Truef, err: assert.Nilf},
 
 		{mes: `
 foo: { op: Exists }
@@ -364,7 +381,8 @@ foo: { op: Exists }
 bar: { op: In, value: ["val", "wal"] }
 baz: { op: Gt, value: ["10"] }
 `,
-			input:  I{"foo": "1", "bar": "val", "baz": "123"},
+			input:  I{"foo": "1", "bar": "val", "baz": "123", "buzz": "light"},
+			output: O{MV{Name: "bar", Value: "val"}, MV{Name: "baz", Value: "123"}, MV{Name: "foo", Value: "1"}},
 			result: assert.Truef, err: assert.Nilf},
 
 		{mes: `
@@ -382,6 +400,10 @@ baz: { op: Gt, value: ["10"] }
 			t.Fatalf("failed to parse data of test case #%d (%v): %v", i, tc, err)
 		}
 
+		out, err := mes.MatchGetValues(tc.input)
+		assert.Equalf(t, tc.output, out, "test case #%d (%v) failed", i, tc)
+		tc.err(t, err, "test case #%d (%v) failed", i, tc)
+
 		res, err := mes.MatchValues(tc.input)
 		tc.result(t, res, "test case #%d (%v) failed", i, tc)
 		tc.err(t, err, "test case #%d (%v) failed", i, tc)
@@ -390,26 +412,30 @@ baz: { op: Gt, value: ["10"] }
 
 func TestMESMatchInstances(t *testing.T) {
 	type I = feature.InstanceFeature
+	type MI = api.MatchedInstance
+	type O = []MI
 	type A = map[string]string
 	type TC struct {
 		mes    string
 		input  []I
+		output O
 		result BoolAssertionFuncf
 		err    ValueAssertionFuncf
 	}
 
 	tcs := []TC{
-		{result: assert.Falsef, err: assert.Nilf}, // nil instances -> false
+		{output: O{}, result: assert.Falsef, err: assert.Nilf}, // nil instances -> false
 
-		{input: []I{}, result: assert.Falsef, err: assert.Nilf}, // zero instances -> false
+		{input: []I{}, output: O{}, result: assert.Falsef, err: assert.Nilf}, // zero instances -> false
 
-		{input: []I{I{Attributes: A{}}}, result: assert.Truef, err: assert.Nilf}, // one "empty" instance
+		{input: []I{I{Attributes: A{}}}, output: O{A{}}, result: assert.Truef, err: assert.Nilf}, // one "empty" instance
 
 		{mes: `
 foo: { op: Exists }
 bar: { op: Lt, value: ["10"] }
 `,
 			input:  []I{I{Attributes: A{"foo": "1"}}, I{Attributes: A{"bar": "1"}}},
+			output: O{},
 			result: assert.Falsef, err: assert.Nilf},
 
 		{mes: `
@@ -417,6 +443,7 @@ foo: { op: Exists }
 bar: { op: Lt, value: ["10"] }
 `,
 			input:  []I{I{Attributes: A{"foo": "1"}}, I{Attributes: A{"foo": "2", "bar": "1"}}},
+			output: O{A{"foo": "2", "bar": "1"}},
 			result: assert.Truef, err: assert.Nilf},
 
 		{mes: `
@@ -432,6 +459,10 @@ bar: { op: Lt, value: ["10"] }
 			t.Fatalf("failed to parse data of test case #%d (%v): %v", i, tc, err)
 		}
 
+		out, err := mes.MatchGetInstances(tc.input)
+		assert.Equalf(t, tc.output, out, "test case #%d (%v) failed", i, tc)
+		tc.err(t, err, "test case #%d (%v) failed", i, tc)
+
 		res, err := mes.MatchInstances(tc.input)
 		tc.result(t, res, "test case #%d (%v) failed", i, tc)
 		tc.err(t, err, "test case #%d (%v) failed", i, tc)
diff --git a/pkg/apis/nfd/v1alpha1/rule_test.go b/pkg/apis/nfd/v1alpha1/rule_test.go
index b4c574203..6ec28673d 100644
--- a/pkg/apis/nfd/v1alpha1/rule_test.go
+++ b/pkg/apis/nfd/v1alpha1/rule_test.go
@@ -192,3 +192,107 @@ func TestRule(t *testing.T) {
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, r5.Labels, m, "instances should have matched")
 }
+
+func TestTemplating(t *testing.T) {
+	f := map[string]*feature.DomainFeatures{
+		"domain_1": &feature.DomainFeatures{
+			Keys: map[string]feature.KeyFeatureSet{
+				"kf_1": feature.KeyFeatureSet{
+					Elements: map[string]feature.Nil{
+						"key-a": feature.Nil{},
+						"key-b": feature.Nil{},
+						"key-c": feature.Nil{},
+					},
+				},
+			},
+			Values: map[string]feature.ValueFeatureSet{
+				"vf_1": feature.ValueFeatureSet{
+					Elements: map[string]string{
+						"key-1": "val-1",
+						"keu-2": "val-2",
+						"key-3": "val-3",
+					},
+				},
+			},
+			Instances: map[string]feature.InstanceFeatureSet{
+				"if_1": feature.InstanceFeatureSet{
+					Elements: []feature.InstanceFeature{
+						feature.InstanceFeature{
+							Attributes: map[string]string{
+								"attr-1": "1",
+								"attr-2": "val-2",
+							},
+						},
+						feature.InstanceFeature{
+							Attributes: map[string]string{
+								"attr-1": "10",
+								"attr-2": "val-20",
+							},
+						},
+						feature.InstanceFeature{
+							Attributes: map[string]string{
+								"attr-1": "100",
+								"attr-2": "val-200",
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	r1 := Rule{
+		Labels: map[string]string{"label-1": "label-val-1"},
+		LabelsTemplate: `
+{{range .domain_1.kf_1}}kf-{{.Name}}=present
+{{end}}
+{{range .domain_1.vf_1}}vf-{{.Name}}=vf-{{.Value}}
+{{end}}
+{{range .domain_1.if_1}}if-{{index . "attr-1"}}_{{index . "attr-2"}}=present
+{{end}}`,
+		MatchFeatures: FeatureMatcher{
+			FeatureMatcherTerm{
+				Feature: "domain_1.kf_1",
+				MatchExpressions: MatchExpressionSet{Expressions: Expressions{
+					"key-a": MustCreateMatchExpression(MatchExists),
+					"key-c": MustCreateMatchExpression(MatchExists),
+					"foo":   MustCreateMatchExpression(MatchDoesNotExist),
+				},
+				},
+			},
+			FeatureMatcherTerm{
+				Feature: "domain_1.vf_1",
+				MatchExpressions: MatchExpressionSet{Expressions: Expressions{
+					"key-1": MustCreateMatchExpression(MatchIn, "val-1", "val-2"),
+					"bar":   MustCreateMatchExpression(MatchDoesNotExist),
+				},
+				},
+			},
+			FeatureMatcherTerm{
+				Feature: "domain_1.if_1",
+				MatchExpressions: MatchExpressionSet{Expressions: Expressions{
+					"attr-1": MustCreateMatchExpression(MatchLt, "100"),
+				},
+				},
+			},
+		},
+	}
+
+	expectedLabels := map[string]string{
+		"label-1": "label-val-1",
+		// From kf_1 template
+		"kf-key-a": "present",
+		"kf-key-c": "present",
+		"kf-foo":   "present",
+		// From vf_1 template
+		"vf-key-1": "vf-val-1",
+		"vf-bar":   "vf-",
+		// From if_1 template
+		"if-1_val-2":   "present",
+		"if-10_val-20": "present",
+	}
+
+	m, err := r1.Execute(f)
+	assert.Nilf(t, err, "unexpected error: %v", err)
+	assert.Equal(t, expectedLabels, m, "instances should have matched")
+}
diff --git a/source/memory/memory_test.go b/source/memory/memory_test.go
deleted file mode 100644
index 6b82ee750..000000000
--- a/source/memory/memory_test.go
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
-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 memory
-
-import (
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"sigs.k8s.io/node-feature-discovery/pkg/api/feature"
-)
-
-func TestMemorySource(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)
-
-}