use comments of variables for help

This commit is contained in:
Christian Goll
2025-10-14 14:54:39 +02:00
committed by Jonathon Anderson
parent facde98fb5
commit 68758e3e45
7 changed files with 183 additions and 87 deletions

View File

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

View File

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

View File

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

View File

@@ -36,4 +36,3 @@ var (
func GetCommand() *cobra.Command {
return baseCmd
}

View File

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

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

View File

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