Merge pull request #2014 from mslacken/showTags

show used Tags for overlays
This commit is contained in:
Christian Goll
2025-12-10 10:21:20 +01:00
committed by GitHub
15 changed files with 1093 additions and 42 deletions

View File

@@ -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.aarch64` overlay always provides an aarch64 wwclient executable.
- `wwclient.x86_64` overlay always provides an x86_64 wwclient executable. - `wwclient.x86_64` overlay always provides an x86_64 wwclient executable.
- systemd-networkd overlay with IPv6 support - systemd-networkd overlay with IPv6 support
- `wwctl overlay info` lists the variables used by an overlay template
### Changed ### Changed

2
go.mod
View File

@@ -36,6 +36,7 @@ require (
github.com/swaggest/usecase v1.3.1 github.com/swaggest/usecase v1.3.1
github.com/talos-systems/go-smbios v0.1.1 github.com/talos-systems/go-smbios v0.1.1
golang.org/x/crypto v0.32.0 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/sys v0.29.0
golang.org/x/term v0.28.0 golang.org/x/term v0.28.0
google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489 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 v1.32.0 // indirect
go.opentelemetry.io/otel/metric v1.32.0 // indirect go.opentelemetry.io/otel/metric v1.32.0 // indirect
go.opentelemetry.io/otel/trace 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/net v0.33.0 // indirect
golang.org/x/sync v0.11.0 // indirect golang.org/x/sync v0.11.0 // indirect
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.22.0 // indirect

View File

@@ -47,7 +47,7 @@ func CobraRunE(cmd *cobra.Command, args []string) error {
defer os.Remove(tempFile.Name()) defer os.Remove(tempFile.Name())
if !NoHeader { 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 { if _, err := tempFile.WriteString("#nodename:\n# " + strings.Join(yamlTemplate, "\n# ") + "\n"); err != nil {
return err return err
} }

View File

@@ -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
}

View File

@@ -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())
}
}
})
}
}

View File

@@ -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
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/warewulf/warewulf/internal/app/wwctl/overlay/delete" "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/edit"
"github.com/warewulf/warewulf/internal/app/wwctl/overlay/imprt" "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/list"
"github.com/warewulf/warewulf/internal/app/wwctl/overlay/mkdir" "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/show"
@@ -35,6 +36,7 @@ func init() {
baseCmd.AddCommand(imprt.GetCommand()) baseCmd.AddCommand(imprt.GetCommand())
baseCmd.AddCommand(chmod.GetCommand()) baseCmd.AddCommand(chmod.GetCommand())
baseCmd.AddCommand(chown.GetCommand()) baseCmd.AddCommand(chown.GetCommand())
baseCmd.AddCommand(info.GetCommand())
} }
// GetRootCommand returns the root cobra.Command for the application. // GetRootCommand returns the root cobra.Command for the application.

View File

@@ -91,8 +91,8 @@ func CobraRunE(cmd *cobra.Command, args []string) error {
wwlog.Debug("Found multifile comment, new filename %s", filenameFromTemplate[0][1]) wwlog.Debug("Found multifile comment, new filename %s", filenameFromTemplate[0][1])
if foundFileComment { if foundFileComment {
if !Quiet { if !Quiet {
wwlog.Info("backupFile: %v", backupFile) wwlog.Info("backupFile: %v", *backupFile)
wwlog.Info("writeFile: %v", writeFile) wwlog.Info("writeFile: %v", *writeFile)
wwlog.Info("Filename: %s", destFileName) wwlog.Info("Filename: %s", destFileName)
} }
wwlog.Output("%s", outBuffer.String()) wwlog.Output("%s", outBuffer.String())
@@ -105,8 +105,8 @@ func CobraRunE(cmd *cobra.Command, args []string) error {
} }
} }
if !Quiet { if !Quiet {
wwlog.Info("backupFile: %v", backupFile) wwlog.Info("backupFile: %v", *backupFile)
wwlog.Info("writeFile: %v", writeFile) wwlog.Info("writeFile: %v", *writeFile)
wwlog.Info("Filename: %s", destFileName) wwlog.Info("Filename: %s", destFileName)
} }
wwlog.Output("%s", outBuffer.String()) wwlog.Output("%s", outBuffer.String())

View File

@@ -44,7 +44,7 @@ func CobraRunE(cmd *cobra.Command, args []string) error {
defer os.Remove(tempFile.Name()) defer os.Remove(tempFile.Name())
if !NoHeader { 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 { if _, err := tempFile.WriteString("#profilename:\n# " + strings.Join(yamlTemplate, "\n# ") + "\n"); err != nil {
return err return err
} }

View File

@@ -49,8 +49,8 @@ func Hostfile() (err error) {
return return
} }
if writeFile { if *writeFile {
err = overlay.CarefulWriteBuffer("/etc/hosts", buffer, backupFile, info.Mode()) err = overlay.CarefulWriteBuffer("/etc/hosts", buffer, *backupFile, info.Mode())
if err != nil { if err != nil {
return fmt.Errorf("could not write file from template: %w", err) return fmt.Errorf("could not write file from template: %w", err)
} }

View File

@@ -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 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 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) objType := reflect.TypeOf(obj)
// now iterate of every field // now iterate of every field
for i := 0; i < objType.NumField(); i++ { for i := 0; i < objType.NumField(); i++ {
@@ -264,7 +264,7 @@ func UnmarshalConf(obj interface{}, excludeList []string) (lines []string) {
typeLine = strings.Split(typeLine, ",")[0] + ":" typeLine = strings.Split(typeLine, ",")[0] + ":"
} }
lines = append(lines, typeLine) 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 { for _, ln := range nestedLine {
lines = append(lines, " "+ln) lines = append(lines, " "+ln)
} }
@@ -274,12 +274,12 @@ func UnmarshalConf(obj interface{}, excludeList []string) (lines []string) {
typeLine = strings.Split(typeLine, ",")[0] + ":" typeLine = strings.Split(typeLine, ",")[0] + ":"
} }
lines = append(lines, typeLine, " element:") 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 { for _, ln := range nestedLine {
lines = append(lines, " "+ln) lines = append(lines, " "+ln)
} }
} else if field.Type.Kind() == reflect.Struct && field.Anonymous { } 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...) lines = append(lines, nestedLine...)
} }
} }
@@ -316,9 +316,13 @@ func getYamlString(myType reflect.StructField, excludeList []string) ([]string,
return []string{ymlStr}, true return []string{ymlStr}, true
} }
/* // struectred type for variable
Getters for unexported fields type TemplateVarDetails struct {
*/ Name string
Comment string
Type string
LongOpt string
}
/* /*
Returns the id of the node Returns the id of the node

View File

@@ -8,10 +8,12 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"reflect"
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
"text/template" "text/template"
"text/template/parse"
"github.com/Masterminds/sprig/v3" "github.com/Masterminds/sprig/v3"
"github.com/coreos/go-systemd/v22/unit" "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)) 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 { func BuildAllOverlays(nodes []node.Node, allNodes []node.Node, workerCount int) error {
nodeChan := make(chan node.Node, len(nodes)) nodeChan := make(chan node.Node, len(nodes))
errChan := make(chan error, len(nodes)*2) errChan := make(chan error, len(nodes)*2)
@@ -536,7 +1013,7 @@ func BuildOverlayIndir(nodeData node.Node, allNodes []node.Node, overlayNames []
if err != nil { if err != nil {
return fmt.Errorf("failed to render template %s: %w", walkPath, err) return fmt.Errorf("failed to render template %s: %w", walkPath, err)
} }
if !writeFile { if !*writeFile {
return nil return nil
} }
var fileBuffer bytes.Buffer var fileBuffer bytes.Buffer
@@ -561,7 +1038,7 @@ func BuildOverlayIndir(nodeData node.Node, allNodes []node.Node, overlayNames []
} else if len(filenameFromTemplate) != 0 { } else if len(filenameFromTemplate) != 0 {
wwlog.Debug("Writing file %s", filenameFromTemplate[0][1]) wwlog.Debug("Writing file %s", filenameFromTemplate[0][1])
if writingToNamedFile && !isLink { if writingToNamedFile && !isLink {
err = CarefulWriteBuffer(outputPath, fileBuffer, backupFile, info.Mode()) err = CarefulWriteBuffer(outputPath, fileBuffer, *backupFile, info.Mode())
if err != nil { if err != nil {
return fmt.Errorf("could not write file from template: %w", err) 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 { if !isLink {
err = CarefulWriteBuffer(outputPath, fileBuffer, backupFile, info.Mode()) err = CarefulWriteBuffer(outputPath, fileBuffer, *backupFile, info.Mode())
if err != nil { if err != nil {
return fmt.Errorf("could not write file from template: %w", err) 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 return err
} }
/* // getTemplateFuncMap returns a template.FuncMap with all the functions
Parses the template with the given filename, variables must be in data. Returns the // for warewulf templates.
parsed template as bytes.Buffer, and the bool variables for backupFile and writeFile. func getTemplateFuncMap(fileName string, data TemplateStruct) (funcMap template.FuncMap, writeFile, backupFile *bool) {
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
// Build our FuncMap // Build our FuncMap
funcMap := template.FuncMap{ _writeFile := true
_backupFile := true
writeFile = &_writeFile
backupFile = &_backupFile
funcMap = template.FuncMap{
"Include": templateFileInclude, "Include": templateFileInclude,
"IncludeFrom": templateImageFileInclude, "IncludeFrom": templateImageFileInclude,
"IncludeBlock": templateFileBlock, "IncludeBlock": templateFileBlock,
@@ -698,12 +1169,12 @@ func RenderTemplateFile(fileName string, data TemplateStruct) (
}, },
"abort": func() string { "abort": func() string {
wwlog.Debug("abort file called in %s", fileName) wwlog.Debug("abort file called in %s", fileName)
writeFile = false *writeFile = false
return "" return ""
}, },
"nobackup": func() string { "nobackup": func() string {
wwlog.Debug("not backup for %s", fileName) wwlog.Debug("not backup for %s", fileName)
backupFile = false *backupFile = false
return "" return ""
}, },
"UniqueField": UniqueField, "UniqueField": UniqueField,
@@ -715,6 +1186,20 @@ func RenderTemplateFile(fileName string, data TemplateStruct) (
for key, value := range sprig.TxtFuncMap() { for key, value := range sprig.TxtFuncMap() {
funcMap[key] = value 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 // Create the template with the merged FuncMap
tmpl, err := template.New(path.Base(fileName)).Option("missingkey=default").Funcs(funcMap).ParseGlob(fileName) tmpl, err := template.New(path.Base(fileName)).Option("missingkey=default").Funcs(funcMap).ParseGlob(fileName)

View File

@@ -2,10 +2,10 @@
network: network:
version: 2 version: 2
renderer: networkd renderer: networkd
{{- $NetDevs := .NetDevs }}
{{- $ethernets := list }} {{- $ethernets := list }}
{{- $bonds := list }} {{- $bonds := list }}
{{- $NetDevs := .NetDevs }} {{- range $devname, $netdev := $NetDevs }}
{{- range $devname, $netdev := .NetDevs }}
{{- if $netdev.Device }} {{- if $netdev.Device }}
{{- if eq (default "ethernet" (lower $netdev.Type)) "ethernet" }} {{- if eq (default "ethernet" (lower $netdev.Type)) "ethernet" }}
{{- $ethernets = append $ethernets $devname }} {{- $ethernets = append $ethernets $devname }}
@@ -16,8 +16,8 @@ network:
{{- end }} {{- end }}
{{- if $ethernets }} {{- if $ethernets }}
ethernets: ethernets:
{{- range $devname := $ethernets }} {{- range $netdev := $NetDevs }}
{{- $netdev := (index $NetDevs $devname) }} {{- if eq (default "ethernet" (lower $netdev.Type)) "ethernet" }}
{{$netdev.Device}}: {{$netdev.Device}}:
dhcp4: no dhcp4: no
optional: true optional: true
@@ -30,10 +30,11 @@ network:
{{- end }} {{- end }}
{{- end }} {{- end }}
{{- end }} {{- end }}
{{- end }}
{{- if $bonds }} {{- if $bonds }}
bonds: bonds:
{{- range $devname := $bonds }} {{- range $netdev := $NetDevs }}
{{- $netdev := (index $NetDevs $devname) }} {{- if eq (lower $netdev.Type) "bond" }}
{{$netdev.Device}}: {{$netdev.Device}}:
dhcp4: no dhcp4: no
optional: true optional: true
@@ -61,3 +62,4 @@ network:
{{- end }} {{- end }}
{{- end }} {{- end }}
{{- end }} {{- end }}
{{- end }}

View File

@@ -25,6 +25,37 @@ within the overlays with ``wwctl overlay list --all``.
fstab etc/ false fstab etc/ false
fstab etc/fstab.ww 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 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 chown issue /etc/issue.ww root root
wwctl overlay chmod issue /etc/issue.ww 0644 wwctl overlay chmod issue /etc/issue.ww 0644
Distribution Overlays 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. the use of most available template metadata.
.. code-block:: shell .. code-block:: shell
wwctl overlay show --render=<nodename> debug tstruct.md.ww wwctl overlay show --render=<nodename> debug tstruct.md.ww
.. _localtime: .. _localtime:

View File

@@ -33,6 +33,28 @@ non-overlay templates as well.
commands for the ``wwctl power``, ``wwctl sensor``, and ``wwctl console`` commands for the ``wwctl power``, ``wwctl sensor``, and ``wwctl console``
commands. 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 Template functions
================== ==================