diff --git a/cmd/nfd-worker/flags.go b/cmd/nfd-worker/flags.go new file mode 100644 index 0000000000..449d4f0b17 --- /dev/null +++ b/cmd/nfd-worker/flags.go @@ -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 +} diff --git a/cmd/nfd-worker/main.go b/cmd/nfd-worker/main.go index 120db537ab..3e902f91d6 100644 --- a/cmd/nfd-worker/main.go +++ b/cmd/nfd-worker/main.go @@ -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 { @@ -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 { @@ -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", @@ -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=).") args.Klog = klogutils.InitKlogFlags(flagset) diff --git a/docs/usage/nfd-worker.md b/docs/usage/nfd-worker.md index 7e9b9b0f3b..208d4160c3 100644 --- a/docs/usage/nfd-worker.md +++ b/docs/usage/nfd-worker.md @@ -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. @@ -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 +``` + + diff --git a/pkg/nfd-worker/nfd-worker.go b/pkg/nfd-worker/nfd-worker.go index f54a901ec5..3cb3c44434 100644 --- a/pkg/nfd-worker/nfd-worker.go +++ b/pkg/nfd-worker/nfd-worker.go @@ -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 @@ -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 { kubeconfig, err := utils.GetKubeconfig(nfd.args.Kubeconfig) if err != nil { return nfd, err @@ -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 { @@ -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) @@ -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 diff --git a/pkg/nfd-worker/nfd-worker_test.go b/pkg/nfd-worker/nfd-worker_test.go index 1095b718e3..5ab2e16321 100644 --- a/pkg/nfd-worker/nfd-worker_test.go +++ b/pkg/nfd-worker/nfd-worker_test.go @@ -17,7 +17,9 @@ limitations under the License. package nfdworker_test import ( + "encoding/json" "os" + "path/filepath" "testing" . "github.com/smartystreets/goconvey/convey" @@ -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") + }) + }) + }) + }) +}