diff --git a/README.md b/README.md index 9f4728ef7..d215db1a2 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ the only label value published for features is the string `"true"`._ "feature.node.kubernetes.io/rdt-": "true", "feature.node.kubernetes.io/storage-": "true", "feature.node.kubernetes.io/system-": "", - "feature.node.kubernetes.io/-": "" + "feature.node.kubernetes.io/-": "" } ``` @@ -195,36 +195,40 @@ See [configuration options](#configuration-options) for more information. ### Local (User-specific Features) -NFD has a special feature source named *local* which is designed for running -user-specific feature detector hooks. It provides a mechanism for users to +NFD has a special feature source named *local* which is designed for getting the +labels from user-specific feature detector. It provides a mechanism for users to implement custom feature sources in a pluggable way, without modifying nfd source code or Docker images. The local feature source can be used to advertise new user-specific features, and, for overriding labels created by the other feature sources. -The *local* feature source tries to execute files found under -`/etc/kubernetes/node-feature-discovery/source.d/` directory. The hooks must be -available inside the Docker image so Volumes and VolumeMounts must be used if -standard NFD images are used. +The *local* feature source gets its labels by two different ways: +* It tries to execute files found under `/etc/kubernetes/node-feature-discovery/source.d/` +directory. The hook files must be executable. When executed, the hooks are +supposed to print all discovered features in `stdout`, one per line. +* It reads files found under `/etc/kubernetes/node-feature-discovery/features.d/` +directory. The file content is expected to be similar to the hook output (described above). -The hook files must be executable. When executed, the hooks are supposed to -print all discovered features in `stdout`, one feature per line. Hooks can -advertise both binary and non-binary labels, using either `` or -`=` output format. +These directories must be available inside the Docker image so Volumes and +VolumeMounts must be used if standard NFD images are used. -Unlike the other feature sources, the name of the hook, instead of the name of +In both cases, the labels can be binary or non binary, using either `` or +`=` format. + +Unlike the other feature sources, the name of the file, instead of the name of the feature source (that would be `local` in this case), is used as a prefix in -the label name, normally. However, if the `` printed by the hook starts -with a slash (`/`) it is used as the label name as is, without any additional -prefix. This makes it possible for the hooks to fully control the feature -label names, e.g. for overriding labels created by other feature sources. +the label name, normally. However, if the `` of the label starts with a +slash (`/`) it is used as the label name as is, without any additional prefix. +This makes it possible for the user to fully control the feature label names, +e.g. for overriding labels created by other feature sources. The value of the label is either `true` (for binary labels) or `` (for non-binary labels). + `stderr` output of the hooks is propagated to NFD log so it can be used for debugging and logging. -**An example:**
+**A hook example:**
User has a shell script `/etc/kubernetes/node-feature-discovery/source.d/my-source` which has the following `stdout` output: @@ -242,6 +246,24 @@ feature.node.kubernetes.io/override_source-OVERRIDE_BOOL=true feature.node.kubernetes.io/override_source-OVERRIDE_VALUE=123 ``` +**A file example:**
+User has a file +`/etc/kubernetes/node-feature-discovery/features.d/my-source` which contains the +following lines: +``` +MY_FEATURE_1 +MY_FEATURE_2=myvalue +/override_source-OVERRIDE_BOOL +/override_source-OVERRIDE_VALUE=123 +``` +which, in turn, will translate into the following node labels: +``` +feature.node.kubernetes.io/my-source-MY_FEATURE_1=true +feature.node.kubernetes.io/my-source-MY_FEATURE_2=myvalue +feature.node.kubernetes.io/override_source-OVERRIDE_BOOL=true +feature.node.kubernetes.io/override_source-OVERRIDE_VALUE=123 +``` + NFD tries to run any regular files found from the hooks directory. Any additional data files your hook might need (e.g. a configuration file) should be placed in a separate directory in order to avoid NFD unnecessarily trying to diff --git a/source/local/local.go b/source/local/local.go index 84932d31c..af655d7b4 100644 --- a/source/local/local.go +++ b/source/local/local.go @@ -31,8 +31,9 @@ import ( // Config var ( - hookDir = "/etc/kubernetes/node-feature-discovery/source.d/" - logger = log.New(os.Stderr, "", log.LstdFlags) + featureFilesDir = "/etc/kubernetes/node-feature-discovery/features.d/" + hookDir = "/etc/kubernetes/node-feature-discovery/source.d/" + logger = log.New(os.Stderr, "", log.LstdFlags) ) // Implement FeatureSource interface @@ -41,6 +42,55 @@ type Source struct{} func (s Source) Name() string { return "local" } func (s Source) Discover() (source.Features, error) { + featuresFromHooks, err := getFeaturesFromHooks() + if err != nil { + log.Printf(err.Error()) + } + + featuresFromFiles, err := getFeaturesFromFiles() + if err != nil { + log.Printf(err.Error()) + } + + // Merge features from hooks and files + for k, v := range featuresFromHooks { + if old, ok := featuresFromFiles[k]; ok { + log.Printf("WARNING: overriding label '%s': value changed from '%s' to '%s'", + k, old, v) + } + featuresFromFiles[k] = v + } + + return featuresFromFiles, nil +} + +func parseFeatures(lines [][]byte, prefix string) source.Features { + features := source.Features{} + + for _, line := range lines { + if len(line) > 0 { + lineSplit := strings.SplitN(string(line), "=", 2) + + // Check if we need to add prefix + key := prefix + "-" + lineSplit[0] + if lineSplit[0][0] == '/' { + key = lineSplit[0][1:] + } + + // Check if it's a boolean value + if len(lineSplit) == 1 { + features[key] = "true" + } else { + features[key] = lineSplit[1] + } + } + } + + return features +} + +// Run all hooks and get features +func getFeaturesFromHooks() (source.Features, error) { features := source.Features{} files, err := ioutil.ReadDir(hookDir) @@ -53,20 +103,20 @@ func (s Source) Discover() (source.Features, error) { } for _, file := range files { - hook := file.Name() - hookFeatures, err := runHook(hook) + fileName := file.Name() + lines, err := runHook(fileName) if err != nil { - log.Printf("ERROR: source hook '%v' failed: %v", hook, err) + log.Printf("ERROR: source local failed running hook '%v': %v", fileName, err) continue } - for feature, value := range hookFeatures { - if feature[0] == '/' { - // Use feature name as the label as is if it is prefixed with a slash - features[feature[1:]] = value - } else { - // Normally, use hook name as label prefix - features[hook+"-"+feature] = value + + // Append features + for k, v := range parseFeatures(lines, fileName) { + if old, ok := features[k]; ok { + log.Printf("WARNING: overriding label '%s' from another hook (%s): value changed from '%s' to '%s'", + k, fileName, old, v) } + features[k] = v } } @@ -74,14 +124,14 @@ func (s Source) Discover() (source.Features, error) { } // Run one hook -func runHook(file string) (map[string]string, error) { - features := map[string]string{} +func runHook(file string) ([][]byte, error) { + var lines [][]byte path := filepath.Join(hookDir, file) filestat, err := os.Stat(path) if err != nil { log.Printf("ERROR: skipping %v, failed to get stat: %v", path, err) - return features, err + return lines, err } if filestat.Mode().IsRegular() { @@ -95,33 +145,79 @@ func runHook(file string) (map[string]string, error) { err = cmd.Run() // Forward stderr to our logger - lines := bytes.Split(stderr.Bytes(), []byte("\n")) - for i, line := range lines { - if i == len(lines)-1 && len(line) == 0 { + errLines := bytes.Split(stderr.Bytes(), []byte("\n")) + for i, line := range errLines { + if i == len(errLines)-1 && len(line) == 0 { // Don't print the last empty string break } log.Printf("%v: %s", file, line) } - // Do not return any features if an error occurred + // Do not return any lines if an error occurred if err != nil { - return features, err + return lines, err + } + lines = bytes.Split(stdout.Bytes(), []byte("\n")) + } + + return lines, nil +} + +// Read all files to get features +func getFeaturesFromFiles() (source.Features, error) { + features := source.Features{} + + files, err := ioutil.ReadDir(featureFilesDir) + if err != nil { + if os.IsNotExist(err) { + log.Printf("ERROR: features directory %v does not exist", featureFilesDir) + return features, nil + } + return features, fmt.Errorf("Unable to access %v: %v", featureFilesDir, err) + } + + for _, file := range files { + fileName := file.Name() + lines, err := getFileContent(fileName) + if err != nil { + log.Printf("ERROR: source local failed reading file '%v': %v", fileName, err) + continue } - // Return features printed to stdout - lines = bytes.Split(stdout.Bytes(), []byte("\n")) - for _, line := range lines { - if len(line) > 0 { - lineSplit := strings.SplitN(string(line), "=", 2) - if len(lineSplit) == 1 { - features[lineSplit[0]] = "true" - } else { - features[lineSplit[0]] = lineSplit[1] - } + // Append features + for k, v := range parseFeatures(lines, fileName) { + if old, ok := features[k]; ok { + log.Printf("WARNING: overriding label '%s' from another features.d file (%s): value changed from '%s' to '%s'", + k, fileName, old, v) } + features[k] = v } } return features, nil } + +// Read one file +func getFileContent(fileName string) ([][]byte, error) { + var lines [][]byte + + path := filepath.Join(featureFilesDir, fileName) + filestat, err := os.Stat(path) + if err != nil { + log.Printf("ERROR: skipping %v, failed to get stat: %v", path, err) + return lines, err + } + + if filestat.Mode().IsRegular() { + fileContent, err := ioutil.ReadFile(path) + + // Do not return any lines if an error occurred + if err != nil { + return lines, err + } + lines = bytes.Split(fileContent, []byte("\n")) + } + + return lines, nil +}