From 5d284b1c61de16eae3eae3fbeb28014248344d00 Mon Sep 17 00:00:00 2001 From: Christian Goll Date: Wed, 17 Sep 2025 15:09:21 +0200 Subject: [PATCH 01/11] show used Tags for overlays --- CHANGELOG.md | 1 + internal/app/wwctl/overlay/list/main.go | 15 ++-- internal/pkg/overlay/overlay.go | 115 ++++++++++++++++++++---- 3 files changed, 111 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88ab83ea..15871e58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- `wwctl overlay show -l` will now also list the used tags in a template - Initial support for EL10 using `dnsmasq` in place of `dhcpd-server`. #1974 - `make rpm` for local development rpm builds. #1974 - `wwclient --once` prompts wwclient to run once. #1226 diff --git a/internal/app/wwctl/overlay/list/main.go b/internal/app/wwctl/overlay/list/main.go index d75da8ee..62a1b738 100644 --- a/internal/app/wwctl/overlay/list/main.go +++ b/internal/app/wwctl/overlay/list/main.go @@ -3,6 +3,7 @@ package list import ( "os" "strconv" + "strings" "syscall" "github.com/spf13/cobra" @@ -33,7 +34,7 @@ func CobraRunE(vars *variables) func(cmd *cobra.Command, args []string) error { locationStr = "PATH" } if vars.ListLong { - t.AddHeader("PERM MODE", "UID", "GID", "OVERLAY", "FILE PATH", locationStr) + t.AddHeader("PERM MODE", "UID", "GID", "OVERLAY", "FILE PATH", locationStr, "VARS") } else { t.AddHeader("OVERLAY NAME", "FILES/DIRS", locationStr) } @@ -50,10 +51,14 @@ func CobraRunE(vars *variables) func(cmd *cobra.Command, args []string) error { wwlog.Debug("Iterating overlay rootfs: %s", overlay_.Rootfs()) if vars.ListLong { - for file := range files { - s, err := os.Stat(overlay_.File(files[file])) + for _, file := range files { + templateVars := []string{} + if !strings.HasSuffix(file, "/") { + templateVars = overlay_.ParseVars(file) + } + s, err := os.Stat(overlay_.File(file)) if err != nil { - wwlog.Warn("%s: %s: %s", name, files[file], err) + wwlog.Warn("%s: %s: %s", name, file, err) continue } fileMode := s.Mode() @@ -63,7 +68,7 @@ func CobraRunE(vars *variables) func(cmd *cobra.Command, args []string) error { if vars.ShowPath { locLine = overlay_.Path() } - t.AddLine(perms, sys.(*syscall.Stat_t).Uid, sys.(*syscall.Stat_t).Gid, name, files[file], locLine) + t.AddLine(perms, sys.(*syscall.Stat_t).Uid, sys.(*syscall.Stat_t).Gid, name, file, locLine, templateVars) } } else { locLine := strconv.FormatBool(overlay_.IsSiteOverlay()) diff --git a/internal/pkg/overlay/overlay.go b/internal/pkg/overlay/overlay.go index c0c38547..20f8649e 100644 --- a/internal/pkg/overlay/overlay.go +++ b/internal/pkg/overlay/overlay.go @@ -12,6 +12,7 @@ import ( "strings" "sync" "text/template" + "text/template/parse" "github.com/Masterminds/sprig/v3" "github.com/coreos/go-systemd/v22/unit" @@ -272,6 +273,82 @@ func (overlay Overlay) Mkdir(path string, mode int32) (err error) { return os.MkdirAll(fullPath, os.FileMode(mode)) } +// returns a list of the variable names for the given template +func (overlay Overlay) ParseVars(file 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 + } + + var ( + writeFile bool + backupFile bool + ) + funcMap := getTemplateFuncMap(fullPath, TemplateStruct{}, &writeFile, &backupFile) + tmpl, err := template.New(path.Base(fullPath)).Option("missingkey=default").Funcs(funcMap).ParseFiles(fullPath) + if err != nil { + wwlog.Error("Could not parse template file %s: %s", fullPath, err) + return nil + } + + vars := make(map[string]bool) + if tmpl.Tree != nil && tmpl.Tree.Root != nil { + walkParseTree(tmpl.Tree.Root, vars) + } + + result := make([]string, 0, len(vars)) + for v := range vars { + if v != "" { + result = append(result, v) + } + } + return result +} + +// walkParseTree recursively traverses the template's parse tree to find variables. +func walkParseTree(node parse.Node, vars map[string]bool) { + if node == nil { + return + } + + switch n := node.(type) { + case *parse.ActionNode: + walkParseTree(n.Pipe, vars) + case *parse.IfNode: + walkParseTree(n.Pipe, vars) + walkParseTree(n.List, vars) + walkParseTree(n.ElseList, vars) + case *parse.ListNode: + if n != nil { + for _, child := range n.Nodes { + walkParseTree(child, vars) + } + } + case *parse.RangeNode: + walkParseTree(n.Pipe, vars) + walkParseTree(n.List, vars) + case *parse.WithNode: + walkParseTree(n.Pipe, vars) + walkParseTree(n.List, vars) + case *parse.PipeNode: + if n != nil { + for _, cmd := range n.Cmds { + for _, arg := range cmd.Args { + walkParseTree(arg, vars) + } + } + } + case *parse.VariableNode, *parse.FieldNode: + if strings.Contains(n.String(), "Tags") { + vars[n.String()] = true + } + } +} + func BuildAllOverlays(nodes []node.Node, allNodes []node.Node, workerCount int) error { nodeChan := make(chan node.Node, len(nodes)) errChan := make(chan error, len(nodes)*2) @@ -668,19 +745,9 @@ func CarefulWriteBuffer(destFile string, buffer bytes.Buffer, backupFile bool, p return err } -/* -Parses the template with the given filename, variables must be in data. Returns the -parsed template as bytes.Buffer, and the bool variables for backupFile and writeFile. -If something goes wrong an error is returned. -*/ -func RenderTemplateFile(fileName string, data TemplateStruct) ( - buffer bytes.Buffer, - backupFile bool, - writeFile bool, - err error, -) { - backupFile = true - writeFile = true +// getTemplateFuncMap returns a template.FuncMap with all the functions +// for warewulf templates. +func getTemplateFuncMap(fileName string, data TemplateStruct, writeFile *bool, backupFile *bool) template.FuncMap { // Build our FuncMap funcMap := template.FuncMap{ "Include": templateFileInclude, @@ -698,12 +765,12 @@ func RenderTemplateFile(fileName string, data TemplateStruct) ( }, "abort": func() string { wwlog.Debug("abort file called in %s", fileName) - writeFile = false + *writeFile = false return "" }, "nobackup": func() string { wwlog.Debug("not backup for %s", fileName) - backupFile = false + *backupFile = false return "" }, "UniqueField": UniqueField, @@ -715,6 +782,24 @@ func RenderTemplateFile(fileName string, data TemplateStruct) ( for key, value := range sprig.TxtFuncMap() { funcMap[key] = value } + return funcMap +} + +/* +Parses the template with the given filename, variables must be in data. Returns the +parsed template as bytes.Buffer, and the bool variables for backupFile and writeFile. +If something goes wrong an error is returned. +*/ +func RenderTemplateFile(fileName string, data TemplateStruct) ( + buffer bytes.Buffer, + backupFile bool, + writeFile bool, + err error, +) { + backupFile = true + writeFile = true + + funcMap := getTemplateFuncMap(fileName, data, &writeFile, &backupFile) // Create the template with the merged FuncMap tmpl, err := template.New(path.Base(fileName)).Option("missingkey=default").Funcs(funcMap).ParseGlob(fileName) From 847a4386e121daa4d7c815862392fa292aa4aab2 Mon Sep 17 00:00:00 2001 From: Christian Goll Date: Mon, 13 Oct 2025 16:37:06 +0200 Subject: [PATCH 02/11] don't show vars in overlay list vars --- internal/app/wwctl/overlay/list/main.go | 57 ++++++------------------- 1 file changed, 14 insertions(+), 43 deletions(-) diff --git a/internal/app/wwctl/overlay/list/main.go b/internal/app/wwctl/overlay/list/main.go index 62a1b738..09e3f96d 100644 --- a/internal/app/wwctl/overlay/list/main.go +++ b/internal/app/wwctl/overlay/list/main.go @@ -1,10 +1,7 @@ package list import ( - "os" "strconv" - "strings" - "syscall" "github.com/spf13/cobra" "github.com/warewulf/warewulf/internal/app/wwctl/table" @@ -33,11 +30,7 @@ func CobraRunE(vars *variables) func(cmd *cobra.Command, args []string) error { if vars.ShowPath { locationStr = "PATH" } - if vars.ListLong { - t.AddHeader("PERM MODE", "UID", "GID", "OVERLAY", "FILE PATH", locationStr, "VARS") - } else { - t.AddHeader("OVERLAY NAME", "FILES/DIRS", locationStr) - } + t.AddHeader("OVERLAY NAME", "FILES/DIRS", locationStr) for _, name := range overlays { overlay_, err := overlay.Get(name) @@ -50,43 +43,21 @@ func CobraRunE(vars *variables) func(cmd *cobra.Command, args []string) error { files := util.FindFiles(overlay_.Rootfs()) wwlog.Debug("Iterating overlay rootfs: %s", overlay_.Rootfs()) - if vars.ListLong { - for _, file := range files { - templateVars := []string{} - if !strings.HasSuffix(file, "/") { - templateVars = overlay_.ParseVars(file) - } - s, err := os.Stat(overlay_.File(file)) - if err != nil { - wwlog.Warn("%s: %s: %s", name, file, err) - continue - } - fileMode := s.Mode() - perms := fileMode & os.ModePerm - sys := s.Sys() - locLine := strconv.FormatBool(overlay_.IsSiteOverlay()) - if vars.ShowPath { - locLine = overlay_.Path() - } - t.AddLine(perms, sys.(*syscall.Stat_t).Uid, sys.(*syscall.Stat_t).Gid, name, file, locLine, templateVars) + locLine := strconv.FormatBool(overlay_.IsSiteOverlay()) + if vars.ShowPath { + locLine = overlay_.Path() + } + if vars.ListContents || vars.ListLong { + var fileCount int + for file := range files { + t.AddLine(name, files[file], locLine) + fileCount++ + } + if fileCount == 0 { + t.AddLine(name, 0, locLine) } } else { - locLine := strconv.FormatBool(overlay_.IsSiteOverlay()) - if vars.ShowPath { - locLine = overlay_.Path() - } - if vars.ListContents { - var fileCount int - for file := range files { - t.AddLine(name, files[file], locLine) - fileCount++ - } - if fileCount == 0 { - t.AddLine(name, 0, locLine) - } - } else { - t.AddLine(name, len(files), locLine) - } + t.AddLine(name, len(files), locLine) } } t.Print() From facde98fb59a88f56cff0e0b9a32573f42a6b71c Mon Sep 17 00:00:00 2001 From: Christian Goll Date: Mon, 13 Oct 2025 17:07:14 +0200 Subject: [PATCH 03/11] new overlay variables command --- internal/app/wwctl/overlay/root.go | 2 + internal/app/wwctl/overlay/variables/main.go | 30 ++++++ .../app/wwctl/overlay/variables/main_test.go | 100 ++++++++++++++++++ internal/app/wwctl/overlay/variables/root.go | 39 +++++++ 4 files changed, 171 insertions(+) create mode 100644 internal/app/wwctl/overlay/variables/main.go create mode 100644 internal/app/wwctl/overlay/variables/main_test.go create mode 100644 internal/app/wwctl/overlay/variables/root.go diff --git a/internal/app/wwctl/overlay/root.go b/internal/app/wwctl/overlay/root.go index 529da93c..7f221a30 100644 --- a/internal/app/wwctl/overlay/root.go +++ b/internal/app/wwctl/overlay/root.go @@ -12,6 +12,7 @@ import ( "github.com/warewulf/warewulf/internal/app/wwctl/overlay/list" "github.com/warewulf/warewulf/internal/app/wwctl/overlay/mkdir" "github.com/warewulf/warewulf/internal/app/wwctl/overlay/show" + "github.com/warewulf/warewulf/internal/app/wwctl/overlay/variables" ) var ( @@ -35,6 +36,7 @@ func init() { baseCmd.AddCommand(imprt.GetCommand()) baseCmd.AddCommand(chmod.GetCommand()) baseCmd.AddCommand(chown.GetCommand()) + baseCmd.AddCommand(variables.GetCommand()) } // GetRootCommand returns the root cobra.Command for the application. diff --git a/internal/app/wwctl/overlay/variables/main.go b/internal/app/wwctl/overlay/variables/main.go new file mode 100644 index 00000000..fdf51222 --- /dev/null +++ b/internal/app/wwctl/overlay/variables/main.go @@ -0,0 +1,30 @@ +package variables + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/warewulf/warewulf/internal/pkg/overlay" + "github.com/warewulf/warewulf/internal/pkg/wwlog" +) + +func CobraRunE(cmd *cobra.Command, args []string) error { + overlayName := args[0] + filePath := args[1] + + ov, err := overlay.Get(overlayName) + if err != nil { + wwlog.Error("Failed to get overlay %s: %s", overlayName, err) + return err + } + + vars := ov.ParseVars(filePath) + if vars == nil { + return fmt.Errorf("could not parse variables for %s in overlay %s", filePath, overlayName) + } + + fmt.Println(strings.Join(vars, "\n")) + + return nil +} diff --git a/internal/app/wwctl/overlay/variables/main_test.go b/internal/app/wwctl/overlay/variables/main_test.go new file mode 100644 index 00000000..b97c150d --- /dev/null +++ b/internal/app/wwctl/overlay/variables/main_test.go @@ -0,0 +1,100 @@ +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/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) + } + + templateContent := ` +{{ .Kernel.Tags.foo }} +{{ .Node.Tags.bar }} +{{ .Cluster.Tags.baz }} +` + 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) + } + + // Redirect stdout + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + baseCmd.SetArgs([]string{"test-overlay", "test.ww"}) + err = baseCmd.Execute() + assert.NoError(t, err) + + // Restore stdout + w.Close() + os.Stdout = old + + var buf bytes.Buffer + _, err = io.Copy(&buf, r) + if err != nil { + t.Fatalf("could not read stdout: %v", err) + } + + output := strings.TrimSpace(buf.String()) + expected := []string{ + ".Kernel.Tags.foo", + ".Node.Tags.bar", + ".Cluster.Tags.baz", + } + + 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) +} diff --git a/internal/app/wwctl/overlay/variables/root.go b/internal/app/wwctl/overlay/variables/root.go new file mode 100644 index 00000000..48ec7940 --- /dev/null +++ b/internal/app/wwctl/overlay/variables/root.go @@ -0,0 +1,39 @@ +package variables + +import ( + "github.com/spf13/cobra" + "github.com/warewulf/warewulf/internal/pkg/overlay" +) + +var ( + baseCmd = &cobra.Command{ + Use: "variables [flags] OVERLAY_NAME FILE_PATH", + Short: "Show variables for a template file in an overlay", + Long: "This command will show the variables for a given template file in a given\n" + "overlay.", + Aliases: []string{"vars", "tags"}, + Args: cobra.ExactArgs(2), + RunE: CobraRunE, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return overlay.FindOverlays(), cobra.ShellCompDirectiveNoFileComp + } else if len(args) == 1 { + ov, err := overlay.Get(args[0]) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + files, err := ov.GetFiles() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + return files, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + } +) + +// GetCommand returns the root cobra.Command for this application. +func GetCommand() *cobra.Command { + return baseCmd +} + From 68758e3e4537b5a193501f6c881f3088021081c5 Mon Sep 17 00:00:00 2001 From: Christian Goll Date: Tue, 14 Oct 2025 14:54:39 +0200 Subject: [PATCH 04/11] use comments of variables for help --- internal/app/wwctl/node/edit/main.go | 2 +- internal/app/wwctl/overlay/variables/main.go | 65 +++++++++- .../app/wwctl/overlay/variables/main_test.go | 113 +++++++----------- internal/app/wwctl/overlay/variables/root.go | 1 - internal/app/wwctl/profile/edit/main.go | 2 +- internal/pkg/node/methods.go | 44 +++++-- internal/pkg/overlay/overlay.go | 43 ++++++- 7 files changed, 183 insertions(+), 87 deletions(-) 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 } } From d1d398d2ff0030373f0481b6f2d24ebd805a3996 Mon Sep 17 00:00:00 2001 From: Christian Goll Date: Mon, 20 Oct 2025 11:47:56 +0200 Subject: [PATCH 05/11] add more generic documentation via wwdoc(n) doc --- internal/app/wwctl/overlay/variables/main.go | 8 ++++++ .../app/wwctl/overlay/variables/main_test.go | 4 +++ internal/pkg/overlay/overlay.go | 25 ++++++------------- userdocs/overlays/overlays.rst | 25 +++++++++++++++++++ 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/internal/app/wwctl/overlay/variables/main.go b/internal/app/wwctl/overlay/variables/main.go index f73e6617..55fc3ec2 100644 --- a/internal/app/wwctl/overlay/variables/main.go +++ b/internal/app/wwctl/overlay/variables/main.go @@ -10,6 +10,7 @@ import ( "github.com/warewulf/warewulf/internal/pkg/node" "github.com/warewulf/warewulf/internal/pkg/overlay" "github.com/warewulf/warewulf/internal/pkg/wwlog" + "golang.org/x/exp/maps" ) func CobraRunE(cmd *cobra.Command, args []string) error { @@ -30,6 +31,13 @@ func CobraRunE(cmd *cobra.Command, args []string) error { if vars == nil { return fmt.Errorf("could not parse variables for %s in overlay %s", filePath, overlayName) } + commentKeys := maps.Keys(commentMap) + sort.Strings(commentKeys) + for _, docLn := range commentKeys { + if strings.Contains(docLn, "wwdoc") { + wwlog.Info(commentMap[docLn]) + } + } t := table.New(cmd.OutOrStdout()) t.AddHeader("OVERLAY VARIABLE", "HELP", "TYPE", "CMD OPTION") diff --git a/internal/app/wwctl/overlay/variables/main_test.go b/internal/app/wwctl/overlay/variables/main_test.go index 1b462216..fc339a82 100644 --- a/internal/app/wwctl/overlay/variables/main_test.go +++ b/internal/app/wwctl/overlay/variables/main_test.go @@ -13,10 +13,13 @@ import ( func Test_Overlay_Variables(t *testing.T) { env := testenv.New(t) defer env.RemoveAll() + wwlog.SetLogLevel(wwlog.DEBUG) warewulfd.SetNoDaemon() templateContent := ` {{/* .Kernel.Tags.foo: "some help text" */}} +{{/* wwdoc1: First Line */}} +{{/* wwdoc2: Second Line */}} {{ .Kernel.Tags.foo }} {{ .Node.Tags.bar }} {{ .Cluster.Tags.baz }} @@ -39,6 +42,7 @@ func Test_Overlay_Variables(t *testing.T) { assert.Contains(t, output, "OVERLAY VARIABLE") assert.Contains(t, output, ".Kernel.Tags.foo") assert.Contains(t, output, "some help text") + assert.Regexp(t, `(?s)First Line.*Second Line`, output, "First Line should come before Second Line") }) t.Run("overlay variables no file", func(t *testing.T) { diff --git a/internal/pkg/overlay/overlay.go b/internal/pkg/overlay/overlay.go index deee0ba3..100f3d18 100644 --- a/internal/pkg/overlay/overlay.go +++ b/internal/pkg/overlay/overlay.go @@ -16,7 +16,6 @@ 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" @@ -312,7 +311,8 @@ func (overlay Overlay) ParseVars(file string) []string { // 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 { +func (overlay Overlay) ParseCommentVars(file string) (retMap map[string]string) { + retMap = make(map[string]string) if !strings.HasSuffix(file, ".ww") { return nil } @@ -328,24 +328,15 @@ func (overlay Overlay) ParseCommentVars(file string) map[string]string { return nil } - vars := make(map[string]string) - re := regexp.MustCompile(`{{\s*/\*(.*?)\*/\s*}}`) + re := regexp.MustCompile(`{{\s*/\*\s*(.*?):\s*(.*?)\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) + wwlog.Debug("matches: %v len(%d:%d)", matches, len(matches), len(matches[0])) + for i := range matches { + if len(matches[i]) > 2 { + retMap[matches[i][1]] = matches[i][2] } } - - return vars + return } // walkParseTree recursively traverses the template's parse tree to find variables. diff --git a/userdocs/overlays/overlays.rst b/userdocs/overlays/overlays.rst index a12c48e0..1382c99c 100644 --- a/userdocs/overlays/overlays.rst +++ b/userdocs/overlays/overlays.rst @@ -89,10 +89,13 @@ Creating and Modifying Overlays You can add a new overlay to Warewulf with ``wwctl overlay create``. + + .. code-block:: shell wwctl overlay create issue + A new overlay is just an empty directory. For it to be useful it needs to contain some files. @@ -103,11 +106,14 @@ into the overlay. wwctl overlay import --parents issue /etc/issue + + This imports ``/etc/issue`` from the Warewulf server into the new ``issue`` overlay. .. note:: + The ``issue`` overlay already existed as a distribution overlay. Creating one shadows the distribution overlay with a new site overlay, allowing for local modification. @@ -118,10 +124,12 @@ overlay. You can also edit a new or existing overlay file in an interactive editor. + .. code-block:: shell wwctl overlay edit issue /etc/issue + Use ``wwctl overlay show`` to inspect the content of an overlay file. .. code-block:: shell @@ -138,6 +146,8 @@ distribution to a given cluster node. wwctl overlay import issue /etc/issue /etc/issue.ww wwctl overlay show issue /etc/issue.ww --render=n1 + + More information about templates is available in :ref:`its own section `. @@ -151,6 +161,20 @@ option. It is not possible to delete files with an overlay. + +Overlay Variables +----------------- + +The command ``wwctl overlay variables`` can be used to show the used variables +in a template. The command also shows the help text for each variable. The help +text can be defined in two ways. The first is a general documentation which is +not tied to a variable. This can be done with the following syntax: +``{{/* wwdoc: Your documentation text */}}``. The second way is to define a help +text for a specific variable. This can be done with the following syntax: +``{{/* .My.Var: Your help text */}}``. If a help text is defined for a variable, +it will be used instead of the default help text. + + Permissions ----------- @@ -163,6 +187,7 @@ mode that they have on the Warewulf server. Use ``wwctl overlay chown`` and wwctl overlay chown issue /etc/issue.ww root root wwctl overlay chmod issue /etc/issue.ww 0644 + Distribution Overlays ===================== From 983a359c00ab4184bd58216a731c56ca00958dfd Mon Sep 17 00:00:00 2001 From: Christian Goll Date: Wed, 12 Nov 2025 19:30:24 +0100 Subject: [PATCH 06/11] refactor to overlay info --- CHANGELOG.md | 2 +- .../wwctl/overlay/{variables => info}/main.go | 2 +- .../overlay/{variables => info}/main_test.go | 2 +- .../wwctl/overlay/{variables => info}/root.go | 6 +-- internal/app/wwctl/overlay/list/main.go | 50 ++++++++++++++----- internal/app/wwctl/overlay/root.go | 4 +- internal/pkg/overlay/overlay.go | 12 ++++- 7 files changed, 56 insertions(+), 22 deletions(-) rename internal/app/wwctl/overlay/{variables => info}/main.go (99%) rename internal/app/wwctl/overlay/{variables => info}/main_test.go (99%) rename internal/app/wwctl/overlay/{variables => info}/root.go (90%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15871e58..df43f0a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `wwclient.aarch64` overlay always provides an aarch64 wwclient executable. - `wwclient.x86_64` overlay always provides an x86_64 wwclient executable. - systemd-networkd overlay with IPv6 support +- `wwctl overlay info` lists the variables used by an overlay template ### Changed @@ -49,7 +50,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added -- `wwctl overlay show -l` will now also list the used tags in a template - Initial support for EL10 using `dnsmasq` in place of `dhcpd-server`. #1974 - `make rpm` for local development rpm builds. #1974 - `wwclient --once` prompts wwclient to run once. #1226 diff --git a/internal/app/wwctl/overlay/variables/main.go b/internal/app/wwctl/overlay/info/main.go similarity index 99% rename from internal/app/wwctl/overlay/variables/main.go rename to internal/app/wwctl/overlay/info/main.go index 55fc3ec2..a531e18a 100644 --- a/internal/app/wwctl/overlay/variables/main.go +++ b/internal/app/wwctl/overlay/info/main.go @@ -1,4 +1,4 @@ -package variables +package info import ( "fmt" diff --git a/internal/app/wwctl/overlay/variables/main_test.go b/internal/app/wwctl/overlay/info/main_test.go similarity index 99% rename from internal/app/wwctl/overlay/variables/main_test.go rename to internal/app/wwctl/overlay/info/main_test.go index fc339a82..94c4ddbd 100644 --- a/internal/app/wwctl/overlay/variables/main_test.go +++ b/internal/app/wwctl/overlay/info/main_test.go @@ -1,4 +1,4 @@ -package variables +package info import ( "bytes" diff --git a/internal/app/wwctl/overlay/variables/root.go b/internal/app/wwctl/overlay/info/root.go similarity index 90% rename from internal/app/wwctl/overlay/variables/root.go rename to internal/app/wwctl/overlay/info/root.go index 6b3e2bcd..8cfa3024 100644 --- a/internal/app/wwctl/overlay/variables/root.go +++ b/internal/app/wwctl/overlay/info/root.go @@ -1,4 +1,4 @@ -package variables +package info import ( "github.com/spf13/cobra" @@ -7,10 +7,10 @@ import ( var ( baseCmd = &cobra.Command{ - Use: "variables [flags] OVERLAY_NAME FILE_PATH", + Use: "info [flags] OVERLAY_NAME FILE_PATH", Short: "Show variables for a template file in an overlay", Long: "This command will show the variables for a given template file in a given\n" + "overlay.", - Aliases: []string{"vars", "tags"}, + Aliases: []string{"vars", "tags", "information"}, Args: cobra.ExactArgs(2), RunE: CobraRunE, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { diff --git a/internal/app/wwctl/overlay/list/main.go b/internal/app/wwctl/overlay/list/main.go index 09e3f96d..d75da8ee 100644 --- a/internal/app/wwctl/overlay/list/main.go +++ b/internal/app/wwctl/overlay/list/main.go @@ -1,7 +1,9 @@ package list import ( + "os" "strconv" + "syscall" "github.com/spf13/cobra" "github.com/warewulf/warewulf/internal/app/wwctl/table" @@ -30,7 +32,11 @@ func CobraRunE(vars *variables) func(cmd *cobra.Command, args []string) error { if vars.ShowPath { locationStr = "PATH" } - t.AddHeader("OVERLAY NAME", "FILES/DIRS", locationStr) + if vars.ListLong { + t.AddHeader("PERM MODE", "UID", "GID", "OVERLAY", "FILE PATH", locationStr) + } else { + t.AddHeader("OVERLAY NAME", "FILES/DIRS", locationStr) + } for _, name := range overlays { overlay_, err := overlay.Get(name) @@ -43,21 +49,39 @@ func CobraRunE(vars *variables) func(cmd *cobra.Command, args []string) error { files := util.FindFiles(overlay_.Rootfs()) wwlog.Debug("Iterating overlay rootfs: %s", overlay_.Rootfs()) - locLine := strconv.FormatBool(overlay_.IsSiteOverlay()) - if vars.ShowPath { - locLine = overlay_.Path() - } - if vars.ListContents || vars.ListLong { - var fileCount int + if vars.ListLong { for file := range files { - t.AddLine(name, files[file], locLine) - fileCount++ - } - if fileCount == 0 { - t.AddLine(name, 0, locLine) + s, err := os.Stat(overlay_.File(files[file])) + if err != nil { + wwlog.Warn("%s: %s: %s", name, files[file], err) + continue + } + fileMode := s.Mode() + perms := fileMode & os.ModePerm + sys := s.Sys() + locLine := strconv.FormatBool(overlay_.IsSiteOverlay()) + if vars.ShowPath { + locLine = overlay_.Path() + } + t.AddLine(perms, sys.(*syscall.Stat_t).Uid, sys.(*syscall.Stat_t).Gid, name, files[file], locLine) } } else { - t.AddLine(name, len(files), locLine) + locLine := strconv.FormatBool(overlay_.IsSiteOverlay()) + if vars.ShowPath { + locLine = overlay_.Path() + } + if vars.ListContents { + var fileCount int + for file := range files { + t.AddLine(name, files[file], locLine) + fileCount++ + } + if fileCount == 0 { + t.AddLine(name, 0, locLine) + } + } else { + t.AddLine(name, len(files), locLine) + } } } t.Print() diff --git a/internal/app/wwctl/overlay/root.go b/internal/app/wwctl/overlay/root.go index 7f221a30..1c7ed6d5 100644 --- a/internal/app/wwctl/overlay/root.go +++ b/internal/app/wwctl/overlay/root.go @@ -9,10 +9,10 @@ import ( "github.com/warewulf/warewulf/internal/app/wwctl/overlay/delete" "github.com/warewulf/warewulf/internal/app/wwctl/overlay/edit" "github.com/warewulf/warewulf/internal/app/wwctl/overlay/imprt" + "github.com/warewulf/warewulf/internal/app/wwctl/overlay/info" "github.com/warewulf/warewulf/internal/app/wwctl/overlay/list" "github.com/warewulf/warewulf/internal/app/wwctl/overlay/mkdir" "github.com/warewulf/warewulf/internal/app/wwctl/overlay/show" - "github.com/warewulf/warewulf/internal/app/wwctl/overlay/variables" ) var ( @@ -36,7 +36,7 @@ func init() { baseCmd.AddCommand(imprt.GetCommand()) baseCmd.AddCommand(chmod.GetCommand()) baseCmd.AddCommand(chown.GetCommand()) - baseCmd.AddCommand(variables.GetCommand()) + baseCmd.AddCommand(info.GetCommand()) } // GetRootCommand returns the root cobra.Command for the application. diff --git a/internal/pkg/overlay/overlay.go b/internal/pkg/overlay/overlay.go index 100f3d18..0206204d 100644 --- a/internal/pkg/overlay/overlay.go +++ b/internal/pkg/overlay/overlay.go @@ -330,7 +330,11 @@ func (overlay Overlay) ParseCommentVars(file string) (retMap map[string]string) re := regexp.MustCompile(`{{\s*/\*\s*(.*?):\s*(.*?)\s*\*/\s*}}`) matches := re.FindAllStringSubmatch(string(content), -1) - wwlog.Debug("matches: %v len(%d:%d)", matches, len(matches), len(matches[0])) + if len(matches) > 0 { + wwlog.Debug("matches: %v len(%d:%d)", matches, len(matches), len(matches[0])) + } else { + wwlog.Debug("matches: [] len(0)") + } for i := range matches { if len(matches[i]) > 2 { retMap[matches[i][1]] = matches[i][2] @@ -361,9 +365,15 @@ func walkParseTree(node parse.Node, vars map[string]bool) { case *parse.RangeNode: walkParseTree(n.Pipe, vars) walkParseTree(n.List, vars) + walkParseTree(n.ElseList, vars) case *parse.WithNode: walkParseTree(n.Pipe, vars) walkParseTree(n.List, vars) + walkParseTree(n.ElseList, vars) + case *parse.TemplateNode: + walkParseTree(n.Pipe, vars) + case *parse.BreakNode, *parse.ContinueNode: + // No variables to extract case *parse.PipeNode: if n != nil { for _, cmd := range n.Cmds { From 0bbab7ac3cf311c507058338d93cda2dcdefad84 Mon Sep 17 00:00:00 2001 From: Christian Goll Date: Tue, 25 Nov 2025 09:12:46 +0100 Subject: [PATCH 07/11] allocate writeFile and backupFile just once --- internal/app/wwctl/overlay/show/main.go | 8 +++---- internal/pkg/configure/hostfile.go | 4 ++-- internal/pkg/overlay/overlay.go | 30 +++++++++++-------------- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/internal/app/wwctl/overlay/show/main.go b/internal/app/wwctl/overlay/show/main.go index 2169beb9..ceeffd3d 100644 --- a/internal/app/wwctl/overlay/show/main.go +++ b/internal/app/wwctl/overlay/show/main.go @@ -91,8 +91,8 @@ func CobraRunE(cmd *cobra.Command, args []string) error { wwlog.Debug("Found multifile comment, new filename %s", filenameFromTemplate[0][1]) if foundFileComment { if !Quiet { - wwlog.Info("backupFile: %v", backupFile) - wwlog.Info("writeFile: %v", writeFile) + wwlog.Info("backupFile: %v", *backupFile) + wwlog.Info("writeFile: %v", *writeFile) wwlog.Info("Filename: %s", destFileName) } wwlog.Output("%s", outBuffer.String()) @@ -105,8 +105,8 @@ func CobraRunE(cmd *cobra.Command, args []string) error { } } if !Quiet { - wwlog.Info("backupFile: %v", backupFile) - wwlog.Info("writeFile: %v", writeFile) + wwlog.Info("backupFile: %v", *backupFile) + wwlog.Info("writeFile: %v", *writeFile) wwlog.Info("Filename: %s", destFileName) } wwlog.Output("%s", outBuffer.String()) diff --git a/internal/pkg/configure/hostfile.go b/internal/pkg/configure/hostfile.go index 1e718a6f..da0814a1 100644 --- a/internal/pkg/configure/hostfile.go +++ b/internal/pkg/configure/hostfile.go @@ -49,8 +49,8 @@ func Hostfile() (err error) { return } - if writeFile { - err = overlay.CarefulWriteBuffer("/etc/hosts", buffer, backupFile, info.Mode()) + if *writeFile { + err = overlay.CarefulWriteBuffer("/etc/hosts", buffer, *backupFile, info.Mode()) if err != nil { return fmt.Errorf("could not write file from template: %w", err) } diff --git a/internal/pkg/overlay/overlay.go b/internal/pkg/overlay/overlay.go index 0206204d..d1a3c467 100644 --- a/internal/pkg/overlay/overlay.go +++ b/internal/pkg/overlay/overlay.go @@ -284,11 +284,7 @@ func (overlay Overlay) ParseVars(file string) []string { return nil } - var ( - writeFile bool - backupFile bool - ) - funcMap := getTemplateFuncMap(fullPath, TemplateStruct{}, &writeFile, &backupFile) + funcMap, _, _ := getTemplateFuncMap(fullPath, TemplateStruct{}) tmpl, err := template.New(path.Base(fullPath)).Option("missingkey=default").Funcs(funcMap).ParseFiles(fullPath) if err != nil { wwlog.Error("Could not parse template file %s: %s", fullPath, err) @@ -651,7 +647,7 @@ func BuildOverlayIndir(nodeData node.Node, allNodes []node.Node, overlayNames [] if err != nil { return fmt.Errorf("failed to render template %s: %w", walkPath, err) } - if !writeFile { + if !*writeFile { return nil } var fileBuffer bytes.Buffer @@ -676,7 +672,7 @@ func BuildOverlayIndir(nodeData node.Node, allNodes []node.Node, overlayNames [] } else if len(filenameFromTemplate) != 0 { wwlog.Debug("Writing file %s", filenameFromTemplate[0][1]) if writingToNamedFile && !isLink { - err = CarefulWriteBuffer(outputPath, fileBuffer, backupFile, info.Mode()) + err = CarefulWriteBuffer(outputPath, fileBuffer, *backupFile, info.Mode()) if err != nil { return fmt.Errorf("could not write file from template: %w", err) } @@ -709,7 +705,7 @@ func BuildOverlayIndir(nodeData node.Node, allNodes []node.Node, overlayNames [] } } if !isLink { - err = CarefulWriteBuffer(outputPath, fileBuffer, backupFile, info.Mode()) + err = CarefulWriteBuffer(outputPath, fileBuffer, *backupFile, info.Mode()) if err != nil { return fmt.Errorf("could not write file from template: %w", err) } @@ -785,9 +781,13 @@ func CarefulWriteBuffer(destFile string, buffer bytes.Buffer, backupFile bool, p // getTemplateFuncMap returns a template.FuncMap with all the functions // for warewulf templates. -func getTemplateFuncMap(fileName string, data TemplateStruct, writeFile *bool, backupFile *bool) template.FuncMap { +func getTemplateFuncMap(fileName string, data TemplateStruct) (funcMap template.FuncMap, writeFile, backupFile *bool) { // Build our FuncMap - funcMap := template.FuncMap{ + _writeFile := true + _backupFile := true + writeFile = &_writeFile + backupFile = &_backupFile + funcMap = template.FuncMap{ "Include": templateFileInclude, "IncludeFrom": templateImageFileInclude, "IncludeBlock": templateFileBlock, @@ -820,7 +820,7 @@ func getTemplateFuncMap(fileName string, data TemplateStruct, writeFile *bool, b for key, value := range sprig.TxtFuncMap() { funcMap[key] = value } - return funcMap + return funcMap, writeFile, backupFile } /* @@ -829,15 +829,11 @@ parsed template as bytes.Buffer, and the bool variables for backupFile and write If something goes wrong an error is returned. */ func RenderTemplateFile(fileName string, data TemplateStruct) ( - buffer bytes.Buffer, - backupFile bool, - writeFile bool, + buffer bytes.Buffer, backupFile, writeFile *bool, err error, ) { - backupFile = true - writeFile = true - funcMap := getTemplateFuncMap(fileName, data, &writeFile, &backupFile) + funcMap, writeFile, backupFile := getTemplateFuncMap(fileName, data) // Create the template with the merged FuncMap tmpl, err := template.New(path.Base(fileName)).Option("missingkey=default").Funcs(funcMap).ParseGlob(fileName) From 29136e17d42988adce479fc7b32d125213921b02 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Wed, 3 Dec 2025 19:11:23 -0700 Subject: [PATCH 08/11] Recommended changes from review - Reorganized documentation - Reordered columns - Renamed column names - Added `--` to options in output - Updated aliases Signed-off-by: Jonathon Anderson --- internal/app/wwctl/overlay/info/main.go | 10 ++-- internal/app/wwctl/overlay/info/main_test.go | 2 +- internal/app/wwctl/overlay/info/root.go | 2 +- userdocs/overlays/overlays.rst | 57 +++++++++++--------- userdocs/overlays/templates.rst | 22 ++++++++ 5 files changed, 61 insertions(+), 32 deletions(-) diff --git a/internal/app/wwctl/overlay/info/main.go b/internal/app/wwctl/overlay/info/main.go index a531e18a..50f0fe72 100644 --- a/internal/app/wwctl/overlay/info/main.go +++ b/internal/app/wwctl/overlay/info/main.go @@ -39,7 +39,7 @@ func CobraRunE(cmd *cobra.Command, args []string) error { } } t := table.New(cmd.OutOrStdout()) - t.AddHeader("OVERLAY VARIABLE", "HELP", "TYPE", "CMD OPTION") + t.AddHeader("VARIABLE", "OPTION", "TYPE", "HELP") for _, v := range vars { found := false @@ -78,19 +78,19 @@ func CobraRunE(cmd *cobra.Command, args []string) error { if match { opt := "" if val.LongOpt != "" { - opt = val.LongOpt + opt = "--" + val.LongOpt } if hasCommentHelp { - t.AddLine(v, helpText, val.Type, opt) + t.AddLine(v, opt, val.Type, helpText) } else { - t.AddLine(v, val.Comment, val.Type, opt) + t.AddLine(v, opt, val.Type, val.Comment) } found = true } } if !found { if hasCommentHelp { - t.AddLine(v, helpText, "", "") + t.AddLine(v, "", "", helpText) } else if strings.Contains(v, "Tags") { t.AddLine(v, "", "", "", "") } diff --git a/internal/app/wwctl/overlay/info/main_test.go b/internal/app/wwctl/overlay/info/main_test.go index 94c4ddbd..e10b78b0 100644 --- a/internal/app/wwctl/overlay/info/main_test.go +++ b/internal/app/wwctl/overlay/info/main_test.go @@ -39,7 +39,7 @@ func Test_Overlay_Variables(t *testing.T) { assert.NoError(t, err) output := buf.String() - assert.Contains(t, output, "OVERLAY VARIABLE") + assert.Contains(t, output, "VARIABLE") assert.Contains(t, output, ".Kernel.Tags.foo") assert.Contains(t, output, "some help text") assert.Regexp(t, `(?s)First Line.*Second Line`, output, "First Line should come before Second Line") diff --git a/internal/app/wwctl/overlay/info/root.go b/internal/app/wwctl/overlay/info/root.go index 8cfa3024..eee3f4b8 100644 --- a/internal/app/wwctl/overlay/info/root.go +++ b/internal/app/wwctl/overlay/info/root.go @@ -10,7 +10,7 @@ var ( Use: "info [flags] OVERLAY_NAME FILE_PATH", Short: "Show variables for a template file in an overlay", Long: "This command will show the variables for a given template file in a given\n" + "overlay.", - Aliases: []string{"vars", "tags", "information"}, + Aliases: []string{"variables", "vars"}, Args: cobra.ExactArgs(2), RunE: CobraRunE, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { diff --git a/userdocs/overlays/overlays.rst b/userdocs/overlays/overlays.rst index 1382c99c..5d180db5 100644 --- a/userdocs/overlays/overlays.rst +++ b/userdocs/overlays/overlays.rst @@ -25,6 +25,37 @@ within the overlays with ``wwctl overlay list --all``. fstab etc/ false fstab etc/fstab.ww false +Overlay Variables +----------------- + +The command ``wwctl overlay info`` shows the variables used in an overlay +template, along with the help text for each variable. + +.. code-block:: console + + # wwctl overlay info NetworkManager etc/NetworkManager/system-connections/ww4-managed.ww + VARIABLE HELP TYPE OPTION + -------- ---- ---- ------ + $netdev.Device Set the device for given network string --netdev + $netdev.Gateway Set the node's network device gateway IP --gateway + $netdev.Hwaddr Set the device's HW address for given network string --hwaddr + $netdev.Ipaddr IPv4 address in given network IP --ipaddr + $netdev.Ipaddr6 IPv4 address in given network IP --ipaddr + $netdev.Ipaddr6 IPv6 address IP --ipaddr6 + $netdev.MTU Set the mtu string --mtu + $netdev.OnBoot.BoolDefaultTrue Enable/disable network device (true/false) WWbool --onboot + $netdev.Tags + $netdev.Tags.DNSSEARCH + $netdev.Tags.downdelay + $netdev.Tags.master + $netdev.Tags.miimon + $netdev.Tags.mode + $netdev.Tags.parent_device + $netdev.Tags.updelay + $netdev.Tags.vlan_id + $netdev.Tags.xmit_hash_policy + $netdev.Type Set device type of given network string --type + Structure ========= @@ -89,13 +120,10 @@ Creating and Modifying Overlays You can add a new overlay to Warewulf with ``wwctl overlay create``. - - .. code-block:: shell wwctl overlay create issue - A new overlay is just an empty directory. For it to be useful it needs to contain some files. @@ -106,14 +134,11 @@ into the overlay. wwctl overlay import --parents issue /etc/issue - - This imports ``/etc/issue`` from the Warewulf server into the new ``issue`` overlay. .. note:: - The ``issue`` overlay already existed as a distribution overlay. Creating one shadows the distribution overlay with a new site overlay, allowing for local modification. @@ -124,12 +149,10 @@ overlay. You can also edit a new or existing overlay file in an interactive editor. - .. code-block:: shell wwctl overlay edit issue /etc/issue - Use ``wwctl overlay show`` to inspect the content of an overlay file. .. code-block:: shell @@ -146,8 +169,6 @@ distribution to a given cluster node. wwctl overlay import issue /etc/issue /etc/issue.ww wwctl overlay show issue /etc/issue.ww --render=n1 - - More information about templates is available in :ref:`its own section `. @@ -161,20 +182,6 @@ option. It is not possible to delete files with an overlay. - -Overlay Variables ------------------ - -The command ``wwctl overlay variables`` can be used to show the used variables -in a template. The command also shows the help text for each variable. The help -text can be defined in two ways. The first is a general documentation which is -not tied to a variable. This can be done with the following syntax: -``{{/* wwdoc: Your documentation text */}}``. The second way is to define a help -text for a specific variable. This can be done with the following syntax: -``{{/* .My.Var: Your help text */}}``. If a help text is defined for a variable, -it will be used instead of the default help text. - - Permissions ----------- @@ -398,7 +405,7 @@ provided as an example. In particular, the provided `tstruct.md.ww` demonstrates the use of most available template metadata. .. code-block:: shell - + wwctl overlay show --render= debug tstruct.md.ww .. _localtime: diff --git a/userdocs/overlays/templates.rst b/userdocs/overlays/templates.rst index 683e8fc5..437a21c6 100644 --- a/userdocs/overlays/templates.rst +++ b/userdocs/overlays/templates.rst @@ -33,6 +33,28 @@ non-overlay templates as well. commands for the ``wwctl power``, ``wwctl sensor``, and ``wwctl console`` commands. +Template documentation +====================== + +Templates can include documentation to be included in the output of ``wwctl overlay info``. + +.. code:: + + {{/* wwdoc: Your documentation text */}} + +Template variables +================== + +Overlay templates have access to a number of variables that provide information +about the server configuration, the node being provisioned, and all nodes in the +cluster. An example of the variables available, and their use, is included with +Warewulf in the ``tstruct.ww`` template of the ``debug`` overlay. + +Variables used in an overlay template can be documented by adding a comment to +the template with the form ``{{/* .My.Var: Your help text */}}``. Variable help +text defined in a comment replaces that variable's default help text in the +output of ``wwctl overlay info``. + Template functions ================== From 4e67db0be6335cf4e5f4c2340f0b2addbf4c92c4 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Thu, 4 Dec 2025 00:26:03 -0700 Subject: [PATCH 09/11] Reflection-based template walking Rather than deriving the identity of template variables by string parsing the name, identify its relationship to the underlying struct using reflection. Signed-off-by: Jonathon Anderson --- go.mod | 2 +- internal/app/wwctl/overlay/info/main.go | 103 ++--- internal/pkg/node/methods.go | 26 -- internal/pkg/overlay/overlay.go | 424 ++++++++++++++++-- .../rootfs/etc/netplan/warewulf.yaml.ww | 14 +- 5 files changed, 451 insertions(+), 118 deletions(-) diff --git a/go.mod b/go.mod index 05a91842..ad8db5dd 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/swaggest/usecase v1.3.1 github.com/talos-systems/go-smbios v0.1.1 golang.org/x/crypto v0.32.0 + golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 golang.org/x/sys v0.29.0 golang.org/x/term v0.28.0 google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489 @@ -149,7 +150,6 @@ require ( go.opentelemetry.io/otel v1.32.0 // indirect go.opentelemetry.io/otel/metric v1.32.0 // indirect go.opentelemetry.io/otel/trace v1.32.0 // indirect - golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect golang.org/x/net v0.33.0 // indirect golang.org/x/sync v0.11.0 // indirect golang.org/x/text v0.22.0 // indirect diff --git a/internal/app/wwctl/overlay/info/main.go b/internal/app/wwctl/overlay/info/main.go index 50f0fe72..f0e2af6b 100644 --- a/internal/app/wwctl/overlay/info/main.go +++ b/internal/app/wwctl/overlay/info/main.go @@ -7,7 +7,6 @@ import ( "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" "golang.org/x/exp/maps" @@ -23,14 +22,14 @@ func CobraRunE(cmd *cobra.Command, args []string) error { return err } - vars := ov.ParseVars(filePath) - sort.Strings(vars) - commentMap := ov.ParseCommentVars(filePath) - varMap := node.TemplateVarMap{} - varMap.ConfToTemplateMap(node.Node{}, "") - if vars == nil { + // Use new type-based variable resolution + varFields := ov.ParseVarFields(filePath) + if varFields == nil { return fmt.Errorf("could not parse variables for %s in overlay %s", filePath, overlayName) } + + // Still parse comment vars for wwdoc and inline documentation + commentMap := ov.ParseCommentVars(filePath) commentKeys := maps.Keys(commentMap) sort.Strings(commentKeys) for _, docLn := range commentKeys { @@ -38,64 +37,56 @@ func CobraRunE(cmd *cobra.Command, args []string) error { wwlog.Info(commentMap[docLn]) } } + + // Sort variables by name for consistent output + varNames := maps.Keys(varFields) + sort.Strings(varNames) + t := table.New(cmd.OutOrStdout()) t.AddHeader("VARIABLE", "OPTION", "TYPE", "HELP") - for _, v := range vars { - found := false - helpText, hasCommentHelp := commentMap[v] + for _, varName := range varNames { + fieldInfo := varFields[varName] + helpText, hasCommentHelp := commentMap[varName] - 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 - } + // Extract metadata from the resolved field + opt := "" + typ := "" + help := "" + + // Check if we have valid field information (field name is not empty) + hasValidField := fieldInfo.Field.Name != "" + + if hasValidField { + // Get option from lopt tag + if lopt := fieldInfo.Field.Tag.Get("lopt"); lopt != "" { + opt = "--" + lopt } - if match { - opt := "" - if val.LongOpt != "" { - opt = "--" + val.LongOpt - } - if hasCommentHelp { - t.AddLine(v, opt, val.Type, helpText) - } else { - t.AddLine(v, opt, val.Type, val.Comment) - } - found = true + + // Get type from type tag, or use field type string + typ = fieldInfo.Field.Tag.Get("type") + if typ == "" { + // Use String() instead of Name() to handle composite types (slices, maps, pointers) + typ = fieldInfo.Field.Type.String() } + + // Get help from comment tag + help = fieldInfo.Field.Tag.Get("comment") } - if !found { - if hasCommentHelp { - t.AddLine(v, "", "", helpText) - } else if strings.Contains(v, "Tags") { - t.AddLine(v, "", "", "", "") - } + + // Prefer inline comment documentation if available + if hasCommentHelp { + help = helpText + } + + // Special handling for Tags fields + if strings.Contains(varName, "Tags") { + t.AddLine(varName, opt, typ, help) + } else if hasValidField || hasCommentHelp { + t.AddLine(varName, opt, typ, help) } } + t.Print() return nil } diff --git a/internal/pkg/node/methods.go b/internal/pkg/node/methods.go index 4f0d722c..1f6853f2 100644 --- a/internal/pkg/node/methods.go +++ b/internal/pkg/node/methods.go @@ -324,32 +324,6 @@ type TemplateVarDetails struct { 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 d1a3c467..79342ea9 100644 --- a/internal/pkg/overlay/overlay.go +++ b/internal/pkg/overlay/overlay.go @@ -8,6 +8,7 @@ import ( "os" "path" "path/filepath" + "reflect" "regexp" "strings" "sync" @@ -273,8 +274,17 @@ func (overlay Overlay) Mkdir(path string, mode int32) (err error) { return os.MkdirAll(fullPath, os.FileMode(mode)) } -// returns a list of the variable names for the given template -func (overlay Overlay) ParseVars(file string) []string { +// FieldInfo contains detailed type information about a template variable +type FieldInfo struct { + Field reflect.StructField // Complete field metadata including tags + ParentType reflect.Type // The containing struct type + FullPath string // Full path like ".NetDevs.Ipaddr6" + VarName string // Original variable name from template +} + +// ParseVarFields returns detailed type information for each variable in the template +// by using reflection to resolve the actual struct fields being referenced. +func (overlay Overlay) ParseVarFields(file string) map[string]FieldInfo { if !strings.HasSuffix(file, ".ww") { return nil } @@ -291,17 +301,18 @@ func (overlay Overlay) ParseVars(file string) []string { return nil } - vars := make(map[string]bool) + result := make(map[string]FieldInfo) + rootType := reflect.TypeOf(TemplateStruct{}) + + // Track range variables and their types + rangeVars := make(map[string]reflect.Type) + // Initialize $ to refer to the root template context + rangeVars["$"] = rootType + if tmpl.Tree != nil && tmpl.Tree.Root != nil { - walkParseTree(tmpl.Tree.Root, vars) + walkParseTree(tmpl.Tree.Root, rootType, "", rangeVars, result) } - result := make([]string, 0, len(vars)) - for v := range vars { - if v != "" { - result = append(result, v) - } - } return result } @@ -339,47 +350,402 @@ func (overlay Overlay) ParseCommentVars(file string) (retMap map[string]string) return } -// walkParseTree recursively traverses the template's parse tree to find variables. -func walkParseTree(node parse.Node, vars map[string]bool) { +// walkParseTree recursively traverses the template's parse tree and resolves +// variable references to actual struct fields using reflection. +func walkParseTree(node parse.Node, currentType reflect.Type, currentPath string, rangeVars map[string]reflect.Type, result map[string]FieldInfo) { if node == nil { return } switch n := node.(type) { case *parse.ActionNode: - walkParseTree(n.Pipe, vars) + // Handle variable assignments like $var := expr + if n.Pipe != nil && len(n.Pipe.Decl) > 0 && len(n.Pipe.Cmds) > 0 { + // Try to resolve the type of the expression being assigned + cmd := n.Pipe.Cmds[0] + if len(cmd.Args) > 0 { + // Check for field access (e.g., $NetDevs := .NetDevs) + if field, ok := cmd.Args[0].(*parse.FieldNode); ok { + fieldInfo := resolveFieldChain(currentType, field.Ident, currentPath) + if fieldInfo != nil { + // Record all declared variables with this type + for _, decl := range n.Pipe.Decl { + rangeVars[decl.Ident[0]] = fieldInfo.Field.Type + } + } + } else if varNode, ok := cmd.Args[0].(*parse.VariableNode); ok { + // Check for variable-to-variable assignment (e.g., $b := $a) + if len(varNode.Ident) == 1 { + // Simple variable reference + varBaseName := varNode.Ident[0] + if varType, exists := rangeVars[varBaseName]; exists { + for _, decl := range n.Pipe.Decl { + rangeVars[decl.Ident[0]] = varType + } + } + } + } + } + } + walkParseTree(n.Pipe, currentType, currentPath, rangeVars, result) + case *parse.IfNode: - walkParseTree(n.Pipe, vars) - walkParseTree(n.List, vars) - walkParseTree(n.ElseList, vars) + walkParseTree(n.Pipe, currentType, currentPath, rangeVars, result) + walkParseTree(n.List, currentType, currentPath, rangeVars, result) + walkParseTree(n.ElseList, currentType, currentPath, rangeVars, result) + case *parse.ListNode: if n != nil { for _, child := range n.Nodes { - walkParseTree(child, vars) + walkParseTree(child, currentType, currentPath, rangeVars, result) } } + case *parse.RangeNode: - walkParseTree(n.Pipe, vars) - walkParseTree(n.List, vars) - walkParseTree(n.ElseList, vars) + // Handle range statements like {{range $key, $val := .NetDevs}} + // First, walk the pipe to record the variable being ranged over + walkParseTree(n.Pipe, currentType, currentPath, rangeVars, result) + + if n.Pipe != nil && len(n.Pipe.Cmds) > 0 { + // Get what's being ranged over to determine element type + var fieldInfo *FieldInfo + + // Check for both FieldNode (e.g., .NetDevs) and VariableNode (e.g., $disk.PartitionList) + rangeField := extractFieldFromPipe(n.Pipe) + if rangeField != nil { + // Resolve the type of the field being ranged over + fieldInfo = resolveFieldChain(currentType, rangeField.Ident, currentPath) + } else { + // Check if it's a variable access + for _, cmd := range n.Pipe.Cmds { + for _, arg := range cmd.Args { + if varNode, ok := arg.(*parse.VariableNode); ok { + varBaseName := varNode.Ident[0] + if varType, exists := rangeVars[varBaseName]; exists { + if len(varNode.Ident) > 1 { + // Multi-part variable like $disk.PartitionList + fieldIdents := varNode.Ident[1:] // Skip the variable name + fieldInfo = resolveFieldChain(varType, fieldIdents, currentPath) + } else { + // Simple variable like $NetDevs + fieldInfo = &FieldInfo{ + Field: reflect.StructField{ + Name: varBaseName, + Type: varType, + }, + ParentType: currentType, + FullPath: currentPath, + } + } + break + } + } + } + if fieldInfo != nil { + break + } + } + } + + if fieldInfo != nil { + rangeType := fieldInfo.Field.Type + + // For slices and arrays, get the element type + if rangeType.Kind() == reflect.Slice || rangeType.Kind() == reflect.Array { + rangeType = rangeType.Elem() + } + + // For maps, get the value type + if rangeType.Kind() == reflect.Map { + rangeType = rangeType.Elem() + } + + // Dereference pointers + if rangeType.Kind() == reflect.Ptr { + rangeType = rangeType.Elem() + } + + // Track the range variable assignments + if len(n.Pipe.Decl) > 0 { + if len(n.Pipe.Decl) == 2 { + // Two-variable range: $key, $val := .Map or $index, $val := .Slice + keyType := reflect.TypeOf(0) // Default to int for slice indices + if fieldInfo.Field.Type.Kind() == reflect.Map { + keyType = fieldInfo.Field.Type.Key() + } + rangeVars[n.Pipe.Decl[0].Ident[0]] = keyType // First variable is key/index + rangeVars[n.Pipe.Decl[1].Ident[0]] = rangeType // Second variable is value + } else { + // Single-variable range: $val := .Slice + rangeVars[n.Pipe.Decl[0].Ident[0]] = rangeType + } + } + + // Walk the range body with the element type + walkParseTree(n.List, rangeType, fieldInfo.FullPath, rangeVars, result) + } + } + walkParseTree(n.ElseList, currentType, currentPath, rangeVars, result) + case *parse.WithNode: - walkParseTree(n.Pipe, vars) - walkParseTree(n.List, vars) - walkParseTree(n.ElseList, vars) + walkParseTree(n.Pipe, currentType, currentPath, rangeVars, result) + walkParseTree(n.List, currentType, currentPath, rangeVars, result) + walkParseTree(n.ElseList, currentType, currentPath, rangeVars, result) + case *parse.TemplateNode: - walkParseTree(n.Pipe, vars) - case *parse.BreakNode, *parse.ContinueNode: - // No variables to extract + walkParseTree(n.Pipe, currentType, currentPath, rangeVars, result) + case *parse.PipeNode: if n != nil { for _, cmd := range n.Cmds { for _, arg := range cmd.Args { - walkParseTree(arg, vars) + walkParseTree(arg, currentType, currentPath, rangeVars, result) } } } - case *parse.VariableNode, *parse.FieldNode: - vars[n.String()] = true + + case *parse.FieldNode: + // Field access like .Ipmi.Ipaddr or $netdev.Ipaddr + varName := n.String() + + // Determine the base type for field resolution + baseType := currentType + basePath := currentPath + + // Check if this is a variable access (starts with $) + if strings.HasPrefix(varName, "$") { + // Extract variable name (e.g., "$netdev.Device" -> "netdev") + parts := strings.SplitN(varName[1:], ".", 2) + if len(parts) > 0 { + varBaseName := parts[0] + if varType, exists := rangeVars[varBaseName]; exists { + baseType = varType + basePath = currentPath + } + } + } + + fieldInfo := resolveFieldChain(baseType, n.Ident, basePath) + + // If resolution failed, try adding "P" suffix to last identifier + // This handles methods like Primary() backed by PrimaryP field + if fieldInfo == nil && len(n.Ident) >= 1 { + identWithP := make([]string, len(n.Ident)) + copy(identWithP, n.Ident) + identWithP[len(identWithP)-1] += "P" + fieldInfo = resolveFieldChain(baseType, identWithP, basePath) + // Use the original variable name (without P) + if fieldInfo != nil { + fieldInfo.VarName = varName + } + } + + if fieldInfo != nil { + if fieldInfo.VarName == "" { + fieldInfo.VarName = varName + } + result[varName] = *fieldInfo + } + + case *parse.VariableNode: + // Variable reference like $netdev or possibly $netdev.Field + varName := n.String() + + // Check if this is a simple variable or a field access on a variable + if len(n.Ident) > 1 { + // This is $var.Field or $var.Field.Method - handle like a FieldNode + varBaseName := n.Ident[0] + if varType, exists := rangeVars[varBaseName]; exists { + // Resolve the field chain starting from the variable's type + fieldIdents := n.Ident[1:] // Skip the variable name, keep the fields + fieldInfo := resolveFieldChain(varType, fieldIdents, currentPath) + + // If resolution failed and we have multiple parts, the last part might be a method + // Try resolving without the last identifier (e.g., OnBoot.BoolDefaultTrue -> OnBoot) + if fieldInfo == nil && len(fieldIdents) > 1 { + fieldIdents = fieldIdents[:len(fieldIdents)-1] + fieldInfo = resolveFieldChain(varType, fieldIdents, currentPath) + // Use a simplified variable name without the method + if fieldInfo != nil { + // Build simplified name: $varBaseName.field1.field2 (without method) + simplifiedName := varBaseName + for _, ident := range fieldIdents { + simplifiedName += "." + ident + } + fieldInfo.VarName = simplifiedName + } + } + + // If resolution still failed, try adding "P" suffix to last identifier + // This handles methods like Primary() backed by PrimaryP field + if fieldInfo == nil && len(fieldIdents) >= 1 { + // Try with "P" suffix on the last identifier + fieldIdentsWithP := make([]string, len(fieldIdents)) + copy(fieldIdentsWithP, fieldIdents) + fieldIdentsWithP[len(fieldIdentsWithP)-1] += "P" + fieldInfo = resolveFieldChain(varType, fieldIdentsWithP, currentPath) + // Use the original variable name (without P) + if fieldInfo != nil { + fieldInfo.VarName = varName + } + } + + if fieldInfo != nil { + if fieldInfo.VarName == "" { + fieldInfo.VarName = varName + } + result[fieldInfo.VarName] = *fieldInfo + } + } + } else if len(n.Ident) == 1 { + // Simple variable reference + varBaseName := n.Ident[0] + if varType, exists := rangeVars[varBaseName]; exists { + result[varName] = FieldInfo{ + VarName: varName, + ParentType: varType, + FullPath: currentPath, + } + } + } + } +} + +// extractFieldFromPipe extracts the FieldNode from a pipe (used in range statements) +func extractFieldFromPipe(pipe *parse.PipeNode) *parse.FieldNode { + if pipe == nil || len(pipe.Cmds) == 0 { + return nil + } + for _, cmd := range pipe.Cmds { + for _, arg := range cmd.Args { + if field, ok := arg.(*parse.FieldNode); ok { + return field + } + } + } + return nil +} + +// resolveFieldChain walks a chain of field identifiers (like ["Ipmi", "Ipaddr"]) +// and returns the final field's information using reflection. +// Methods are resolved and reported. If a method has a backing field with "P" suffix, +// the backing field's metadata is used; otherwise, the method's return type is used. +func resolveFieldChain(rootType reflect.Type, idents []string, basePath string) *FieldInfo { + if rootType.Kind() == reflect.Ptr { + rootType = rootType.Elem() + } + + if len(idents) == 0 { + return nil + } + + currentType := rootType + fullPath := basePath + var finalField reflect.StructField + var parentType reflect.Type + + for i, fieldName := range idents { + if currentType.Kind() != reflect.Struct { + return nil + } + + field, found := currentType.FieldByName(fieldName) + if !found { + // Field not found - try to find a method with this name + // This handles methods like DiskList(), PartitionList(), Id(), ShouldExist() + ptrType := reflect.PointerTo(currentType) + method, methodFound := ptrType.MethodByName(fieldName) + if !methodFound { + return nil + } + + // Get the method's return type (first return value) + methodType := method.Type + if methodType.NumOut() == 0 { + return nil + } + returnType := methodType.Out(0) + + // Check if there's a backing field with "P" suffix + // Only methods with backing fields should be reported as user-facing variables + backingFieldName := fieldName + "P" + backingField, backingFound := currentType.FieldByName(backingFieldName) + + // For the last identifier in the chain + if i == len(idents)-1 { + if backingFound { + // Use the backing field's metadata (tags) but the method's return type + // This gives us the documentation from ShouldExistP but the type from ShouldExist() + finalField = reflect.StructField{ + Name: fieldName, + Type: returnType, // Use method's return type, not backing field's type + Tag: backingField.Tag, + } + parentType = currentType + fullPath += "." + fieldName // Use method name in path, not field name + currentType = returnType + } else { + // No backing field - create a field with the method's return type + // This allows documenting methods via comments + finalField = reflect.StructField{ + Name: fieldName, + Type: returnType, + } + parentType = currentType + fullPath += "." + fieldName + currentType = returnType + } + } else { + // Not the last identifier - we're in the middle of a chain (e.g., DiskList in node.DiskList.PartitionList) + // Continue walking with the method's return type for type resolution + parentType = currentType + fullPath += "." + fieldName + currentType = returnType + + // We don't have a real field yet, just continue to next identifier + // Don't set finalField here as we need to keep walking + continue + } + } else { + // Field found + finalField = field + parentType = currentType + fullPath += "." + fieldName + currentType = field.Type + } + + // Dereference pointer types for next iteration + if currentType.Kind() == reflect.Ptr { + currentType = currentType.Elem() + } + + // For map types, remaining identifiers are map keys, not fields + if currentType.Kind() == reflect.Map && i < len(idents)-1 { + // Append remaining path as map keys + mapKeyPath := "" + for j := i + 1; j < len(idents); j++ { + mapKeyPath += "." + idents[j] + } + fullPath += mapKeyPath + + // Create a synthetic field with the map's value type + // For Tags (map[string]string), accessing Tags.key should return string, not map[string]string + valueType := currentType.Elem() + return &FieldInfo{ + Field: reflect.StructField{ + Name: idents[len(idents)-1], // Use the last key as the field name + Type: valueType, // Use the map's value type + }, + ParentType: parentType, + FullPath: fullPath, + } + } + } + + return &FieldInfo{ + Field: finalField, + ParentType: parentType, + FullPath: fullPath, } } diff --git a/overlays/netplan/rootfs/etc/netplan/warewulf.yaml.ww b/overlays/netplan/rootfs/etc/netplan/warewulf.yaml.ww index f68a618e..6a053481 100644 --- a/overlays/netplan/rootfs/etc/netplan/warewulf.yaml.ww +++ b/overlays/netplan/rootfs/etc/netplan/warewulf.yaml.ww @@ -2,10 +2,10 @@ network: version: 2 renderer: networkd +{{- $NetDevs := .NetDevs }} {{- $ethernets := list }} {{- $bonds := list }} -{{- $NetDevs := .NetDevs }} -{{- range $devname, $netdev := .NetDevs }} +{{- range $devname, $netdev := $NetDevs }} {{- if $netdev.Device }} {{- if eq (default "ethernet" (lower $netdev.Type)) "ethernet" }} {{- $ethernets = append $ethernets $devname }} @@ -16,8 +16,8 @@ network: {{- end }} {{- if $ethernets }} ethernets: -{{- range $devname := $ethernets }} -{{- $netdev := (index $NetDevs $devname) }} +{{- range $netdev := $NetDevs }} +{{- if eq (default "ethernet" (lower $netdev.Type)) "ethernet" }} {{$netdev.Device}}: dhcp4: no optional: true @@ -30,10 +30,11 @@ network: {{- end }} {{- end }} {{- end }} +{{- end }} {{- if $bonds }} bonds: -{{- range $devname := $bonds }} -{{- $netdev := (index $NetDevs $devname) }} +{{- range $netdev := $NetDevs }} +{{- if eq (lower $netdev.Type) "bond" }} {{$netdev.Device}}: dhcp4: no optional: true @@ -61,3 +62,4 @@ network: {{- end }} {{- end }} {{- end }} +{{- end }} From 9ed245f9fefa5c378c221b98bc0fcb2ad5ed8d58 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Tue, 9 Dec 2025 21:57:41 -0700 Subject: [PATCH 10/11] Update test suite for wwctl overlay info Signed-off-by: Jonathon Anderson --- internal/app/wwctl/overlay/info/main.go | 7 ++ internal/app/wwctl/overlay/info/main_test.go | 111 +++++++++++-------- 2 files changed, 70 insertions(+), 48 deletions(-) diff --git a/internal/app/wwctl/overlay/info/main.go b/internal/app/wwctl/overlay/info/main.go index f0e2af6b..e92bc4f7 100644 --- a/internal/app/wwctl/overlay/info/main.go +++ b/internal/app/wwctl/overlay/info/main.go @@ -32,12 +32,19 @@ func CobraRunE(cmd *cobra.Command, args []string) error { commentMap := ov.ParseCommentVars(filePath) commentKeys := maps.Keys(commentMap) sort.Strings(commentKeys) + hasWwdoc := false for _, docLn := range commentKeys { if strings.Contains(docLn, "wwdoc") { wwlog.Info(commentMap[docLn]) + hasWwdoc = true } } + // Add newline after wwdoc lines if they exist + if hasWwdoc { + fmt.Fprintln(cmd.OutOrStdout()) + } + // Sort variables by name for consistent output varNames := maps.Keys(varFields) sort.Strings(varNames) diff --git a/internal/app/wwctl/overlay/info/main_test.go b/internal/app/wwctl/overlay/info/main_test.go index e10b78b0..2b906741 100644 --- a/internal/app/wwctl/overlay/info/main_test.go +++ b/internal/app/wwctl/overlay/info/main_test.go @@ -11,61 +11,76 @@ import ( ) func Test_Overlay_Variables(t *testing.T) { - env := testenv.New(t) - defer env.RemoveAll() - wwlog.SetLogLevel(wwlog.DEBUG) - warewulfd.SetNoDaemon() - - templateContent := ` + tests := []struct { + name string + writeFiles map[string]string + args []string + expectError bool + expectedOutput string + }{ + { + name: "overlay variables", + writeFiles: map[string]string{ + "var/lib/warewulf/overlays/test-overlay/test.ww": ` {{/* .Kernel.Tags.foo: "some help text" */}} {{/* wwdoc1: First Line */}} -{{/* wwdoc2: Second Line */}} -{{ .Kernel.Tags.foo }} {{ .Node.Tags.bar }} -{{ .Cluster.Tags.baz }} -{{ .Kernel.Vars }} -` - env.WriteFile("var/lib/warewulf/overlays/test-overlay/test.ww", templateContent) +{{/* wwdoc2: Second Line */}} +`, + }, + args: []string{"test-overlay", "test.ww"}, + expectError: false, + expectedOutput: `First Line +Second Line - t.Run("overlay variables", func(t *testing.T) { - baseCmd := GetCommand() - buf := new(bytes.Buffer) - baseCmd.SetOut(buf) - baseCmd.SetErr(buf) - wwlog.SetLogWriter(buf) +VARIABLE OPTION TYPE HELP +-------- ------ ---- ---- +.Node.Tags.bar string +`, + }, + { + name: "overlay variables no file", + writeFiles: map[string]string{ + "var/lib/warewulf/overlays/test-overlay/test.ww": ``, + }, + args: []string{"test-overlay", "no-file.ww"}, + expectError: true, + }, + { + name: "overlay variables no overlay", + args: []string{"no-overlay", "test.ww"}, + expectError: true, + }, + } - baseCmd.SetArgs([]string{"test-overlay", "test.ww"}) - err := baseCmd.Execute() - assert.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := testenv.New(t) + defer env.RemoveAll() + warewulfd.SetNoDaemon() - output := buf.String() - assert.Contains(t, output, "VARIABLE") - assert.Contains(t, output, ".Kernel.Tags.foo") - assert.Contains(t, output, "some help text") - assert.Regexp(t, `(?s)First Line.*Second Line`, output, "First Line should come before Second Line") - }) + for path, content := range tt.writeFiles { + env.WriteFile(path, content) + } + baseCmd := GetCommand() + buf := new(bytes.Buffer) + baseCmd.SetOut(buf) + baseCmd.SetErr(buf) + wwlog.SetLogWriter(buf) - 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) + baseCmd.SetArgs(tt.args) + err := baseCmd.Execute() - baseCmd.SetArgs([]string{"test-overlay", "no-file.ww"}) - err := baseCmd.Execute() - assert.Error(t, err) - }) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(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) - }) + if tt.expectedOutput != "" { + output := buf.String() + assert.Equal(t, tt.expectedOutput, output) + } + }) + } } From f82d90123a9fd53584e85c6fa58c0d8e0a90edc0 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Tue, 9 Dec 2025 22:32:29 -0700 Subject: [PATCH 11/11] Additional tests for wwctl overlay info Signed-off-by: Jonathon Anderson --- internal/app/wwctl/overlay/info/main_test.go | 294 ++++++++++++++++++- 1 file changed, 287 insertions(+), 7 deletions(-) diff --git a/internal/app/wwctl/overlay/info/main_test.go b/internal/app/wwctl/overlay/info/main_test.go index 2b906741..d08c383b 100644 --- a/internal/app/wwctl/overlay/info/main_test.go +++ b/internal/app/wwctl/overlay/info/main_test.go @@ -30,13 +30,12 @@ func Test_Overlay_Variables(t *testing.T) { }, args: []string{"test-overlay", "test.ww"}, expectError: false, - expectedOutput: `First Line -Second Line - -VARIABLE OPTION TYPE HELP --------- ------ ---- ---- -.Node.Tags.bar string -`, + expectedOutput: "First Line\n" + + "Second Line\n" + + "\n" + + "VARIABLE OPTION TYPE HELP\n" + + "-------- ------ ---- ----\n" + + ".Node.Tags.bar string \n", }, { name: "overlay variables no file", @@ -51,6 +50,281 @@ VARIABLE OPTION TYPE HELP args: []string{"no-overlay", "test.ww"}, expectError: true, }, + { + name: "nested fields multiple levels", + writeFiles: map[string]string{ + "var/lib/warewulf/overlays/test-overlay/nested.ww": ` +{{ .Kernel.Args }} +{{ .Ipmi.UserName }} +{{ .Ipmi.Ipaddr }} +{{ .Warewulf.Port }} +`, + }, + args: []string{"test-overlay", "nested.ww"}, + expectError: false, + expectedOutput: "VARIABLE OPTION TYPE HELP\n" + + "-------- ------ ---- ----\n" + + ".Ipmi.Ipaddr --ipmiaddr IP Set the IPMI IP address\n" + + ".Ipmi.UserName --ipmiuser string Set the IPMI username\n" + + ".Kernel.Args --kernelargs []string Set kernel arguments\n" + + ".Warewulf.Port int \n", + }, + { + name: "template without variables", + writeFiles: map[string]string{ + "var/lib/warewulf/overlays/test-overlay/no-vars.ww": ` +# Static configuration file +# This file has no template variables +static_value=true +`, + }, + args: []string{"test-overlay", "no-vars.ww"}, + expectError: false, + expectedOutput: `VARIABLE OPTION TYPE HELP +-------- ------ ---- ---- +`, + }, + { + name: "only documentation no variables", + writeFiles: map[string]string{ + "var/lib/warewulf/overlays/test-overlay/doc-only.ww": ` +{{/* wwdoc: This is just documentation */}} +{{/* wwdoc2: No actual template variables used */}} +Static content here +`, + }, + args: []string{"test-overlay", "doc-only.ww"}, + expectError: false, + expectedOutput: `This is just documentation +No actual template variables used + +VARIABLE OPTION TYPE HELP +-------- ------ ---- ---- +`, + }, + { + name: "template parse error", + writeFiles: map[string]string{ + "var/lib/warewulf/overlays/test-overlay/invalid.ww": ` +{{ .Id } +{{ unclosed range .NetDevs }} +`, + }, + args: []string{"test-overlay", "invalid.ww"}, + expectError: true, + }, + { + name: "inline comment documentation", + writeFiles: map[string]string{ + "var/lib/warewulf/overlays/test-overlay/inline-doc.ww": ` +{{/* .Id: The unique node identifier */}} +{{/* .Hostname: The node's hostname in DNS */}} +{{/* .Tags.env: Environment tag (prod, dev, test) */}} +{{ .Id }} +{{ .Hostname }} +{{ .Tags.env }} +`, + }, + args: []string{"test-overlay", "inline-doc.ww"}, + expectError: false, + expectedOutput: `VARIABLE OPTION TYPE HELP +-------- ------ ---- ---- +.Hostname string The node's hostname in DNS +.Id string The unique node identifier +.Tags.env string Environment tag (prod, dev, test) +`, + }, + { + name: "complex conditional branches", + writeFiles: map[string]string{ + "var/lib/warewulf/overlays/test-overlay/conditionals.ww": ` +{{ if eq .Kernel.Version "5.15" }} +Old kernel +{{ else if gt (len .NetDevs) 0 }} +Has network: {{ .Id }} +{{ else }} +Default: {{ .Hostname }} +{{ end }} +`, + }, + args: []string{"test-overlay", "conditionals.ww"}, + expectError: false, + expectedOutput: "VARIABLE OPTION TYPE HELP\n" + + "-------- ------ ---- ----\n" + + ".Hostname string \n" + + ".Id string \n" + + ".Kernel.Version --kernelversion string Set kernel version\n" + + ".NetDevs map[string]*node.NetDev \n", + }, + { + name: "sprig function pipelines", + writeFiles: map[string]string{ + "var/lib/warewulf/overlays/test-overlay/sprig.ww": ` +{{ .Id | upper }} +{{ .Hostname | lower }} +{{ .Kernel.Args | join " " }} +{{ .Tags.foo | default "bar" }} +`, + }, + args: []string{"test-overlay", "sprig.ww"}, + expectError: false, + expectedOutput: "VARIABLE OPTION TYPE HELP\n" + + "-------- ------ ---- ----\n" + + ".Hostname string \n" + + ".Id string \n" + + ".Kernel.Args --kernelargs []string Set kernel arguments\n" + + ".Tags.foo string \n", + }, + { + name: "include function usage", + writeFiles: map[string]string{ + "var/lib/warewulf/overlays/test-overlay/include.ww": ` +{{ Include "/etc/passwd" }} +{{ IncludeFrom .ImageName "/etc/group" }} +`, + }, + args: []string{"test-overlay", "include.ww"}, + expectError: false, + expectedOutput: "VARIABLE OPTION TYPE HELP\n" + + "-------- ------ ---- ----\n" + + ".ImageName --image string Set image name\n", + }, + { + name: "mixed documentation types", + writeFiles: map[string]string{ + "var/lib/warewulf/overlays/test-overlay/mixed-doc.ww": ` +{{/* wwdoc: Configuration for network interfaces */}} +{{/* wwdoc-details: This template generates network configs */}} +{{/* .NetDevs: Map of network devices by name */}} +{{/* .Id: Node identifier */}} +{{ .Id }} +{{ .NetDevs }} +`, + }, + args: []string{"test-overlay", "mixed-doc.ww"}, + expectError: false, + expectedOutput: "Configuration for network interfaces\n" + + "This template generates network configs\n" + + "\n" + + "VARIABLE OPTION TYPE HELP\n" + + "-------- ------ ---- ----\n" + + ".Id string Node identifier\n" + + ".NetDevs map[string]*node.NetDev Map of network devices by name\n", + }, + { + name: "tag field access", + writeFiles: map[string]string{ + "var/lib/warewulf/overlays/test-overlay/tags.ww": ` +{{ .Tags.foo }} +{{ .Ipmi.Tags.vlan }} +{{ .NetDevs }} +`, + }, + args: []string{"test-overlay", "tags.ww"}, + expectError: false, + expectedOutput: "VARIABLE OPTION TYPE HELP\n" + + "-------- ------ ---- ----\n" + + ".Ipmi.Tags.vlan string \n" + + ".NetDevs map[string]*node.NetDev \n" + + ".Tags.foo string \n", + }, + { + name: "dollar sign root context", + writeFiles: map[string]string{ + "var/lib/warewulf/overlays/test-overlay/dollar.ww": ` +{{ $.Id }} +{{ $.BuildHost }} +{{ $.Ipaddr }} +`, + }, + args: []string{"test-overlay", "dollar.ww"}, + expectError: false, + expectedOutput: "VARIABLE OPTION TYPE HELP\n" + + "-------- ------ ---- ----\n" + + "$.BuildHost string \n" + + "$.Id string \n" + + "$.Ipaddr string \n", + }, + { + name: "range over map basic", + writeFiles: map[string]string{ + "var/lib/warewulf/overlays/test-overlay/range-map.ww": ` +{{/* wwdoc: Network device iteration */}} +{{- range $devname, $netdev := .NetDevs }} +Device: {{ $netdev.Device }} +Type: {{ $netdev.Type }} +IP: {{ $netdev.Ipaddr }} +{{- end }} +`, + }, + args: []string{"test-overlay", "range-map.ww"}, + expectError: false, + expectedOutput: "Network device iteration\n" + + "\n" + + "VARIABLE OPTION TYPE HELP\n" + + "-------- ------ ---- ----\n" + + "$netdev.Device --netdev string Set the device for given network\n" + + "$netdev.Ipaddr --ipaddr IP IPv4 address in given network\n" + + "$netdev.Type --type string Set device type of given network\n" + + ".NetDevs map[string]*node.NetDev \n", + }, + { + name: "range over map with output verification", + writeFiles: map[string]string{ + "var/lib/warewulf/overlays/test-overlay/range-verify.ww": ` +{{- range $devname, $netdev := .NetDevs }} +{{ $devname }} +{{ $netdev.Device }} +{{ $netdev.Type }} +{{- end }} +`, + }, + args: []string{"test-overlay", "range-verify.ww"}, + expectError: false, + expectedOutput: "VARIABLE OPTION TYPE HELP\n" + + "-------- ------ ---- ----\n" + + "$netdev.Device --netdev string Set the device for given network\n" + + "$netdev.Type --type string Set device type of given network\n" + + ".NetDevs map[string]*node.NetDev \n", + }, + { + name: "empty collection checks", + writeFiles: map[string]string{ + "var/lib/warewulf/overlays/test-overlay/empty.ww": ` +{{ if gt (len .NetDevs) 0 }} +Has devices +{{ end }} +{{ if .FileSystems }} +Has filesystems +{{ end }} +`, + }, + args: []string{"test-overlay", "empty.ww"}, + expectError: false, + expectedOutput: "VARIABLE OPTION TYPE HELP\n" + + "-------- ------ ---- ----\n" + + ".FileSystems map[string]*node.FileSystem \n" + + ".NetDevs map[string]*node.NetDev \n", + }, + { + name: "file and abort directives", + writeFiles: map[string]string{ + "var/lib/warewulf/overlays/test-overlay/control.ww": ` +{{ file "output.conf" }} +{{ if .Tags.enabled }} +Config: {{ .Tags.value }} +{{ else }} +{{ abort }} +{{ end }} +`, + }, + args: []string{"test-overlay", "control.ww"}, + expectError: false, + expectedOutput: "VARIABLE OPTION TYPE HELP\n" + + "-------- ------ ---- ----\n" + + ".Tags.enabled string \n" + + ".Tags.value string \n", + }, } for _, tt := range tests { @@ -80,6 +354,12 @@ VARIABLE OPTION TYPE HELP if tt.expectedOutput != "" { output := buf.String() assert.Equal(t, tt.expectedOutput, output) + } else { + // For tests without expected output, print actual output for debugging + // This helps understand what variables are being detected + if testing.Verbose() { + t.Logf("Output:\n%s", buf.String()) + } } }) }