Skip to content

Commit

Permalink
pkg/apis/nfd: support label name templating
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
marquiz committed Nov 23, 2021
1 parent e259924 commit 780bcc5
Show file tree
Hide file tree
Showing 11 changed files with 453 additions and 38 deletions.
23 changes: 23 additions & 0 deletions deployment/base/nfd-crds/cr-sample.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,26 @@ 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: ["8086"]
cpu.cpuid:
AVX: {op: Exists}
6 changes: 6 additions & 0 deletions deployment/base/nfd-crds/nodefeaturerule-crd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions deployment/components/worker-config/nfd-worker.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,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}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions deployment/helm/node-feature-discovery/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,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: {}
Expand Down
87 changes: 78 additions & 9 deletions pkg/apis/nfd/v1alpha1/expression.go
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
49 changes: 40 additions & 9 deletions pkg/apis/nfd/v1alpha1/expression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,37 +291,45 @@ 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: `
foo: { op: DoesNotExist }
bar: { op: Exists }
`,
input: I{"foo": {}, "bar": {}, "baz": {}},
output: nil,
result: assert.Falsef, err: assert.Nilf},

{mes: `
foo: { op: In, value: ["bar"] }
bar: { op: Exists }
`,
input: I{"bar": {}, "baz": {}},
output: nil,
result: assert.Falsef, err: assert.NotNilf},
}

Expand All @@ -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)
Expand All @@ -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 }
Expand All @@ -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: `
Expand All @@ -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)
Expand All @@ -390,33 +412,38 @@ 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: `
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: `
Expand All @@ -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)
Expand Down
Loading

0 comments on commit 780bcc5

Please sign in to comment.