Skip to content

feat: add ability to export features for static node #2183

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions cmd/nfd/subcmd/export/export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
Copyright 2025 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 export

import (
"fmt"
"os"

"github.com/spf13/cobra"
)

var ExportCmd = &cobra.Command{
Use: "export",
Short: "Export commands",
}

func Execute() {
if err := ExportCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
68 changes: 68 additions & 0 deletions cmd/nfd/subcmd/export/features.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
Copyright 2025 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 export

import (
"encoding/json"
"fmt"

"github.com/spf13/cobra"

"sigs.k8s.io/node-feature-discovery/source"
)

func NewExportCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "features",
Short: "Export features for given node",
RunE: func(cmd *cobra.Command, args []string) error {
sources := map[string]source.FeatureSource{}
for k, v := range source.GetAllFeatureSources() {
if ts, ok := v.(source.SupplementalSource); ok && ts.DisableByDefault() {
continue
}
sources[k] = v
}

// Discover all feature sources
for _, s := range sources {
if err := s.Discover(); err != nil {
return fmt.Errorf("error during discovery of source %s: %w", s.Name(), err)
}
}

features := source.GetAllFeatures()
exportedLabels, err := json.MarshalIndent(features, "", " ")
if err != nil {
return err
}

if outputPath != "" {
err = writeToFile(outputPath, string(exportedLabels))
} else {
fmt.Println(string(exportedLabels))
}
return err
},
}
cmd.Flags().StringVar(&outputPath, "path", "", "export to this JSON path")
return cmd
}

func init() {
ExportCmd.AddCommand(NewExportCmd())
}
102 changes: 102 additions & 0 deletions cmd/nfd/subcmd/export/labels.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
Copyright 2025 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 export

import (
"encoding/json"
"fmt"
"maps"
"regexp"
"slices"
"sort"

"github.com/spf13/cobra"
"k8s.io/klog/v2"

worker "sigs.k8s.io/node-feature-discovery/pkg/nfd-worker"
"sigs.k8s.io/node-feature-discovery/source"
)

func NewLabelsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "labels",
Short: "Export feature labels for given node",
RunE: func(cmd *cobra.Command, args []string) error {

// Determine enabled feature sources
featureSources := make(map[string]source.FeatureSource)
for n, s := range source.GetAllFeatureSources() {
if ts, ok := s.(source.SupplementalSource); !ok || !ts.DisableByDefault() {
err := s.Discover()
if err != nil {
return err
}
featureSources[n] = s
}
}
featureSourceList := slices.Collect(maps.Values(featureSources))
sort.Slice(featureSourceList, func(i, j int) bool { return featureSourceList[i].Name() < featureSourceList[j].Name() })

// Determine enabled label sources
labelSources := make(map[string]source.LabelSource)
for n, s := range source.GetAllLabelSources() {
if ts, ok := s.(source.SupplementalSource); !ok || !ts.DisableByDefault() {
labelSources[n] = s
}
}
labelSourcesList := slices.Collect(maps.Values(labelSources))
sort.Slice(labelSourcesList, func(i, j int) bool {
iP, jP := labelSourcesList[i].Priority(), labelSourcesList[j].Priority()
if iP != jP {
return iP < jP
}
return labelSourcesList[i].Name() < labelSourcesList[j].Name()
})

labels := worker.Labels{}
labelWhiteList := *regexp.MustCompile("")

// Get labels from all enabled label sources
for _, source := range labelSourcesList {
labelsFromSource, err := worker.GetFeatureLabels(source, labelWhiteList)
if err != nil {
klog.ErrorS(err, "discovery failed", "source", source.Name())
continue
}
maps.Copy(labels, labelsFromSource)
}

exportedLabels, err := json.MarshalIndent(labels, "", " ")
if err != nil {
return err
}

if outputPath != "" {
err = writeToFile(outputPath, string(exportedLabels))
} else {
fmt.Println(string(exportedLabels))
}
return err
},
}
cmd.Flags().StringVar(&outputPath, "path", "", "export to this JSON path")
return cmd
}

func init() {
ExportCmd.AddCommand(NewLabelsCmd())
}
37 changes: 37 additions & 0 deletions cmd/nfd/subcmd/export/utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
Copyright 2025 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 export

import (
"fmt"
"os"
)

var (
outputPath string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If Var is global for the pkg export, why have it as an entry value for writeToFile?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is global (and private) because it's a shared flag, and I usually see those defined in this way. For the latter, I think it's better practice to not have package functions rely on global variables.

)

// writeToFile saves string content to a file at the path set by path
func writeToFile(path, content string) error {
fd, err := os.Create(path)
if err != nil {
return err
}
defer fd.Close()
_, err = fmt.Fprint(fd, content)
return err
}
2 changes: 2 additions & 0 deletions cmd/nfd/subcmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/spf13/cobra"

"sigs.k8s.io/node-feature-discovery/cmd/nfd/subcmd/compat"
"sigs.k8s.io/node-feature-discovery/cmd/nfd/subcmd/export"
)

// RootCmd represents the base command when called without any subcommands
Expand All @@ -33,6 +34,7 @@ var RootCmd = &cobra.Command{

func init() {
RootCmd.AddCommand(compat.CompatCmd)
RootCmd.AddCommand(export.ExportCmd)
}

// Execute adds all child commands to the root command and sets flags appropriately.
Expand Down
58 changes: 58 additions & 0 deletions docs/usage/nfd-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
title: "Export"
layout: default
sort: 12
---

# Feature Export
{: .no_toc}

## Table of contents
{: .no_toc .text-delta}

1. TOC
{:toc}

---

## Export

If you are interested in exporting features or labels in a generic
context, the nfd client supports an export mode, where both can be
derived on the command line.

### Features

**Feature export is in the experimental version.**

This addresses use cases such as high performance computing (HPC) and
other environments with compute nodes that warrant assessment, but may
not have Kubernetes running, or may not be able to or want to run a
central daemon service for data. To export features, you can use `nfd
export features`:

```bash
nfd export features
```

By default, JSON structure with parsed key value pairs will appear in the
terminal. To save to a file path:

```bash
nfd export features --path features.json
```

### Labels

To export equivalent labels outside of a Kubernetes context,
you can use `nfd export labels`.

```bash
nfd export labels
```

Or export to an output file:

```bash
nfd export labels --path labels.json
```
4 changes: 2 additions & 2 deletions pkg/nfd-worker/nfd-worker-internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func TestGetLabelsWithMockSources(t *testing.T) {
mockLabelSource.On("Name").Return(fakeLabelSourceName)
mockLabelSource.On("GetLabels").Return(fakeFeatures, nil)

returnedLabels, err := getFeatureLabels(fakeLabelSource, labelWhiteList.Regexp)
returnedLabels, err := GetFeatureLabels(fakeLabelSource, labelWhiteList.Regexp)
Convey("Proper label is returned", func() {
So(returnedLabels, ShouldResemble, fakeFeatureLabels)
})
Expand All @@ -67,7 +67,7 @@ func TestGetLabelsWithMockSources(t *testing.T) {
expectedError := errors.New("fake error")
mockLabelSource.On("GetLabels").Return(nil, expectedError)

returnedLabels, err := getFeatureLabels(fakeLabelSource, labelWhiteList.Regexp)
returnedLabels, err := GetFeatureLabels(fakeLabelSource, labelWhiteList.Regexp)
Convey("No label is returned", func() {
So(returnedLabels, ShouldBeNil)
})
Expand Down
4 changes: 2 additions & 2 deletions pkg/nfd-worker/nfd-worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,7 @@ func createFeatureLabels(sources []source.LabelSource, labelWhiteList regexp.Reg
// Get labels from all enabled label sources
klog.InfoS("starting feature discovery...")
for _, source := range sources {
labelsFromSource, err := getFeatureLabels(source, labelWhiteList)
labelsFromSource, err := GetFeatureLabels(source, labelWhiteList)
if err != nil {
klog.ErrorS(err, "discovery failed", "source", source.Name())
continue
Expand All @@ -555,7 +555,7 @@ func createFeatureLabels(sources []source.LabelSource, labelWhiteList regexp.Reg

// getFeatureLabels returns node labels for features discovered by the
// supplied source.
func getFeatureLabels(source source.LabelSource, labelWhiteList regexp.Regexp) (labels Labels, err error) {
func GetFeatureLabels(source source.LabelSource, labelWhiteList regexp.Regexp) (labels Labels, err error) {
labels = Labels{}
features, err := source.GetLabels()
if err != nil {
Expand Down