Skip to content

feat: add ability to export feature labels for static node #2170

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

Closed
wants to merge 1 commit into from
Closed
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-worker/flags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package main

// boolOrStringValue is a custom flag type that can be a boolean or a non-empty string.
// We need it to support nfd-worker --export, which can be set (true) or accept
// an export value (--export=file.json). It satisfies the flag.Value interface.
type boolOrStringValue struct {
// Fistinguish "not set" (false) from "set as a boolean" (true).
IsSet bool

// Value holds the string provided.
// If the flag is used as a boolean (e.g., -flag), this value will be "true".
Value string
}

// String is the method to format the flag's value for printing.
// It's part of the flag.Value interface.
func (b *boolOrStringValue) String() string {
if !b.IsSet {
// Represents the "not set" state
return ""
}
return b.Value
}

// Set parses the flag's value from the command line.
func (b *boolOrStringValue) Set(s string) error {
// When Set is called, we know the flag was present on the command line.
b.IsSet = true
b.Value = s
return nil
}

// IsBoolFlag is an optional method that makes the flag behave like a boolean.
func (b *boolOrStringValue) IsBoolFlag() bool {
return true
}
22 changes: 20 additions & 2 deletions cmd/nfd-worker/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ func main() {
}

func parseArgs(flags *flag.FlagSet, osArgs ...string) *worker.Args {
args, overrides := initFlags(flags)

// We allow exportFlag to be a boolean or string, and need to check it here
var exportFlag boolOrStringValue
args, overrides := initFlags(flags, &exportFlag)

_ = flags.Parse(osArgs)
if len(flags.Args()) > 0 {
Expand All @@ -82,6 +85,20 @@ func parseArgs(flags *flag.FlagSet, osArgs ...string) *worker.Args {
os.Exit(2)
}

// Was export set?
if exportFlag.IsSet {

// Case 1: Only -export is defined, print labels to screen
if exportFlag.Value == "true" {
args.Export = true

} else {
// Case 2: a specific filename is provided to export to
args.Export = true
args.ExportPath = exportFlag.Value
}
}

// Handle overrides
flags.Visit(func(f *flag.Flag) {
switch f.Name {
Expand All @@ -99,7 +116,7 @@ func parseArgs(flags *flag.FlagSet, osArgs ...string) *worker.Args {
return args
}

func initFlags(flagset *flag.FlagSet) (*worker.Args, *worker.ConfigOverrideArgs) {
func initFlags(flagset *flag.FlagSet, exportFlag *boolOrStringValue) (*worker.Args, *worker.ConfigOverrideArgs) {
args := &worker.Args{}

flagset.StringVar(&args.ConfigFile, "config", "/etc/kubernetes/node-feature-discovery/nfd-worker.conf",
Expand All @@ -113,6 +130,7 @@ func initFlags(flagset *flag.FlagSet) (*worker.Args, *worker.ConfigOverrideArgs)
flagset.StringVar(&args.Options, "options", "",
"Specify config options from command line. Config options are specified "+
"in the same format as in the config file (i.e. json or yaml). These options")
flagset.Var(exportFlag, "export", "Export features from a static, non-Kubernetes node. Can be boolean (-export) or path (-export=<path>).")

args.Klog = klogutils.InitKlogFlags(flagset)

Expand Down
21 changes: 20 additions & 1 deletion docs/usage/nfd-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ config option.

NFD-Worker supports configuration through a configuration file. The
default location is `/etc/kubernetes/node-feature-discovery/nfd-worker.conf`,
but, this can be changed by specifying the`-config` command line flag.
but, this can be changed by specifying the `-config` command line flag.
Configuration file is re-read whenever it is modified which makes run-time
re-configuration of nfd-worker straightforward.

Expand Down Expand Up @@ -63,3 +63,22 @@ file must be used, i.e. JSON (or YAML). For example:

Configuration options specified from the command line will override those read
from the config file.

## Worker export

The nfd worker supports an export mode, where metadata labels can be derived without requiring a Kubernetes context.
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 use export, you can simply add the `--export` flag to the worker start command:

```bash
nfd-worker --export
```

By default, the labels will be printed to the screen. If you want to export to a file path:

```bash
nfd-worker --export=labels.json
```


37 changes: 35 additions & 2 deletions pkg/nfd-worker/nfd-worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,10 @@ type Args struct {
Port int
NoOwnerRefs bool

Overrides ConfigOverrideArgs
// Options for export
Export bool
ExportPath string
Overrides ConfigOverrideArgs
}

// ConfigOverrideArgs are args that override config file options
Expand Down Expand Up @@ -178,7 +181,8 @@ func NewNfdWorker(opts ...NfdWorkerOption) (NfdWorker, error) {
}

// k8sClient might've been set via opts by tests
if nfd.k8sClient == nil {
// Export mode does not assume in Kubernetes environment
if nfd.k8sClient == nil && !nfd.args.Export {
Copy link
Contributor

@mfranczy mfranczy Jun 20, 2025

Choose a reason for hiding this comment

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

I believe it's better to keep the worker k8s centric (as it implements the logic for updating k8s resources), operate on raw features rather than labels, and add a new subcommand to the nfd client for dumping features.

Raw features can easily be transformed into labels if needed. However, since labels are derived from raw features and the worker configuration, relying solely on labels may cause certain node capabilities to be missed due to default or custom configurations.

The same principle applies to the image compatibility implementation in NFD, where comparisons are performed by checking the features (not the labels).

Thoughts?

Copy link
Author

Choose a reason for hiding this comment

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

I agree - I'll update the PR this week. Thanks @mfranczy!

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, I tend to agree as well. Mostly because much of the nfd-worker code is currently heavily based on the assumption that we're running in a kubernetes environment. Starting from the assumption that a kubeconfig must be found... Wiring the export functionality nicely into nfd-worker would require heavy refactoring, I believe, and it's much easier to put the stuff into the nfd client (I believe) as @mfranczy suggested. Especially if/when you're not interested in all the config options of nfd-worker.

kubeconfig, err := utils.GetKubeconfig(nfd.args.Kubeconfig)
if err != nil {
return nfd, err
Expand Down Expand Up @@ -221,6 +225,7 @@ func (i *infiniteTicker) Reset(d time.Duration) {
}

// Run feature discovery.
// singleExport will derive labels and save or print
func (w *nfdWorker) runFeatureDiscovery() error {
discoveryStart := time.Now()
for _, s := range w.featureSources {
Expand All @@ -240,6 +245,29 @@ func (w *nfdWorker) runFeatureDiscovery() error {
// Get the set of feature labels.
labels := createFeatureLabels(w.labelSources, w.config.Core.LabelWhiteList.Regexp)

// Show exported labels and exit
if w.args.Export {
exportedLabels, err := json.MarshalIndent(labels, "", " ")
if err != nil {
return err
}
// No export path provided
if w.args.ExportPath == "" {
fmt.Println(string(exportedLabels))
} else {

// Export labels to provided filename
fd, err := os.Create(w.args.ExportPath)
if err != nil {
return err
}
defer fd.Close()
_, err = fmt.Fprint(fd, string(exportedLabels))
return err
}
return err
}

// Update the node with the feature labels.
if !w.config.Core.NoPublish {
return w.advertiseFeatures(labels)
Expand Down Expand Up @@ -304,6 +332,11 @@ func (w *nfdWorker) Run() error {
labelTrigger.Reset(w.config.Core.SleepInterval.Duration)
defer labelTrigger.Stop()

// If we are exporting and don't assume Kubernetes, don't set up prometheus, etc.
if w.args.Export {
return w.runFeatureDiscovery()
}

httpMux := http.NewServeMux()

// Register to metrics server
Expand Down
77 changes: 77 additions & 0 deletions pkg/nfd-worker/nfd-worker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ limitations under the License.
package nfdworker_test

import (
"encoding/json"
"os"
"path/filepath"
"testing"

. "github.com/smartystreets/goconvey/convey"
Expand Down Expand Up @@ -134,3 +136,78 @@ func TestRun(t *testing.T) {
})
})
}

func TestExport(t *testing.T) {
nfdCli := fakenfdclient.NewSimpleClientset()
initializeFeatureGates()
Convey("When running nfd-worker", t, func() {
Convey("When requesting export to terminal", func() {
args := &worker.Args{
Export: true,
Overrides: worker.ConfigOverrideArgs{
FeatureSources: &utils.StringSliceVal{"fake"},
LabelSources: &utils.StringSliceVal{"fake"},
},
}
w, _ := worker.NewNfdWorker(
worker.WithArgs(args),
worker.WithKubernetesClient(fakeclient.NewSimpleClientset()),
worker.WithNFDClient(nfdCli),
)
err := w.Run()
Convey("No error should be returned", func() {
So(err, ShouldBeNil)
})
})
})
}

func TestExportToFile(t *testing.T) {
nfdCli := fakenfdclient.NewSimpleClientset()
initializeFeatureGates()
tempDir := t.TempDir()
exportFilePath := filepath.Join(tempDir, "labels.json")
Convey("When running nfd-worker", t, func() {
Convey("When requesting export to terminal", func() {
args := &worker.Args{
Export: true,
ExportPath: exportFilePath,
Overrides: worker.ConfigOverrideArgs{
FeatureSources: &utils.StringSliceVal{"fake"},
LabelSources: &utils.StringSliceVal{"fake"},
},
}
w, _ := worker.NewNfdWorker(
worker.WithArgs(args),
worker.WithKubernetesClient(fakeclient.NewSimpleClientset()),
worker.WithNFDClient(nfdCli),
)
err := w.Run()
Convey("No error should be returned", func() {
So(err, ShouldBeNil)
})

Convey("The output file should be created with labels", func() {

_, err := os.Stat(exportFilePath)
So(os.IsNotExist(err), ShouldBeFalse)

rawLabels, err := os.ReadFile(exportFilePath)
So(err, ShouldBeNil)
So(len(rawLabels), ShouldBeGreaterThan, 0)

// Unmarshal the JSON into labels
var labels worker.Labels
err = json.Unmarshal(rawLabels, &labels)
Convey("The output should unmarshal to valid JSON", func() {
So(err, ShouldBeNil)
})
Convey("The Labels should not be empty", func() {
So(labels, ShouldNotBeNil)
So(len(labels), ShouldBeGreaterThan, 0)
So(labels, ShouldContainKey, "feature.node.kubernetes.io/fake-fakefeature1")
})
})
})
})
}