diff --git a/CHANGELOG.md b/CHANGELOG.md index 88ab83ea..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 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/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/info/main.go b/internal/app/wwctl/overlay/info/main.go new file mode 100644 index 00000000..e92bc4f7 --- /dev/null +++ b/internal/app/wwctl/overlay/info/main.go @@ -0,0 +1,99 @@ +package info + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + "github.com/warewulf/warewulf/internal/app/wwctl/table" + "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 { + 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 + } + + // 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) + 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) + + t := table.New(cmd.OutOrStdout()) + t.AddHeader("VARIABLE", "OPTION", "TYPE", "HELP") + + for _, varName := range varNames { + fieldInfo := varFields[varName] + helpText, hasCommentHelp := commentMap[varName] + + // 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 + } + + // 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") + } + + // 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/app/wwctl/overlay/info/main_test.go b/internal/app/wwctl/overlay/info/main_test.go new file mode 100644 index 00000000..d08c383b --- /dev/null +++ b/internal/app/wwctl/overlay/info/main_test.go @@ -0,0 +1,366 @@ +package info + +import ( + "bytes" + "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) { + 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 */}} +{{ .Node.Tags.bar }} +{{/* wwdoc2: Second Line */}} +`, + }, + args: []string{"test-overlay", "test.ww"}, + expectError: false, + expectedOutput: "First Line\n" + + "Second Line\n" + + "\n" + + "VARIABLE OPTION TYPE HELP\n" + + "-------- ------ ---- ----\n" + + ".Node.Tags.bar string \n", + }, + { + 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, + }, + { + 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 { + t.Run(tt.name, func(t *testing.T) { + env := testenv.New(t) + defer env.RemoveAll() + warewulfd.SetNoDaemon() + + 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) + + baseCmd.SetArgs(tt.args) + err := baseCmd.Execute() + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + 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()) + } + } + }) + } +} diff --git a/internal/app/wwctl/overlay/info/root.go b/internal/app/wwctl/overlay/info/root.go new file mode 100644 index 00000000..eee3f4b8 --- /dev/null +++ b/internal/app/wwctl/overlay/info/root.go @@ -0,0 +1,38 @@ +package info + +import ( + "github.com/spf13/cobra" + "github.com/warewulf/warewulf/internal/pkg/overlay" +) + +var ( + baseCmd = &cobra.Command{ + 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{"variables", "vars"}, + 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 +} diff --git a/internal/app/wwctl/overlay/root.go b/internal/app/wwctl/overlay/root.go index 529da93c..1c7ed6d5 100644 --- a/internal/app/wwctl/overlay/root.go +++ b/internal/app/wwctl/overlay/root.go @@ -9,6 +9,7 @@ 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" @@ -35,6 +36,7 @@ func init() { baseCmd.AddCommand(imprt.GetCommand()) baseCmd.AddCommand(chmod.GetCommand()) baseCmd.AddCommand(chown.GetCommand()) + baseCmd.AddCommand(info.GetCommand()) } // GetRootCommand returns the root cobra.Command for the application. 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/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/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/node/methods.go b/internal/pkg/node/methods.go index a5ae6a5c..1f6853f2 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,13 @@ 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 +} /* Returns the id of the node diff --git a/internal/pkg/overlay/overlay.go b/internal/pkg/overlay/overlay.go index c0c38547..79342ea9 100644 --- a/internal/pkg/overlay/overlay.go +++ b/internal/pkg/overlay/overlay.go @@ -8,10 +8,12 @@ import ( "os" "path" "path/filepath" + "reflect" "regexp" "strings" "sync" "text/template" + "text/template/parse" "github.com/Masterminds/sprig/v3" "github.com/coreos/go-systemd/v22/unit" @@ -272,6 +274,481 @@ func (overlay Overlay) Mkdir(path string, mode int32) (err error) { return os.MkdirAll(fullPath, os.FileMode(mode)) } +// 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 + } + fullPath := overlay.File(file) + if !util.IsFile(fullPath) { + wwlog.Error("Template file does not exist in overlay %s: %s", overlay.Name(), file) + return nil + } + + 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) + return nil + } + + 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, rootType, "", rangeVars, result) + } + + 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) (retMap map[string]string) { + retMap = make(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 + } + + re := regexp.MustCompile(`{{\s*/\*\s*(.*?):\s*(.*?)\s*\*/\s*}}`) + matches := re.FindAllStringSubmatch(string(content), -1) + 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] + } + } + return +} + +// 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: + // 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, 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, currentType, currentPath, rangeVars, result) + } + } + + case *parse.RangeNode: + // 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, currentType, currentPath, rangeVars, result) + walkParseTree(n.List, currentType, currentPath, rangeVars, result) + walkParseTree(n.ElseList, currentType, currentPath, rangeVars, result) + + case *parse.TemplateNode: + 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, currentType, currentPath, rangeVars, result) + } + } + } + + 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, + } +} + 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) @@ -536,7 +1013,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 @@ -561,7 +1038,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) } @@ -594,7 +1071,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) } @@ -668,21 +1145,15 @@ 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) (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, @@ -698,12 +1169,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 +1186,20 @@ func RenderTemplateFile(fileName string, data TemplateStruct) ( for key, value := range sprig.TxtFuncMap() { funcMap[key] = value } + return funcMap, writeFile, backupFile +} + +/* +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, writeFile *bool, + err error, +) { + + 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) 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 }} diff --git a/userdocs/overlays/overlays.rst b/userdocs/overlays/overlays.rst index a12c48e0..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 ========= @@ -163,6 +194,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 ===================== @@ -373,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 ==================