diff --git a/internal/app/wwctl/node/edit/main.go b/internal/app/wwctl/node/edit/main.go index 113bba00..bca4f818 100644 --- a/internal/app/wwctl/node/edit/main.go +++ b/internal/app/wwctl/node/edit/main.go @@ -47,7 +47,7 @@ func CobraRunE(cmd *cobra.Command, args []string) error { defer os.Remove(tempFile.Name()) if !NoHeader { - yamlTemplate := node.UnmarshalConf(node.Node{}, nil) + yamlTemplate := node.ConfToYaml(node.Node{}, nil) if _, err := tempFile.WriteString("#nodename:\n# " + strings.Join(yamlTemplate, "\n# ") + "\n"); err != nil { return err } diff --git a/internal/app/wwctl/overlay/variables/main.go b/internal/app/wwctl/overlay/variables/main.go index fdf51222..f73e6617 100644 --- a/internal/app/wwctl/overlay/variables/main.go +++ b/internal/app/wwctl/overlay/variables/main.go @@ -2,9 +2,12 @@ package variables import ( "fmt" + "sort" "strings" "github.com/spf13/cobra" + "github.com/warewulf/warewulf/internal/app/wwctl/table" + "github.com/warewulf/warewulf/internal/pkg/node" "github.com/warewulf/warewulf/internal/pkg/overlay" "github.com/warewulf/warewulf/internal/pkg/wwlog" ) @@ -20,11 +23,71 @@ func CobraRunE(cmd *cobra.Command, args []string) error { } vars := ov.ParseVars(filePath) + sort.Strings(vars) + commentMap := ov.ParseCommentVars(filePath) + varMap := node.TemplateVarMap{} + varMap.ConfToTemplateMap(node.Node{}, "") if vars == nil { return fmt.Errorf("could not parse variables for %s in overlay %s", filePath, overlayName) } + t := table.New(cmd.OutOrStdout()) + t.AddHeader("OVERLAY VARIABLE", "HELP", "TYPE", "CMD OPTION") - fmt.Println(strings.Join(vars, "\n")) + for _, v := range vars { + found := false + helpText, hasCommentHelp := commentMap[v] + for key, val := range varMap { + // fuzzy match, ignore case and try to also match singular / plural + textLower := strings.ToLower(v) + keyLower := strings.ToLower(key) + match := false + if strings.Contains(textLower, keyLower) { + match = true + } else { + keyParts := strings.Split(key, ".") + newParts := make([]string, len(keyParts)) + for i, p := range keyParts { + newParts[i] = strings.ToLower(p) + } + for i, part := range newParts { + originalPart := part + var variation string + if strings.HasSuffix(part, "s") { + variation = strings.TrimSuffix(part, "s") + } else { + variation = part + "s" + } + newParts[i] = variation + variantKey := strings.Join(newParts, ".") + if strings.Contains(textLower, variantKey) { + match = true + break + } + newParts[i] = originalPart // restore for next iteration + } + } + if match { + opt := "" + if val.LongOpt != "" { + opt = val.LongOpt + } + if hasCommentHelp { + t.AddLine(v, helpText, val.Type, opt) + } else { + t.AddLine(v, val.Comment, val.Type, opt) + } + found = true + } + } + if !found { + if hasCommentHelp { + t.AddLine(v, helpText, "", "") + } else if strings.Contains(v, "Tags") { + t.AddLine(v, "", "", "", "") + } + } + } + t.Print() return nil } diff --git a/internal/app/wwctl/overlay/variables/main_test.go b/internal/app/wwctl/overlay/variables/main_test.go index b97c150d..1b462216 100644 --- a/internal/app/wwctl/overlay/variables/main_test.go +++ b/internal/app/wwctl/overlay/variables/main_test.go @@ -2,99 +2,66 @@ package variables import ( "bytes" - "io" - "os" - "path" - "strings" "testing" "github.com/stretchr/testify/assert" "github.com/warewulf/warewulf/internal/pkg/testenv" + "github.com/warewulf/warewulf/internal/pkg/warewulfd" "github.com/warewulf/warewulf/internal/pkg/wwlog" ) func Test_Overlay_Variables(t *testing.T) { env := testenv.New(t) defer env.RemoveAll() - conf := env.Configure() - - // be quiet - wwlog.SetLogFormatter(func(int, *wwlog.LogRecord) string { return "" }) - - overlayDir := path.Join(conf.Paths.SiteOverlaydir(), "test-overlay") - err := os.MkdirAll(overlayDir, 0755) - if err != nil { - t.Fatalf("could not create overlay dir: %v", err) - } + warewulfd.SetNoDaemon() templateContent := ` +{{/* .Kernel.Tags.foo: "some help text" */}} {{ .Kernel.Tags.foo }} {{ .Node.Tags.bar }} {{ .Cluster.Tags.baz }} +{{ .Kernel.Vars }} ` - templatePath := path.Join(overlayDir, "test.ww") - err = os.WriteFile(templatePath, []byte(templateContent), 0644) - if err != nil { - t.Fatalf("could not write template file: %v", err) - } + env.WriteFile("var/lib/warewulf/overlays/test-overlay/test.ww", templateContent) - // Redirect stdout - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w + t.Run("overlay variables", func(t *testing.T) { + baseCmd := GetCommand() + buf := new(bytes.Buffer) + baseCmd.SetOut(buf) + baseCmd.SetErr(buf) + wwlog.SetLogWriter(buf) - baseCmd.SetArgs([]string{"test-overlay", "test.ww"}) - err = baseCmd.Execute() - assert.NoError(t, err) + baseCmd.SetArgs([]string{"test-overlay", "test.ww"}) + err := baseCmd.Execute() + assert.NoError(t, err) - // Restore stdout - w.Close() - os.Stdout = old + output := buf.String() + assert.Contains(t, output, "OVERLAY VARIABLE") + assert.Contains(t, output, ".Kernel.Tags.foo") + assert.Contains(t, output, "some help text") + }) - var buf bytes.Buffer - _, err = io.Copy(&buf, r) - if err != nil { - t.Fatalf("could not read stdout: %v", err) - } + t.Run("overlay variables no file", func(t *testing.T) { + baseCmd := GetCommand() + buf := new(bytes.Buffer) + baseCmd.SetOut(buf) + baseCmd.SetErr(buf) + wwlog.SetLogWriter(buf) - output := strings.TrimSpace(buf.String()) - expected := []string{ - ".Kernel.Tags.foo", - ".Node.Tags.bar", - ".Cluster.Tags.baz", - } + baseCmd.SetArgs([]string{"test-overlay", "no-file.ww"}) + err := baseCmd.Execute() + assert.Error(t, err) + }) - outputLines := strings.Split(output, "\n") - assert.ElementsMatch(t, expected, outputLines) -} - -func Test_Overlay_Variables_No_File(t *testing.T) { - env := testenv.New(t) - defer env.RemoveAll() - conf := env.Configure() - - // be quiet - wwlog.SetLogFormatter(func(int, *wwlog.LogRecord) string { return "" }) - overlayDir := path.Join(conf.Paths.SiteOverlaydir(), "test-overlay") - err := os.MkdirAll(overlayDir, 0755) - if err != nil { - t.Fatalf("could not create overlay dir: %v", err) - } - - baseCmd.SetArgs([]string{"test-overlay", "test.ww"}) - err = baseCmd.Execute() - assert.Error(t, err) -} - -func Test_Overlay_Variables_No_Overlay(t *testing.T) { - env := testenv.New(t) - defer env.RemoveAll() - env.Configure() - - // be quiet - wwlog.SetLogFormatter(func(int, *wwlog.LogRecord) string { return "" }) - - baseCmd.SetArgs([]string{"no-overlay", "test.ww"}) - err := baseCmd.Execute() - assert.Error(t, err) + t.Run("overlay variables no overlay", func(t *testing.T) { + baseCmd := GetCommand() + buf := new(bytes.Buffer) + baseCmd.SetOut(buf) + baseCmd.SetErr(buf) + wwlog.SetLogWriter(buf) + + baseCmd.SetArgs([]string{"no-overlay", "test.ww"}) + err := baseCmd.Execute() + assert.Error(t, err) + }) } diff --git a/internal/app/wwctl/overlay/variables/root.go b/internal/app/wwctl/overlay/variables/root.go index 48ec7940..6b3e2bcd 100644 --- a/internal/app/wwctl/overlay/variables/root.go +++ b/internal/app/wwctl/overlay/variables/root.go @@ -36,4 +36,3 @@ var ( func GetCommand() *cobra.Command { return baseCmd } - diff --git a/internal/app/wwctl/profile/edit/main.go b/internal/app/wwctl/profile/edit/main.go index f9f795e6..f5e0c079 100644 --- a/internal/app/wwctl/profile/edit/main.go +++ b/internal/app/wwctl/profile/edit/main.go @@ -44,7 +44,7 @@ func CobraRunE(cmd *cobra.Command, args []string) error { defer os.Remove(tempFile.Name()) if !NoHeader { - yamlTemplate := node.UnmarshalConf(node.Profile{}, nil) + yamlTemplate := node.ConfToYaml(node.Profile{}, nil) if _, err := tempFile.WriteString("#profilename:\n# " + strings.Join(yamlTemplate, "\n# ") + "\n"); err != nil { return err } diff --git a/internal/pkg/node/methods.go b/internal/pkg/node/methods.go index a5ae6a5c..4f0d722c 100644 --- a/internal/pkg/node/methods.go +++ b/internal/pkg/node/methods.go @@ -248,7 +248,7 @@ func recursiveFlatten(obj interface{}) (hasContent bool) { Create a string slice, where every element represents a yaml entry, used for node/profile edit in order to get a summary of all available elements */ -func UnmarshalConf(obj interface{}, excludeList []string) (lines []string) { +func ConfToYaml(obj interface{}, excludeList []string) (lines []string) { objType := reflect.TypeOf(obj) // now iterate of every field for i := 0; i < objType.NumField(); i++ { @@ -264,7 +264,7 @@ func UnmarshalConf(obj interface{}, excludeList []string) (lines []string) { typeLine = strings.Split(typeLine, ",")[0] + ":" } lines = append(lines, typeLine) - nestedLine := UnmarshalConf(reflect.New(field.Type.Elem()).Elem().Interface(), excludeList) + nestedLine := ConfToYaml(reflect.New(field.Type.Elem()).Elem().Interface(), excludeList) for _, ln := range nestedLine { lines = append(lines, " "+ln) } @@ -274,12 +274,12 @@ func UnmarshalConf(obj interface{}, excludeList []string) (lines []string) { typeLine = strings.Split(typeLine, ",")[0] + ":" } lines = append(lines, typeLine, " element:") - nestedLine := UnmarshalConf(reflect.New(field.Type.Elem().Elem()).Elem().Interface(), excludeList) + nestedLine := ConfToYaml(reflect.New(field.Type.Elem().Elem()).Elem().Interface(), excludeList) for _, ln := range nestedLine { lines = append(lines, " "+ln) } } else if field.Type.Kind() == reflect.Struct && field.Anonymous { - nestedLine := UnmarshalConf(reflect.New(field.Type).Elem().Interface(), excludeList) + nestedLine := ConfToYaml(reflect.New(field.Type).Elem().Interface(), excludeList) lines = append(lines, nestedLine...) } } @@ -316,9 +316,39 @@ func getYamlString(myType reflect.StructField, excludeList []string) ([]string, return []string{ymlStr}, true } -/* -Getters for unexported fields -*/ +// struectred type for variable +type TemplateVarDetails struct { + Name string + Comment string + Type string + LongOpt string +} + +// Type to sore a map which looks and feels like the variables in a template +type TemplateVarMap map[string]TemplateVarDetails + +// Fill the map so that every key is like a template variable and the value is the comment field +func (varMap TemplateVarMap) ConfToTemplateMap(obj interface{}, prefix string) { + objType := reflect.TypeOf(obj) + // now iterate of every field + for i := 0; i < objType.NumField(); i++ { + field := objType.Field(i) + if field.Type.Kind() == reflect.Ptr && field.Type.Elem().Kind() == reflect.Struct { + varMap.ConfToTemplateMap(reflect.New(field.Type.Elem()).Elem().Interface(), field.Name) + } else if field.Type.Kind() == reflect.Map && field.Type.Elem().Kind() == reflect.Ptr { + varMap.ConfToTemplateMap(reflect.New(field.Type.Elem().Elem()).Elem().Interface(), field.Name) + } else if field.Type.Kind() == reflect.Struct && field.Anonymous { + varMap.ConfToTemplateMap(reflect.New(field.Type).Elem().Interface(), field.Name) + } else { + varMap[prefix+"."+field.Name] = TemplateVarDetails{ + Name: field.Name, + Comment: field.Tag.Get("comment"), + Type: field.Type.Name(), + LongOpt: field.Tag.Get("lopt"), + } + } + } +} /* Returns the id of the node diff --git a/internal/pkg/overlay/overlay.go b/internal/pkg/overlay/overlay.go index 20f8649e..deee0ba3 100644 --- a/internal/pkg/overlay/overlay.go +++ b/internal/pkg/overlay/overlay.go @@ -16,6 +16,7 @@ import ( "github.com/Masterminds/sprig/v3" "github.com/coreos/go-systemd/v22/unit" + "gopkg.in/yaml.v2" "github.com/warewulf/warewulf/internal/pkg/config" "github.com/warewulf/warewulf/internal/pkg/node" @@ -309,6 +310,44 @@ func (overlay Overlay) ParseVars(file string) []string { return result } +// ParseCommentVars parses a template file for comments that contain variable documentations. +// The comments must be in the format `{{/* key: value */}}`. The content is parsed as YAML. +func (overlay Overlay) ParseCommentVars(file string) map[string]string { + if !strings.HasSuffix(file, ".ww") { + return nil + } + fullPath := overlay.File(file) + if !util.IsFile(fullPath) { + wwlog.Error("Template file does not exist in overlay %s: %s", overlay.Name(), file) + return nil + } + + content, err := os.ReadFile(fullPath) + if err != nil { + wwlog.Error("Could not read template file %s: %s", fullPath, err) + return nil + } + + vars := make(map[string]string) + re := regexp.MustCompile(`{{\s*/\*(.*?)\*/\s*}}`) + matches := re.FindAllStringSubmatch(string(content), -1) + + for _, match := range matches { + commentContent := strings.TrimSpace(match[1]) + var data map[string]string + err := yaml.Unmarshal([]byte(commentContent), &data) + if err == nil { + for k, v := range data { + vars[k] = v + } + } else { + wwlog.Debug("Could not parse template comment as yaml in file %s: %s", file, err) + } + } + + return vars +} + // walkParseTree recursively traverses the template's parse tree to find variables. func walkParseTree(node parse.Node, vars map[string]bool) { if node == nil { @@ -343,9 +382,7 @@ func walkParseTree(node parse.Node, vars map[string]bool) { } } case *parse.VariableNode, *parse.FieldNode: - if strings.Contains(n.String(), "Tags") { - vars[n.String()] = true - } + vars[n.String()] = true } }