Fix newline handling in file, softlink, and ImportLink template functions

Use state-based routing instead of sentinel strings, so whitespace-trimming
syntax (e.g. `{{- file "name" -}}`) correctly creates all named files and
symlinks.

Fixes: #2118

Signed-off-by: Jonathon Anderson <janderson@ciq.com>
Co-authored-by: Christian Goll <cgoll@suse.com>
Signed-off-by: Jonathon Anderson <janderson@ciq.com>
This commit is contained in:
Jonathon Anderson
2026-04-17 15:57:19 -06:00
parent eaac55a97a
commit 2bad789f4f
28 changed files with 767 additions and 256 deletions

View File

@@ -24,6 +24,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Fix `wwctl node set --all` to set values on all nodes - Fix `wwctl node set --all` to set values on all nodes
- Fix path traversal vulnerability (CWE-23) - Fix path traversal vulnerability (CWE-23)
- Bump minimum golang version to 1.25.5 to address stdlib CVEs - Bump minimum golang version to 1.25.5 to address stdlib CVEs
- Fix `{{ file }}`, `{{ softlink }}`, and `{{ ImportLink }}` template functions
to use state-based routing instead of sentinel strings, so whitespace-trimming
syntax (e.g. `{{- file "name" -}}`) correctly creates all named files and
symlinks. #2118
### Dependencies ### Dependencies

View File

@@ -1,12 +1,9 @@
package show package show
import ( import (
"bufio"
"bytes"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -28,6 +25,7 @@ func CobraRunE(cmd *cobra.Command, args []string) error {
overlayFile := overlay_.File(fileName) overlayFile := overlay_.File(fileName)
if NodeName == "" { if NodeName == "" {
// No node specified: show the raw template source without rendering.
if !util.IsFile(overlayFile) { if !util.IsFile(overlayFile) {
return fmt.Errorf("%s: %s not found", overlayName, overlayFile) return fmt.Errorf("%s: %s not found", overlayName, overlayFile)
} }
@@ -38,6 +36,9 @@ func CobraRunE(cmd *cobra.Command, args []string) error {
wwlog.Output("%s", string(f)) wwlog.Output("%s", string(f))
} else { } else {
// Node specified: render the template for the given node and show output.
// If the caller gave the output filename (without .ww), find the template.
if !util.IsFile(overlayFile) { if !util.IsFile(overlayFile) {
possibleFile := fmt.Sprintf("%s.ww", overlayFile) possibleFile := fmt.Sprintf("%s.ww", overlayFile)
if filepath.Ext(overlayFile) != ".ww" && util.IsFile(possibleFile) { if filepath.Ext(overlayFile) != ".ww" && util.IsFile(possibleFile) {
@@ -56,6 +57,8 @@ func CobraRunE(cmd *cobra.Command, args []string) error {
} }
nodeConf, err := nodeDB.GetNode(NodeName) nodeConf, err := nodeDB.GetNode(NodeName)
if err == node.ErrNotFound { if err == node.ErrNotFound {
// Unknown node name: fall back to the local hostname so operators can
// preview templates on the warewulf server itself.
hostName, err := os.Hostname() hostName, err := os.Hostname()
if err != nil { if err != nil {
return fmt.Errorf("could not get host name: %s", err) return fmt.Errorf("could not get host name: %s", err)
@@ -73,43 +76,50 @@ func CobraRunE(cmd *cobra.Command, args []string) error {
return err return err
} }
tstruct.BuildSource = overlayFile tstruct.BuildSource = overlayFile
buffer, backupFile, writeFile, err := overlay.RenderTemplateFile(overlayFile, tstruct) rendered, err := overlay.RenderTemplate(overlayFile, tstruct)
if err != nil { if err != nil {
return err return err
} }
var outBuffer bytes.Buffer
// search for magic file name comment if !rendered.WriteFile {
bufferScanner := bufio.NewScanner(bytes.NewReader(buffer.Bytes())) // abort() was called in the template: nothing would be written to disk.
bufferScanner.Split(overlay.ScanLines) // Still show any content rendered before the abort() call for debugging.
reg := regexp.MustCompile(`.*{{\s*/\*\s*file\s*["'](.*)["']\s*\*/\s*}}.*`) if !Quiet {
foundFileComment := false wwlog.Info("backupFile: %v", rendered.BackupFile)
destFileName := strings.TrimSuffix(fileName, ".ww") wwlog.Info("writeFile: %v", rendered.WriteFile)
for bufferScanner.Scan() { }
line := bufferScanner.Text() wwlog.Output("%s", rendered.Files[0].Buffer.String())
filenameFromTemplate := reg.FindAllStringSubmatch(line, -1) if !Quiet {
if len(filenameFromTemplate) != 0 { wwlog.Info("Aborted")
wwlog.Debug("Found multifile comment, new filename %s", filenameFromTemplate[0][1]) }
if foundFileComment { return nil
if !Quiet { }
wwlog.Info("backupFile: %v", *backupFile)
wwlog.Info("writeFile: %v", *writeFile) for _, f := range rendered.Files {
wwlog.Info("Filename: %s", destFileName) // The default slot (Name == "") holds pre-file() content or a symlink.
} // When named files are also present, non-symlink content is discarded
wwlog.Output("%s", outBuffer.String()) // for disk output; skip it here too so that show output matches what
outBuffer.Reset() // is actually written to disk. A default symlink is always shown.
if f.Name == "" && len(rendered.Files) > 1 && !f.IsSymlink {
continue
}
// The default slot's display name is the template filename with .ww stripped.
displayName := f.Name
if displayName == "" {
displayName = strings.TrimSuffix(fileName, ".ww")
}
if !Quiet {
wwlog.Info("backupFile: %v", rendered.BackupFile)
wwlog.Info("writeFile: %v", rendered.WriteFile)
wwlog.Info("Filename: %s", displayName)
if f.IsSymlink {
wwlog.Info("Symlink: %s", f.Target)
} }
destFileName = filenameFromTemplate[0][1] }
foundFileComment = true if !f.IsSymlink {
} else { wwlog.Output("%s", f.Buffer.String())
_, _ = outBuffer.WriteString(line)
} }
} }
if !Quiet {
wwlog.Info("backupFile: %v", *backupFile)
wwlog.Info("writeFile: %v", *writeFile)
wwlog.Info("Filename: %s", destFileName)
}
wwlog.Output("%s", outBuffer.String())
} }
return nil return nil
} }

View File

@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"os" "os"
"path" "path"
"path/filepath"
"strings" "strings"
warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" warewulfconf "github.com/warewulf/warewulf/internal/pkg/config"
@@ -118,19 +117,6 @@ func createIgnitionJson(node *node.Node) string {
return string(tmpYaml) return string(tmpYaml)
} }
func importSoftlink(lnk string) string {
target, err := filepath.EvalSymlinks(lnk)
if err != nil {
return "abort"
}
wwlog.Debug("importing softlink pointing to: %s", target)
return softlink(target)
}
func softlink(target string) string {
return fmt.Sprintf("{{ /* softlink \"%s\" */ }}", target)
}
// UniqueField returns a filtered version of a multi-line input string. input is // UniqueField returns a filtered version of a multi-line input string. input is
// expected to be a field-separated format with one record per line (terminated // expected to be a field-separated format with one record per line (terminated
// by `\n`). Order of lines is preserved, with the first matching line taking // by `\n`). Order of lines is preserved, with the first matching line taking

View File

@@ -1,8 +1,8 @@
package overlay package overlay
import ( import (
"bufio"
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"os" "os"
@@ -294,7 +294,13 @@ func (overlay Overlay) ParseVarFields(file string) map[string]FieldInfo {
return nil return nil
} }
funcMap, _, _ := getTemplateFuncMap(fullPath, TemplateStruct{}) renderState := &RenderedTemplate{
Files: []*RenderedFile{
{Name: ""},
},
}
renderWriter := &multiFileWriter{current: &renderState.Files[0].Buffer}
funcMap := buildTemplateFuncMap(fullPath, TemplateStruct{}, renderState, renderWriter)
tmpl, err := template.New(path.Base(fullPath)).Option("missingkey=default").Funcs(funcMap).ParseFiles(fullPath) tmpl, err := template.New(path.Base(fullPath)).Option("missingkey=default").Funcs(funcMap).ParseFiles(fullPath)
if err != nil { if err != nil {
wwlog.Error("Could not parse template file %s: %s", fullPath, err) wwlog.Error("Could not parse template file %s: %s", fullPath, err)
@@ -946,16 +952,6 @@ func BuildOverlay(nodeConf node.Node, allNodes []node.Node, context string, over
return err return err
} }
var (
regFile *regexp.Regexp
regLink *regexp.Regexp
)
func init() {
regFile = regexp.MustCompile(`.*{{\s*/\*\s*file\s*["'](.*)["']\s*\*/\s*}}.*`)
regLink = regexp.MustCompile(`.*{{\s*/\*\s*softlink\s*["'](.*)["']\s*\*/\s*}}.*`)
}
// Build the given overlays for a node in the given directory. // Build the given overlays for a node in the given directory.
func BuildOverlayIndir(nodeData node.Node, allNodes []node.Node, overlayNames []string, outputDir string) error { func BuildOverlayIndir(nodeData node.Node, allNodes []node.Node, overlayNames []string, outputDir string) error {
if len(overlayNames) == 0 { if len(overlayNames) == 0 {
@@ -1005,7 +1001,7 @@ func BuildOverlayIndir(nodeData node.Node, allNodes []node.Node, overlayNames []
} else if filepath.Ext(walkPath) == ".ww" { } else if filepath.Ext(walkPath) == ".ww" {
originalOutputPath := outputPath originalOutputPath := outputPath
outputPath := strings.TrimSuffix(outputPath, ".ww") defaultOutputPath := strings.TrimSuffix(outputPath, ".ww")
tstruct, err := InitStruct(overlayName, nodeData, allNodes) tstruct, err := InitStruct(overlayName, nodeData, allNodes)
if err != nil { if err != nil {
return fmt.Errorf("failed to initial data for %s: %w", nodeData.Id(), err) return fmt.Errorf("failed to initial data for %s: %w", nodeData.Id(), err)
@@ -1013,79 +1009,75 @@ func BuildOverlayIndir(nodeData node.Node, allNodes []node.Node, overlayNames []
tstruct.BuildSource = walkPath tstruct.BuildSource = walkPath
wwlog.Verbose("Evaluating overlay template file: %s", walkPath) wwlog.Verbose("Evaluating overlay template file: %s", walkPath)
buffer, backupFile, writeFile, err := RenderTemplateFile(walkPath, tstruct) rendered, err := RenderTemplate(walkPath, tstruct)
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 !rendered.WriteFile {
return nil return nil
} }
var fileBuffer bytes.Buffer
// search for magic file name comment
fileScanner := bufio.NewScanner(bytes.NewReader(buffer.Bytes()))
fileScanner.Split(ScanLines)
writingToNamedFile := false
isLink := false
for fileScanner.Scan() {
line := fileScanner.Text()
filenameFromTemplate := regFile.FindAllStringSubmatch(line, -1)
targetFromTemplate := regLink.FindAllStringSubmatch(line, -1)
if len(targetFromTemplate) != 0 {
target := targetFromTemplate[0][1]
wwlog.Debug("Creating soft link %s -> %s", outputPath, target)
err := os.Symlink(target, outputPath)
if err != nil {
return fmt.Errorf("could not create symlink from template: %w", err)
} else {
isLink = true
}
} else if len(filenameFromTemplate) != 0 {
wwlog.Debug("Writing file %s", filenameFromTemplate[0][1])
if writingToNamedFile && !isLink {
err = CarefulWriteBuffer(outputPath, fileBuffer, *backupFile, info.Mode())
if err != nil {
return fmt.Errorf("could not write file from template: %w", err)
}
err = util.CopyUIDGID(walkPath, outputPath)
if err != nil {
return fmt.Errorf("failed setting permissions on template output file: %w", err)
}
fileBuffer.Reset()
}
if path.IsAbs(filenameFromTemplate[0][1]) {
outputPath = filenameFromTemplate[0][1]
// Create parent directory for absolute paths
parentDir := path.Dir(outputPath)
sourceDirInfo, err := os.Stat(path.Dir(walkPath))
if err != nil {
return fmt.Errorf("could not stat source directory: %w", err)
}
if err := os.MkdirAll(parentDir, sourceDirInfo.Mode()); err != nil {
return fmt.Errorf("could not create parent directory for absolute path: %w", err)
}
} else {
outputPath = path.Join(path.Dir(originalOutputPath), filenameFromTemplate[0][1])
}
writingToNamedFile = true
isLink = false
} else {
if _, err = fileBuffer.WriteString(line); err != nil {
return fmt.Errorf("could not write to template buffer: %w", err)
}
}
}
if !isLink {
err = CarefulWriteBuffer(outputPath, fileBuffer, *backupFile, info.Mode())
if err != nil {
return fmt.Errorf("could not write file from template: %w", err)
}
err = util.CopyUIDGID(walkPath, outputPath)
if err != nil {
return fmt.Errorf("failed setting permissions on template output file: %w", err)
}
wwlog.Debug("Wrote template file into overlay: %s", outputPath)
}
// Write each output file. The default slot (Name == "") is written to
// defaultOutputPath when it is the only file. When named files are also
// present, the default slot is skipped unless it is a symlink: content
// before the first file() call often includes template setup (variable
// assignments, range headers) that produces incidental whitespace, and
// writing it as a separate file would be unexpected. A softlink() call
// before any file() call is always intentional and must be honored.
// Named files are always written.
for _, f := range rendered.Files {
var filePath string
if f.Name == "" {
if len(rendered.Files) > 1 && !f.IsSymlink {
continue
}
filePath = defaultOutputPath
} else if path.IsAbs(f.Name) {
// Anchor absolute paths under outputDir so they resolve within
// the image. For the host overlay (outputDir == "/") this is a
// no-op and the path writes to the real host location as intended.
filePath = filepath.Join(outputDir, f.Name)
} else {
filePath = path.Join(path.Dir(originalOutputPath), f.Name)
}
// Guard against path traversal: verify the resolved path stays
// within outputDir before writing or creating a symlink.
if rel, relErr := filepath.Rel(filepath.Clean(outputDir), filepath.Clean(filePath)); relErr != nil || strings.HasPrefix(rel, "..") {
return fmt.Errorf("file() path %q escapes output directory", f.Name)
}
// For absolute file() paths, ensure parent directories exist.
// This applies to both regular files and symlinks: os.Symlink
// fails with ENOENT if the parent directory does not exist.
if path.IsAbs(f.Name) {
parentDir := filepath.Dir(filePath)
sourceDirInfo, err := os.Stat(path.Dir(walkPath))
if err != nil {
return fmt.Errorf("could not stat source directory: %w", err)
}
if err := os.MkdirAll(parentDir, sourceDirInfo.Mode()); err != nil {
return fmt.Errorf("could not create parent directory for absolute path: %w", err)
}
}
if f.IsSymlink {
wwlog.Debug("Creating soft link %s -> %s", filePath, f.Target)
if err = os.Symlink(f.Target, filePath); err != nil {
return fmt.Errorf("could not create symlink from template: %w", err)
}
} else {
wwlog.Debug("Writing file %s", f.Name)
err = CarefulWriteBuffer(filePath, f.Buffer, rendered.BackupFile, info.Mode())
if err != nil {
return fmt.Errorf("could not write file from template: %w", err)
}
err = util.CopyUIDGID(walkPath, filePath)
if err != nil {
return fmt.Errorf("failed setting permissions on template output file: %w", err)
}
}
}
} else if info.Mode()&os.ModeSymlink == os.ModeSymlink { } else if info.Mode()&os.ModeSymlink == os.ModeSymlink {
wwlog.Debug("Found symlink %s", walkPath) wwlog.Debug("Found symlink %s", walkPath)
target, err := os.Readlink(walkPath) target, err := os.Readlink(walkPath)
@@ -1153,94 +1145,177 @@ func CarefulWriteBuffer(destFile string, buffer bytes.Buffer, backupFile bool, p
return err return err
} }
// getTemplateFuncMap returns a template.FuncMap with all the functions // errAbort is returned by the abort() template function to halt template execution
// for warewulf templates. // at the exact point of the call. RenderTemplate catches this sentinel and sets
func getTemplateFuncMap(fileName string, data TemplateStruct) (funcMap template.FuncMap, writeFile, backupFile *bool) { // result.WriteFile = false rather than propagating it as a real error.
// Build our FuncMap var errAbort = errors.New("abort")
_writeFile := true
_backupFile := true // RenderedFile represents one output file or symlink produced by a .ww template.
writeFile = &_writeFile // Template rendering uses a state-based multi-file writer instead of post-processing
backupFile = &_backupFile // sentinel strings. When a .ww template calls file("name"), the fileFn closure appends
funcMap = template.FuncMap{ // a RenderedFile to the result and redirects the multiFileWriter to that file's buffer.
"Include": templateFileInclude, // All subsequent template output flows into that buffer until the next file() call.
"IncludeFrom": templateImageFileInclude, // This approach is whitespace-trim safe: the old sentinel approach broke when {{- -}}
"IncludeBlock": templateFileBlock, // caused adjacent file() calls to collapse onto a single line, causing the greedy regex
"ImportLink": importSoftlink, // to match only the last sentinel.
"basename": path.Base, //
"inc": func(i int) int { return i + 1 }, // A RenderedFile with an empty Name is the default slot. It holds all template
"dec": func(i int) int { return i - 1 }, // output when no file() calls are made, or the content written before the first
"file": func(str string) string { return fmt.Sprintf("{{ /* file \"%s\" */ }}", str) }, // file() call when file() calls are present. The default slot is always present
"softlink": softlink, // in Files[0]; callers are responsible for deciding how to handle it.
"readlink": filepath.EvalSymlinks, type RenderedFile struct {
"IgnitionJson": func() string { Name string
return createIgnitionJson(data.ThisNode) IsSymlink bool
}, Target string
"abort": func() string { Buffer bytes.Buffer
wwlog.Debug("abort file called in %s", fileName) }
*writeFile = false
return "" // RenderedTemplate is the complete output of rendering a .ww template file.
}, // Files is always initialized with a default entry (Name == "") before rendering.
"nobackup": func() string { // When no file() calls are present, all content goes to that entry and it is the
wwlog.Debug("not backup for %s", fileName) // only element. When file() calls are present, named entries follow the default.
*backupFile = false // The default entry is always present in Files[0] regardless of whether its
return "" // buffer is empty; callers decide whether to act on it (see BuildOverlayIndir).
}, type RenderedTemplate struct {
WriteFile bool
BackupFile bool
Files []*RenderedFile
}
type multiFileWriter struct {
current *bytes.Buffer // redirected by file() and softlink() closures
}
func (w *multiFileWriter) Write(p []byte) (n int, err error) {
return w.current.Write(p)
}
// buildTemplateFuncMap constructs the template FuncMap for a .ww template.
// result and writer are the shared mutable state closed over by the stateful
// template functions (file, softlink, ImportLink, abort, nobackup). They must
// be allocated by the caller so RenderTemplate and ParseVarFields can each
// supply their own isolated state.
func buildTemplateFuncMap(fileName string, data TemplateStruct, result *RenderedTemplate, writer *multiFileWriter) template.FuncMap {
softlinkFn := func(target string) string {
if len(result.Files) > 0 {
last := result.Files[len(result.Files)-1]
last.IsSymlink = true
last.Target = target
}
return ""
}
importlinkFn := func(lnk string) (string, error) {
resolvedTarget, err := filepath.EvalSymlinks(lnk)
if err != nil {
return "", fmt.Errorf("ImportLink: failed to resolve symlink %q: %w", lnk, err)
}
wwlog.Debug("importing softlink pointing to: %s", resolvedTarget)
return softlinkFn(resolvedTarget), nil
}
// fileFn switches the active write target; returns "" so no output is emitted.
fileFn := func(name string) string {
f := &RenderedFile{Name: name}
result.Files = append(result.Files, f)
writer.current = &f.Buffer
return ""
}
incFn := func(i int) int { return i + 1 }
decFn := func(i int) int { return i - 1 }
ignitionFn := func() string {
return createIgnitionJson(data.ThisNode)
}
abortFn := func() (string, error) {
wwlog.Debug("abort file called in %s", fileName)
return "", errAbort
}
nobackupFn := func() string {
wwlog.Debug("not backup for %s", fileName)
result.BackupFile = false
return ""
}
funcMap := template.FuncMap{
"Include": templateFileInclude,
"IncludeFrom": templateImageFileInclude,
"IncludeBlock": templateFileBlock,
"ImportLink": importlinkFn,
"basename": path.Base,
"inc": incFn,
"dec": decFn,
"file": fileFn,
"softlink": softlinkFn,
"readlink": filepath.EvalSymlinks,
"IgnitionJson": ignitionFn,
"abort": abortFn,
"nobackup": nobackupFn,
"UniqueField": UniqueField, "UniqueField": UniqueField,
"SystemdEscape": unit.UnitNameEscape, "SystemdEscape": unit.UnitNameEscape,
"SystemdEscapePath": unit.UnitNamePathEscape, "SystemdEscapePath": unit.UnitNamePathEscape,
} }
// Merge sprig.FuncMap with our FuncMap
for key, value := range sprig.TxtFuncMap() { for key, value := range sprig.TxtFuncMap() {
funcMap[key] = value funcMap[key] = value
} }
return funcMap, writeFile, backupFile return funcMap
} }
/* // RenderTemplate renders the .ww template at fileName with the provided data,
Parses the template with the given filename, variables must be in data. Returns the // returning a RenderedTemplate that describes all output files. The file() and
parsed template as bytes.Buffer, and the bool variables for backupFile and writeFile. // softlink() template functions update state directly rather than emitting
If something goes wrong an error is returned. // sentinel strings, so they work correctly regardless of whitespace trimming.
*/ func RenderTemplate(fileName string, data TemplateStruct) (*RenderedTemplate, error) {
result := &RenderedTemplate{
WriteFile: true,
BackupFile: true,
Files: []*RenderedFile{{Name: ""}},
}
writer := &multiFileWriter{current: &result.Files[0].Buffer}
funcMap := buildTemplateFuncMap(fileName, data, result, writer)
tmpl, err := template.New(path.Base(fileName)).Option("missingkey=default").Funcs(funcMap).ParseGlob(fileName)
if err != nil {
return nil, fmt.Errorf("could not parse template %s: %w", fileName, err)
}
if err = tmpl.Execute(writer, data); err != nil {
if errors.Is(err, errAbort) {
// abort() halts execution at the call site; content up to that point
// is preserved in result.Files[0].Buffer for debugging via overlay show / API.
result.WriteFile = false
} else {
return nil, fmt.Errorf("could not execute template: %w", err)
}
}
return result, nil
}
// RenderTemplateFile is a wrapper around RenderTemplate for callers that only
// need a single rendered buffer. It returns the default slot (Files[0].Buffer):
// all content when the template makes no file() calls, or the content written
// before the first file() call when file() calls are present. Callers that need
// named output files or symlink information should use RenderTemplate directly.
func RenderTemplateFile(fileName string, data TemplateStruct) ( func RenderTemplateFile(fileName string, data TemplateStruct) (
buffer bytes.Buffer, backupFile, writeFile *bool, buffer bytes.Buffer, backupFile, writeFile *bool,
err error, err error,
) { ) {
rendered, renderErr := RenderTemplate(fileName, data)
funcMap, writeFile, backupFile := getTemplateFuncMap(fileName, data) if renderErr != nil {
err = renderErr
// Create the template with the merged FuncMap
tmpl, err := template.New(path.Base(fileName)).Option("missingkey=default").Funcs(funcMap).ParseGlob(fileName)
if err != nil {
err = fmt.Errorf("could not parse template %s: %w", fileName, err)
return
}
err = tmpl.Execute(&buffer, data)
if err != nil {
err = fmt.Errorf("could not execute template: %w", err)
return return
} }
backupFile = &rendered.BackupFile
writeFile = &rendered.WriteFile
buffer = rendered.Files[0].Buffer
return return
} }
// Simple version of ScanLines, but include the line break
func ScanLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexByte(data, '\n'); i >= 0 {
// We have a full newline-terminated line.
return i + 1, data[0 : i+1], nil
}
// If we're at EOF, we have a final, non-terminated line. Return it.
if atEOF {
return len(data), data, nil
}
// Request more data.
return 0, nil, nil
}
// Get all the files as a string slice for a given overlay // Get all the files as a string slice for a given overlay
func (overlay Overlay) GetFiles() (files []string, err error) { func (overlay Overlay) GetFiles() (files []string, err error) {
err = filepath.Walk(overlay.Rootfs(), func(path string, info fs.FileInfo, err error) error { err = filepath.Walk(overlay.Rootfs(), func(path string, info fs.FileInfo, err error) error {

View File

@@ -230,8 +230,79 @@ T3
outputDir: "/image", outputDir: "/image",
outputFiles: map[string]string{ outputFiles: map[string]string{
"t1.txt": "\nT1\n", "t1.txt": "\nT1\n",
"t2.txt": "T2\n", "t2.txt": "\nT2\n",
"t3.txt": "T3\n", "t3.txt": "\nT3\n",
},
},
"multifile whitespace trimmed": {
overlays: []string{"o1"},
overlayFiles: map[string]string{
"/var/lib/warewulf/overlays/o1/rootfs/file.txt.ww": `{{- range $i, $name := list "a" "b" "c" }}
{{ file $name }}
{{- end -}}`,
},
outputDir: "/image",
outputFiles: map[string]string{
"a": "\n",
"b": "\n",
"c": "",
},
},
"abort": {
// Regression test: abort() must suppress all file output in BuildOverlayIndir.
overlays: []string{"o1"},
overlayFiles: map[string]string{
"/var/lib/warewulf/overlays/o1/rootfs/file.txt.ww": `{{- abort -}}`,
},
outputDir: "/image",
outputFiles: map[string]string{},
},
"multifile all empty with whitespace trimming": {
// Regression test: before the state-based rewrite, using {{- file "name" -}}
// caused adjacent file() sentinels to collapse onto one line. The greedy .*
// in the regex matched only the last sentinel, so only the final file was
// created. All three files must be created here, each with zero content.
overlays: []string{"o1"},
overlayFiles: map[string]string{
"/var/lib/warewulf/overlays/o1/rootfs/file.txt.ww": `{{- range $name := list "a" "b" "c" -}}
{{- file $name -}}
{{- end -}}`,
},
outputDir: "/image",
outputFiles: map[string]string{
"a": "",
"b": "",
"c": "",
},
},
"multifile default symlink written to disk": {
// A softlink() call before any file() call targets the default output
// path and must be created even when named files are also present.
overlays: []string{"o1"},
overlayFiles: map[string]string{
"/var/lib/warewulf/overlays/o1/rootfs/file.txt.ww": `{{- softlink "/link-target" -}}{{- file "named.txt" -}}named content`,
},
outputDir: "/image",
outputFiles: map[string]string{
"named.txt": "named content",
},
outputSymlinks: map[string]string{
"file.txt": "/link-target",
},
},
"multifile pre-file content not written to disk": {
// Content written before the first file() call is preserved in the
// RenderedTemplate (RenderTemplateFile returns it), but BuildOverlayIndir
// does not write it to disk when named files are present. Only named.txt
// is created; file.txt is not, despite the non-empty default buffer.
overlays: []string{"o1"},
overlayFiles: map[string]string{
"/var/lib/warewulf/overlays/o1/rootfs/file.txt.ww": `pre-file content
{{ file "named.txt" }}named content`,
},
outputDir: "/image",
outputFiles: map[string]string{
"named.txt": "named content",
}, },
}, },
"symlink": { "symlink": {
@@ -338,6 +409,24 @@ Tags:map[]
} }
} }
func Test_BuildOverlayIndir_PathTraversal(t *testing.T) {
tests := map[string]string{
"relative traversal": `{{- file "../../../../etc/shadow" -}}escaped`,
"absolute traversal": `{{- file "/../../etc/shadow" -}}escaped`,
}
for name, tmpl := range tests {
t.Run(name, func(t *testing.T) {
env := testenv.New(t)
defer env.RemoveAll()
env.WriteFile("/var/lib/warewulf/overlays/o1/rootfs/file.txt.ww", tmpl)
env.MkdirAll("/image")
err := BuildOverlayIndir(node.Node{}, []node.Node{}, []string{"o1"}, env.GetPath("/image"))
assert.ErrorContains(t, err, "escapes output directory")
})
}
}
func Test_BuildOverlay(t *testing.T) { func Test_BuildOverlay(t *testing.T) {
tests := []struct { tests := []struct {
description string description string
@@ -751,6 +840,223 @@ func Test_CreateOverlayFile(t *testing.T) {
} }
} }
func Test_RenderTemplate_ImportLink(t *testing.T) {
// Create a real symlink on the host filesystem for the success case.
// ImportLink calls filepath.EvalSymlinks on the host, not in the overlay rootfs.
tmpDir := t.TempDir()
target := filepath.Join(tmpDir, "real-target")
err := os.WriteFile(target, []byte("content"), 0644)
assert.NoError(t, err)
link := filepath.Join(tmpDir, "test-link")
err = os.Symlink(target, link)
assert.NoError(t, err)
t.Run("ImportLink success", func(t *testing.T) {
env := testenv.New(t)
defer env.RemoveAll()
tmplContent := fmt.Sprintf(`{{- ImportLink %q -}}`, link)
tmplPath := env.GetPath("test.ww")
env.WriteFile("test.ww", tmplContent)
rendered, err := RenderTemplate(tmplPath, TemplateStruct{})
assert.NoError(t, err)
assert.True(t, rendered.Files[0].IsSymlink)
assert.Equal(t, target, rendered.Files[0].Target)
})
t.Run("ImportLink failure", func(t *testing.T) {
env := testenv.New(t)
defer env.RemoveAll()
tmplPath := env.GetPath("test.ww")
env.WriteFile("test.ww", `{{- ImportLink "/nonexistent/path/that/does/not/exist" -}}`)
_, err := RenderTemplate(tmplPath, TemplateStruct{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "ImportLink")
})
}
func TestRenderTemplate(t *testing.T) {
env := testenv.New(t)
defer env.RemoveAll()
// Setup for Include and IncludeBlock
includeDir := env.GetPath(path.Join(testenv.Sysconfdir, "warewulf"))
env.MkdirAll(path.Join(testenv.Sysconfdir, "warewulf"))
includeFile := path.Join(includeDir, "test-include")
err := os.WriteFile(includeFile, []byte("included content\n"), 0644)
assert.NoError(t, err)
blockFile := path.Join(includeDir, "test-block")
err = os.WriteFile(blockFile, []byte("line1\nline2\nABORT\nline3\n"), 0644)
assert.NoError(t, err)
// Setup for IncludeFrom
imageName := "test-image"
imageRootfs := env.GetPath(path.Join(testenv.WWChrootdir, imageName, "rootfs"))
env.MkdirAll(path.Join(testenv.WWChrootdir, imageName, "rootfs"))
err = os.WriteFile(path.Join(imageRootfs, "file-in-image"), []byte("image content\n"), 0644)
assert.NoError(t, err)
tests := []struct {
name string
template string
data TemplateStruct
validate func(*testing.T, *RenderedTemplate, error)
}{
{
name: "Basic functions",
template: `{{inc 1}} {{dec 5}} {{basename "/path/to/file"}}`,
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
assert.Equal(t, "2 4 file", rt.Files[0].Buffer.String())
},
},
{
name: "File redirection",
template: `default content{{file "other"}}other content`,
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
// Pre-file() content is in the default slot; the named slot follows it.
assert.Len(t, rt.Files, 2)
assert.Equal(t, "", rt.Files[0].Name)
assert.Equal(t, "default content", rt.Files[0].Buffer.String())
assert.Equal(t, "other", rt.Files[1].Name)
assert.Equal(t, "other content", rt.Files[1].Buffer.String())
},
},
{
name: "Softlink",
template: `{{softlink "/target"}}`,
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
assert.True(t, rt.Files[0].IsSymlink)
assert.Equal(t, "/target", rt.Files[0].Target)
},
},
{
name: "No backup",
template: `{{nobackup}}content`,
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
assert.False(t, rt.BackupFile)
},
},
{
name: "Abort",
template: `some content{{abort}}more content`,
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
assert.False(t, rt.WriteFile)
assert.Contains(t, rt.Files[0].Buffer.String(), "some content")
assert.NotContains(t, rt.Files[0].Buffer.String(), "more content")
},
},
{
name: "Systemd Escape",
template: `{{SystemdEscape "foo-bar/baz"}}`,
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
assert.Equal(t, "foo\\x2dbar-baz", rt.Files[0].Buffer.String())
},
},
{
name: "Include",
template: `{{Include "test-include"}}`,
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
assert.Equal(t, "included content", rt.Files[0].Buffer.String())
},
},
{
name: "IncludeBlock",
template: `{{IncludeBlock "test-block" "ABORT"}}`,
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
assert.Equal(t, "line1\nline2\nABORT", rt.Files[0].Buffer.String())
},
},
{
name: "IncludeFrom",
template: fmt.Sprintf(`{{IncludeFrom %q "file-in-image"}}`, imageName),
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
assert.Equal(t, "image content", rt.Files[0].Buffer.String())
},
},
{
name: "Sprig functions",
template: `{{ "hello" | upper }}`,
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
assert.Equal(t, "HELLO", rt.Files[0].Buffer.String())
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpFile := path.Join(env.BaseDir, tt.name+".ww")
err := os.WriteFile(tmpFile, []byte(tt.template), 0644)
assert.NoError(t, err)
rt, err := RenderTemplate(tmpFile, tt.data)
tt.validate(t, rt, err)
})
}
}
func TestRenderTemplateFile(t *testing.T) {
t.Run("no file() calls returns all content", func(t *testing.T) {
env := testenv.New(t)
defer env.RemoveAll()
env.WriteFile("test.ww", `hello world`)
buf, backupFile, writeFile, err := RenderTemplateFile(env.GetPath("test.ww"), TemplateStruct{})
assert.NoError(t, err)
assert.True(t, *backupFile)
assert.True(t, *writeFile)
assert.Equal(t, "hello world", buf.String())
})
t.Run("pre-file() content is returned when file() calls are present", func(t *testing.T) {
// When a template writes content before the first file() call,
// RenderTemplateFile returns that pre-file() content from the default slot.
env := testenv.New(t)
defer env.RemoveAll()
env.WriteFile("test.ww", `pre-file content{{ file "other" }}other content`)
buf, _, _, err := RenderTemplateFile(env.GetPath("test.ww"), TemplateStruct{})
assert.NoError(t, err)
assert.Equal(t, "pre-file content", buf.String())
})
t.Run("empty default slot is always preserved when file() calls are present", func(t *testing.T) {
// When there is no content before the first file() call, the empty
// default slot is retained. RenderTemplate always returns it as Files[0].
env := testenv.New(t)
defer env.RemoveAll()
env.WriteFile("test.ww", `{{- file "other" -}}other content`)
rendered, err := RenderTemplate(env.GetPath("test.ww"), TemplateStruct{})
assert.NoError(t, err)
assert.Len(t, rendered.Files, 2)
assert.Equal(t, "", rendered.Files[0].Name)
assert.Equal(t, "", rendered.Files[0].Buffer.String())
assert.Equal(t, "other", rendered.Files[1].Name)
assert.Equal(t, "other content", rendered.Files[1].Buffer.String())
})
t.Run("RenderTemplateFile returns empty buffer not named-file content", func(t *testing.T) {
// When the template has no pre-file() content, RenderTemplateFile must
// return an empty buffer (the default slot), not the content of the named
// file. Before the fix, the stripped default slot caused Files[0] to be
// the named file, so RenderTemplateFile silently returned named content.
env := testenv.New(t)
defer env.RemoveAll()
env.WriteFile("test.ww", `{{- file "other" -}}other content`)
buf, _, _, err := RenderTemplateFile(env.GetPath("test.ww"), TemplateStruct{})
assert.NoError(t, err)
assert.Equal(t, "", buf.String())
})
}
func dirIsEmpty(t *testing.T, name string) bool { func dirIsEmpty(t *testing.T, name string) bool {
f, err := os.Open(name) f, err := os.Open(name)
if err != nil { if err != nil {

View File

@@ -6,6 +6,7 @@ import (
"net/url" "net/url"
"os" "os"
"path" "path"
"strings"
"syscall" "syscall"
"github.com/swaggest/usecase" "github.com/swaggest/usecase"
@@ -148,12 +149,46 @@ func (of *OverlayFile) renderContents(nodeName string) (string, error) {
} }
tstruct.BuildSource = of.Path tstruct.BuildSource = of.Path
buffer, _, _, renderErr := overlay.RenderTemplateFile(of.FullPath(), tstruct) rendered, renderErr := overlay.RenderTemplate(of.FullPath(), tstruct)
if renderErr != nil { if renderErr != nil {
return "", renderErr return "", renderErr
} }
return buffer.String(), nil if !rendered.WriteFile {
// abort() was called in the template: nothing would be written to disk.
// Include any content rendered before the abort() call for debugging.
return rendered.Files[0].Buffer.String() + "Aborted\n", nil
}
// Single output file with no file() calls: return content as-is for backward
// compatibility. The default slot has Name == ""; named entries always have a
// non-empty Name regardless of how many there are.
if len(rendered.Files) == 1 && rendered.Files[0].Name == "" {
if rendered.Files[0].IsSymlink {
return "Symlink: " + rendered.Files[0].Target + "\n", nil
}
return rendered.Files[0].Buffer.String(), nil
}
// One or more file() calls: emit Filename: / Symlink: headers for each named
// entry. Skip the default slot (Name == ""); pre-file() content in it is
// for RenderTemplateFile callers, not serialized here.
var combined strings.Builder
for _, f := range rendered.Files {
if f.Name == "" {
continue
}
combined.WriteString("Filename: ")
combined.WriteString(f.Name)
combined.WriteString("\n")
if f.IsSymlink {
combined.WriteString("Symlink: ")
combined.WriteString(f.Target)
combined.WriteString("\n")
} else {
combined.WriteString(f.Buffer.String())
}
}
return combined.String(), nil
} }
func NewOverlayFile(name string, path string, renderNodeName string) (*OverlayFile, error) { func NewOverlayFile(name string, path string, renderNodeName string) (*OverlayFile, error) {

View File

@@ -18,6 +18,29 @@ import (
const sampleTemplate = `{{ if .Tags.email }}eMail: {{ .Tags.email }}{{else}} noMail{{- end }} const sampleTemplate = `{{ if .Tags.email }}eMail: {{ .Tags.email }}{{else}} noMail{{- end }}
` `
const multiFileTemplate = `{{- range $name := list "alpha" "beta" }}
{{- file $name -}}
content of {{ $name }}
{{ end -}}`
const abortTemplate = `{{- abort -}}`
const abortWithContentTemplate = `some content
{{- abort -}}`
const symlinkTemplate = `{{- softlink "/usr/share/zoneinfo/UTC" -}}`
const multiSymlinkTemplate = `{{- file "link1" -}}
{{- softlink "/target1" -}}
{{- file "link2" -}}
{{- softlink "/target2" -}}`
const sampleNodesConf = `
nodeprofiles: {}
nodes:
node1: {}
`
var overlayTests = []struct { var overlayTests = []struct {
name string name string
initFiles map[string]string initFiles map[string]string
@@ -106,6 +129,96 @@ var overlayTests = []struct {
}, },
response: `{"test":{"files":null, "site":true},"testoverlay":{"files":["/email.ww"], "site":true}}`, response: `{"test":{"files":null, "site":true},"testoverlay":{"files":["/email.ww"], "site":true}}`,
}, },
{
name: "render multi-file overlay template",
initFiles: map[string]string{
"/usr/share/warewulf/overlays/multioverlay/multi.ww": multiFileTemplate,
"/etc/warewulf/nodes.conf": sampleNodesConf,
},
request: func(serverURL string) (*http.Request, error) {
return http.NewRequest(http.MethodGet, serverURL+"/api/overlays/multioverlay/file?path=multi.ww&render=node1", nil)
},
response: `{
"overlay": "multioverlay",
"path": "multi.ww",
"contents": "Filename: alpha\ncontent of alpha\nFilename: beta\ncontent of beta\n",
"perms": "<<PRESENCE>>",
"uid": "<<PRESENCE>>",
"gid": "<<PRESENCE>>"
}`,
},
{
name: "render aborted template",
initFiles: map[string]string{
"/usr/share/warewulf/overlays/multioverlay/abort.ww": abortTemplate,
"/etc/warewulf/nodes.conf": sampleNodesConf,
},
request: func(serverURL string) (*http.Request, error) {
return http.NewRequest(http.MethodGet, serverURL+"/api/overlays/multioverlay/file?path=abort.ww&render=node1", nil)
},
response: `{
"overlay": "multioverlay",
"path": "abort.ww",
"contents": "Aborted\n",
"perms": "<<PRESENCE>>",
"uid": "<<PRESENCE>>",
"gid": "<<PRESENCE>>"
}`,
},
{
name: "render aborted template with pre-abort content",
initFiles: map[string]string{
"/usr/share/warewulf/overlays/multioverlay/abort-content.ww": abortWithContentTemplate,
"/etc/warewulf/nodes.conf": sampleNodesConf,
},
request: func(serverURL string) (*http.Request, error) {
return http.NewRequest(http.MethodGet, serverURL+"/api/overlays/multioverlay/file?path=abort-content.ww&render=node1", nil)
},
response: `{
"overlay": "multioverlay",
"path": "abort-content.ww",
"contents": "some contentAborted\n",
"perms": "<<PRESENCE>>",
"uid": "<<PRESENCE>>",
"gid": "<<PRESENCE>>"
}`,
},
{
name: "render single-file symlink template",
initFiles: map[string]string{
"/usr/share/warewulf/overlays/multioverlay/symlink.ww": symlinkTemplate,
"/etc/warewulf/nodes.conf": sampleNodesConf,
},
request: func(serverURL string) (*http.Request, error) {
return http.NewRequest(http.MethodGet, serverURL+"/api/overlays/multioverlay/file?path=symlink.ww&render=node1", nil)
},
response: `{
"overlay": "multioverlay",
"path": "symlink.ww",
"contents": "Symlink: /usr/share/zoneinfo/UTC\n",
"perms": "<<PRESENCE>>",
"uid": "<<PRESENCE>>",
"gid": "<<PRESENCE>>"
}`,
},
{
name: "render multi-file symlink template",
initFiles: map[string]string{
"/usr/share/warewulf/overlays/multioverlay/multi-symlink.ww": multiSymlinkTemplate,
"/etc/warewulf/nodes.conf": sampleNodesConf,
},
request: func(serverURL string) (*http.Request, error) {
return http.NewRequest(http.MethodGet, serverURL+"/api/overlays/multioverlay/file?path=multi-symlink.ww&render=node1", nil)
},
response: `{
"overlay": "multioverlay",
"path": "multi-symlink.ww",
"contents": "Filename: link1\nSymlink: /target1\nFilename: link2\nSymlink: /target2\n",
"perms": "<<PRESENCE>>",
"uid": "<<PRESENCE>>",
"gid": "<<PRESENCE>>"
}`,
},
{ {
name: "delete overlay file", name: "delete overlay file",
initFiles: map[string]string{ initFiles: map[string]string{

View File

@@ -87,7 +87,6 @@ nodes:
log: `backupFile: true log: `backupFile: true
writeFile: true writeFile: true
Filename: warewulf-default.conf Filename: warewulf-default.conf
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
[connection] [connection]
@@ -178,7 +177,6 @@ nodes:
log: `backupFile: true log: `backupFile: true
writeFile: true writeFile: true
Filename: warewulf-default.conf Filename: warewulf-default.conf
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
[connection] [connection]
@@ -278,7 +276,6 @@ nodes:
log: `backupFile: true log: `backupFile: true
writeFile: true writeFile: true
Filename: warewulf-tagged.conf Filename: warewulf-tagged.conf
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
[connection] [connection]
@@ -343,7 +340,6 @@ nodes:
log: `backupFile: true log: `backupFile: true
writeFile: true writeFile: true
Filename: warewulf-bond0.conf Filename: warewulf-bond0.conf
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
[connection] [connection]
@@ -436,7 +432,6 @@ nodes:
log: `backupFile: true log: `backupFile: true
writeFile: true writeFile: true
Filename: warewulf-bond0.conf Filename: warewulf-bond0.conf
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
[connection] [connection]
@@ -528,7 +523,6 @@ nodes:
log: `backupFile: true log: `backupFile: true
writeFile: true writeFile: true
Filename: warewulf-bond0.conf Filename: warewulf-bond0.conf
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
[connection] [connection]
@@ -620,7 +614,6 @@ nodes:
log: `backupFile: true log: `backupFile: true
writeFile: true writeFile: true
Filename: warewulf-bond0.conf Filename: warewulf-bond0.conf
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
[connection] [connection]

View File

@@ -1,4 +1,4 @@
{{ file "warewulf-unmanaged.conf" }} {{- file "warewulf-unmanaged.conf" -}}
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
[main] [main]

View File

@@ -1,5 +1,5 @@
{{- range $devname, $netdev := .NetDevs }} {{- range $devname, $netdev := .NetDevs }}
{{ file (print "warewulf-" $devname ".conf") }} {{- file (print "warewulf-" $devname ".conf") -}}
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
[connection] [connection]

View File

@@ -46,7 +46,6 @@ nodes:
log: `backupFile: true log: `backupFile: true
writeFile: true writeFile: true
Filename: ifcfg-default.conf Filename: ifcfg-default.conf
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
TYPE=Ethernet TYPE=Ethernet
DEVICE=wwnet0 DEVICE=wwnet0
@@ -116,7 +115,6 @@ nodes:
log: `backupFile: true log: `backupFile: true
writeFile: true writeFile: true
Filename: ifcfg-default.conf Filename: ifcfg-default.conf
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
TYPE=Ethernet TYPE=Ethernet
NAME=default NAME=default
@@ -210,7 +208,6 @@ nodes:
log: `backupFile: true log: `backupFile: true
writeFile: true writeFile: true
Filename: ifcfg-tagged.conf Filename: ifcfg-tagged.conf
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
VLAN=yes VLAN=yes
DEVICE=eth0.902 DEVICE=eth0.902
@@ -263,7 +260,6 @@ nodes:
log: `backupFile: true log: `backupFile: true
writeFile: true writeFile: true
Filename: ifcfg-bond0.conf Filename: ifcfg-bond0.conf
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
TYPE=Bond TYPE=Bond
DEVICE=bond0 DEVICE=bond0
@@ -333,10 +329,8 @@ nodes:
log: `backupFile: true log: `backupFile: true
writeFile: true writeFile: true
Filename: route-tagged.conf Filename: route-tagged.conf
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
192.168.1.0/24 via 192.168.2.254 192.168.1.0/24 via 192.168.2.254
backupFile: true backupFile: true
writeFile: true writeFile: true
Filename: route-untagged.conf Filename: route-untagged.conf
@@ -363,7 +357,6 @@ nodes:
log: `backupFile: true log: `backupFile: true
writeFile: true writeFile: true
Filename: ifcfg-tagged.conf Filename: ifcfg-tagged.conf
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
VLAN=yes VLAN=yes
DEVICE=eth0.902 DEVICE=eth0.902

View File

@@ -1,5 +1,5 @@
{{- range $devname, $netdev := .NetDevs }} {{- range $devname, $netdev := .NetDevs }}
{{ file (print "ifcfg-" $devname ".conf") }} {{- file (print "ifcfg-" $devname ".conf") -}}
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
{{- if eq $netdev.Type "vlan" }} {{- if eq $netdev.Type "vlan" }}
VLAN=yes VLAN=yes

View File

@@ -1,5 +1,5 @@
{{- range $devname, $netdev := .NetDevs }} {{- range $devname, $netdev := .NetDevs }}
{{ file (print "route-" $devname ".conf") }} {{- file (print "route-" $devname ".conf") -}}
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
{{- range $tk, $tv := $netdev.Tags }} {{- range $tk, $tv := $netdev.Tags }}
{{- if eq (substr 0 5 $tk) "route" }} {{- if eq (substr 0 5 $tk) "route" }}

View File

@@ -60,7 +60,6 @@ func Test_wickedOverlay(t *testing.T) {
const expected_log string = `backupFile: true const expected_log string = `backupFile: true
writeFile: true writeFile: true
Filename: default Filename: default
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
auto wwnet0 auto wwnet0
allow-hotplug wwnet0 allow-hotplug wwnet0
@@ -69,7 +68,6 @@ iface wwnet0 inet static
netmask 255.255.255.0 netmask 255.255.255.0
gateway 192.168.3.1 gateway 192.168.3.1
mtu 1500 mtu 1500
backupFile: true backupFile: true
writeFile: true writeFile: true
Filename: secondary Filename: secondary

View File

@@ -1,5 +1,5 @@
{{- range $devname, $netdev := .ThisNode.NetDevs }} {{- range $devname, $netdev := .ThisNode.NetDevs }}
{{ file $devname }} {{- file $devname -}}
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
{{- if $netdev.OnBoot.BoolDefaultTrue }} {{- if $netdev.OnBoot.BoolDefaultTrue }}
auto {{ $netdev.Device }} auto {{ $netdev.Device }}

View File

@@ -52,5 +52,5 @@ func Test_localtimeOverlay(t *testing.T) {
const localtime string = `backupFile: true const localtime string = `backupFile: true
writeFile: true writeFile: true
Filename: etc/localtime Filename: etc/localtime
{{ /* softlink "/usr/share/zoneinfo/GMT" */ }} Symlink: /usr/share/zoneinfo/GMT
` `

View File

@@ -35,8 +35,6 @@ nodes:
output: `backupFile: true output: `backupFile: true
writeFile: true writeFile: true
Filename: -.mount Filename: -.mount
[Unit] [Unit]
Before=local-fs.target Before=local-fs.target
@@ -50,7 +48,6 @@ RequiredBy=local-fs.target
backupFile: true backupFile: true
writeFile: true writeFile: true
Filename: scratch.mount Filename: scratch.mount
[Unit] [Unit]
Before=local-fs.target Before=local-fs.target
@@ -83,12 +80,11 @@ nodes:
output: `backupFile: true output: `backupFile: true
writeFile: true writeFile: true
Filename: -.mount Filename: -.mount
Symlink: /etc/systemd/system/-.mount
{{ /* softlink "/etc/systemd/system/-.mount" */ }}
backupFile: true backupFile: true
writeFile: true writeFile: true
Filename: scratch.mount Filename: scratch.mount
{{ /* softlink "/etc/systemd/system/scratch.mount" */ }} Symlink: /etc/systemd/system/scratch.mount
`, `,
}, },
} }

View File

@@ -2,7 +2,7 @@
{{- range $fs := .ThisNode.FileSystemList }} {{- range $fs := .ThisNode.FileSystemList }}
{{- if and $fs.Path (ne $fs.Format "swap") }} {{- if and $fs.Path (ne $fs.Format "swap") }}
{{- $abort = false }} {{- $abort = false }}
{{ file (print ($fs.Path | SystemdEscapePath) ".mount") }} {{- file (print ($fs.Path | SystemdEscapePath) ".mount") -}}
[Unit] [Unit]
Before=local-fs.target Before=local-fs.target

View File

@@ -2,7 +2,7 @@
{{- range $fs := .ThisNode.FileSystemList }} {{- range $fs := .ThisNode.FileSystemList }}
{{- if and $fs.Path (ne $fs.Format "swap") }} {{- if and $fs.Path (ne $fs.Format "swap") }}
{{- $abort = false }} {{- $abort = false }}
{{ file (print ($fs.Path | SystemdEscapePath) ".mount") }} {{- file (print ($fs.Path | SystemdEscapePath) ".mount") -}}
{{ softlink (print "/etc/systemd/system/" ($fs.Path | SystemdEscapePath) ".mount") }} {{ softlink (print "/etc/systemd/system/" ($fs.Path | SystemdEscapePath) ".mount") }}
{{- end }} {{- end }}
{{- end }} {{- end }}

View File

@@ -1,5 +1,5 @@
{{ range $devname, $netdev := .NetDevs -}} {{ range $devname, $netdev := .NetDevs -}}
{{ file (print "10-ww4-netname-" $devname ".link") }} {{- file (print "10-ww4-netname-" $devname ".link") -}}
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
{{- if and $netdev.Hwaddr $netdev.Device }} {{- if and $netdev.Hwaddr $netdev.Device }}
[Match] [Match]

View File

@@ -59,7 +59,6 @@ func Test_systemdNetworkOverlay(t *testing.T) {
const expected_log_with_hwaddr string = `backupFile: true const expected_log_with_hwaddr string = `backupFile: true
writeFile: true writeFile: true
Filename: 10-ww4-networkd-default.network Filename: 10-ww4-networkd-default.network
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
[Match] [Match]
Name=wwnet0 Name=wwnet0
@@ -104,7 +103,6 @@ DUIDType=link-layer
const expected_log_without_hwaddr string = `backupFile: true const expected_log_without_hwaddr string = `backupFile: true
writeFile: true writeFile: true
Filename: 10-ww4-networkd-default.network Filename: 10-ww4-networkd-default.network
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
[Match] [Match]
Name=wwnet0 Name=wwnet0

View File

@@ -1,5 +1,5 @@
{{- range $devname, $netdev := .ThisNode.NetDevs }} {{- range $devname, $netdev := .ThisNode.NetDevs }}
{{ file (print "10-ww4-networkd-" $devname ".network") }} {{- file (print "10-ww4-networkd-" $devname ".network") -}}
# This file is autogenerated by warewulf # This file is autogenerated by warewulf
[Match] [Match]
{{- if $netdev.Device }} {{- if $netdev.Device }}

View File

@@ -35,8 +35,6 @@ nodes:
output: `backupFile: true output: `backupFile: true
writeFile: true writeFile: true
Filename: dev-disk-by\x2dpartlabel-swap.swap Filename: dev-disk-by\x2dpartlabel-swap.swap
[Unit] [Unit]
Before=swap.target Before=swap.target
@@ -67,8 +65,7 @@ nodes:
output: `backupFile: true output: `backupFile: true
writeFile: true writeFile: true
Filename: dev-disk-by\x2dpartlabel-swap.swap Filename: dev-disk-by\x2dpartlabel-swap.swap
Symlink: /etc/systemd/system/dev-disk-by\x2dpartlabel-swap.swap
{{ /* softlink "/etc/systemd/system/dev-disk-by\x2dpartlabel-swap.swap" */ }}
`, `,
}, },
} }

View File

@@ -2,7 +2,7 @@
{{- range $fs := .ThisNode.FileSystemList }} {{- range $fs := .ThisNode.FileSystemList }}
{{- if eq $fs.Format "swap" }} {{- if eq $fs.Format "swap" }}
{{- $abort = false }} {{- $abort = false }}
{{ file (print ($fs.Id | SystemdEscapePath) ".swap") }} {{- file (print ($fs.Id | SystemdEscapePath) ".swap") -}}
[Unit] [Unit]
Before=swap.target Before=swap.target

View File

@@ -2,7 +2,7 @@
{{- range $fs := .ThisNode.FileSystemList }} {{- range $fs := .ThisNode.FileSystemList }}
{{- if eq $fs.Format "swap" }} {{- if eq $fs.Format "swap" }}
{{- $abort = false }} {{- $abort = false }}
{{ file (print ($fs.Id | SystemdEscapePath) ".swap") }} {{- file (print ($fs.Id | SystemdEscapePath) ".swap") -}}
{{ softlink (print "/etc/systemd/system/" ($fs.Id | SystemdEscapePath) ".swap") }} {{ softlink (print "/etc/systemd/system/" ($fs.Id | SystemdEscapePath) ".swap") }}
{{- end }} {{- end }}
{{- end }} {{- end }}

View File

@@ -58,7 +58,6 @@ func Test_wickedOverlay(t *testing.T) {
const wicked string = `backupFile: true const wicked string = `backupFile: true
writeFile: true writeFile: true
Filename: ifcfg-default.xml Filename: ifcfg-default.xml
<!-- <!--
This file is autogenerated by warewulf This file is autogenerated by warewulf
--> -->
@@ -90,7 +89,6 @@ This file is autogenerated by warewulf
<accept-redirects>false</accept-redirects> <accept-redirects>false</accept-redirects>
</ipv6> </ipv6>
</interface> </interface>
backupFile: true backupFile: true
writeFile: true writeFile: true
Filename: ifcfg-secondary.xml Filename: ifcfg-secondary.xml
@@ -130,7 +128,6 @@ This file is autogenerated by warewulf
const wicked_vlans string = `backupFile: true const wicked_vlans string = `backupFile: true
writeFile: true writeFile: true
Filename: ifcfg-tagged.xml Filename: ifcfg-tagged.xml
<!-- <!--
This file is autogenerated by warewulf This file is autogenerated by warewulf
--> -->
@@ -168,7 +165,6 @@ This file is autogenerated by warewulf
<accept-redirects>false</accept-redirects> <accept-redirects>false</accept-redirects>
</ipv6> </ipv6>
</interface> </interface>
backupFile: true backupFile: true
writeFile: true writeFile: true
Filename: ifcfg-untagged.xml Filename: ifcfg-untagged.xml

View File

@@ -1,6 +1,6 @@
{{- $NetDevs := .NetDevs }} {{- $NetDevs := .NetDevs }}
{{- range $devname, $netdev := .ThisNode.NetDevs }} {{- range $devname, $netdev := .ThisNode.NetDevs }}
{{ file (print "ifcfg-" $devname ".xml") }} {{- file (print "ifcfg-" $devname ".xml") -}}
<!-- <!--
This file is autogenerated by warewulf This file is autogenerated by warewulf
--> -->

View File

@@ -115,17 +115,17 @@ referenced symlink.
.. code-block:: .. code-block::
{{ ImportLink "/etc/localtime" }} {{- ImportLink "/etc/localtime" -}}
When paired with ``file``, ``ImportLink`` can create multiple symlinks from a When paired with ``file``, ``ImportLink`` can create multiple symlinks from a
single template. single template.
.. code-block:: .. code-block::
{{ file "/tmp/test-link1"}} {{- file "/tmp/test-link1" -}}
{{ softlink "/tmp/test-target1" }} {{- softlink "/tmp/test-target1" -}}
{{ file "/tmp/test-link2"}} {{- file "/tmp/test-link2" -}}
{{ softlink "/tmp/test-target2" }} {{- softlink "/tmp/test-target2" -}}
basename basename
-------- --------
@@ -146,11 +146,21 @@ file
Write the content from the template to the specified file name. May be specified Write the content from the template to the specified file name. May be specified
more than once in a template to write content to multiple files. more than once in a template to write content to multiple files.
When no ``file`` call is present, all template output is written to the
**default output path**: the template filename with the ``.ww`` suffix stripped
(e.g., ``/etc/hosts.ww````/etc/hosts``).
When one or more ``file`` calls are present, each call redirects subsequent
template output to that named file. Any content rendered before the first
``file`` call is not written to disk. A ``softlink`` or ``ImportLink`` placed
before any ``file`` call still applies to the default output path and is
written to disk even when named files are also present.
.. code-block:: .. code-block::
{{- range $devname, $netdev := .NetDevs }} {{- range $devname, $netdev := .NetDevs }}
{{- $filename := print "ifcfg-" $devname ".conf" }} {{- $filename := print "ifcfg-" $devname ".conf" }}
{{ file $filename }} {{- file $filename -}}
{{/* content here */}} {{/* content here */}}
{{- end }} {{- end }}
@@ -163,7 +173,7 @@ Causes the processed template file to become a symlink to the referenced target.
.. code-block:: .. code-block::
{{ printf "%s/%s" "/usr/share/zoneinfo" .Tags.localtime | softlink }} {{- printf "%s/%s" "/usr/share/zoneinfo" .Tags.localtime | softlink -}}
When paired with ``file``, ``softlink`` can create multiple symlinks from a When paired with ``file``, ``softlink`` can create multiple symlinks from a
single template. single template.
@@ -198,7 +208,7 @@ Immediately aborts processing the template and does not write a file.
.. code-block:: .. code-block::
{{ abort }} {{- abort -}}
nobackup nobackup
-------- --------
@@ -208,7 +218,7 @@ nobackup
.. code-block:: .. code-block::
{{ nobackup }} {{- nobackup -}}
.. _UniqueField: .. _UniqueField:
@@ -245,10 +255,11 @@ Escapes a path for use in a systemd unit file.
.. code-block:: .. code-block::
{{ file (print ($fs.path | SystemdEscapePath) ".mount") }} {{- file (print ($fs.path | SystemdEscapePath) ".mount") -}}
Escape rules are documented at `systemd.unit. <https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#String%20Escaping%20for%20Inclusion%20in%20Unit%20Names>`_ Escape rules are documented at `systemd.unit. <https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#String%20Escaping%20for%20Inclusion%20in%20Unit%20Names>`_
Examples Examples
======== ========