From 2bad789f4ffff7ccbbb3fd683e4cd5a03354a345 Mon Sep 17 00:00:00 2001 From: Jonathon Anderson Date: Fri, 17 Apr 2026 15:57:19 -0600 Subject: [PATCH] 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 Co-authored-by: Christian Goll Signed-off-by: Jonathon Anderson --- CHANGELOG.md | 4 + internal/app/wwctl/overlay/show/main.go | 78 ++-- internal/pkg/overlay/funcmap.go | 14 - internal/pkg/overlay/overlay.go | 373 +++++++++++------- internal/pkg/overlay/overlay_test.go | 310 ++++++++++++++- internal/pkg/warewulfd/api/overlay.go | 39 +- internal/pkg/warewulfd/api/overlay_test.go | 113 ++++++ .../internal/networkmanager_test.go | 7 - .../NetworkManager/conf.d/ww4-unmanaged.ww | 2 +- .../system-connections/ww4-managed.ww | 2 +- overlays/ifcfg/internal/ifcfg_test.go | 7 - .../etc/sysconfig/network-scripts/ifcfg.ww | 2 +- .../etc/sysconfig/network-scripts/route.ww | 2 +- overlays/ifupdown/internal/ifupdown_test.go | 2 - .../etc/network/interfaces.d/default.ww | 2 +- overlays/localtime/internal/localtime_test.go | 2 +- .../internal/systemd_mount_test.go | 8 +- .../rootfs/etc/systemd/system/disk.mount.ww | 2 +- .../local-fs.target.wants/disk.mount.ww | 2 +- .../systemd/network/10-ww4-netname.link.ww | 2 +- .../internal/systemd_networkd_test.go | 2 - .../etc/systemd/network/default.network.ww | 2 +- .../internal/systemd_swap_test.go | 5 +- .../rootfs/etc/systemd/system/disk.swap.ww | 2 +- .../system/local-fs.target.wants/disk.swap.ww | 2 +- overlays/wicked/internal/wicked_test.go | 4 - .../rootfs/etc/wicked/ifconfig/ifcfg.xml.ww | 2 +- userdocs/overlays/templates.rst | 31 +- 28 files changed, 767 insertions(+), 256 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a55425..2ff72b9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 path traversal vulnerability (CWE-23) - 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 diff --git a/internal/app/wwctl/overlay/show/main.go b/internal/app/wwctl/overlay/show/main.go index ceeffd3d..e8c9a01e 100644 --- a/internal/app/wwctl/overlay/show/main.go +++ b/internal/app/wwctl/overlay/show/main.go @@ -1,12 +1,9 @@ package show import ( - "bufio" - "bytes" "fmt" "os" "path/filepath" - "regexp" "strings" "github.com/spf13/cobra" @@ -28,6 +25,7 @@ func CobraRunE(cmd *cobra.Command, args []string) error { overlayFile := overlay_.File(fileName) if NodeName == "" { + // No node specified: show the raw template source without rendering. if !util.IsFile(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)) } 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) { possibleFile := fmt.Sprintf("%s.ww", overlayFile) 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) 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() if err != nil { return fmt.Errorf("could not get host name: %s", err) @@ -73,43 +76,50 @@ func CobraRunE(cmd *cobra.Command, args []string) error { return err } tstruct.BuildSource = overlayFile - buffer, backupFile, writeFile, err := overlay.RenderTemplateFile(overlayFile, tstruct) + rendered, err := overlay.RenderTemplate(overlayFile, tstruct) if err != nil { return err } - var outBuffer bytes.Buffer - // search for magic file name comment - bufferScanner := bufio.NewScanner(bytes.NewReader(buffer.Bytes())) - bufferScanner.Split(overlay.ScanLines) - reg := regexp.MustCompile(`.*{{\s*/\*\s*file\s*["'](.*)["']\s*\*/\s*}}.*`) - foundFileComment := false - destFileName := strings.TrimSuffix(fileName, ".ww") - for bufferScanner.Scan() { - line := bufferScanner.Text() - filenameFromTemplate := reg.FindAllStringSubmatch(line, -1) - if len(filenameFromTemplate) != 0 { - wwlog.Debug("Found multifile comment, new filename %s", filenameFromTemplate[0][1]) - if foundFileComment { - if !Quiet { - wwlog.Info("backupFile: %v", *backupFile) - wwlog.Info("writeFile: %v", *writeFile) - wwlog.Info("Filename: %s", destFileName) - } - wwlog.Output("%s", outBuffer.String()) - outBuffer.Reset() + + if !rendered.WriteFile { + // abort() was called in the template: nothing would be written to disk. + // Still show any content rendered before the abort() call for debugging. + if !Quiet { + wwlog.Info("backupFile: %v", rendered.BackupFile) + wwlog.Info("writeFile: %v", rendered.WriteFile) + } + wwlog.Output("%s", rendered.Files[0].Buffer.String()) + if !Quiet { + wwlog.Info("Aborted") + } + return nil + } + + for _, f := range rendered.Files { + // The default slot (Name == "") holds pre-file() content or a symlink. + // When named files are also present, non-symlink content is discarded + // for disk output; skip it here too so that show output matches what + // 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 - } else { - _, _ = outBuffer.WriteString(line) + } + if !f.IsSymlink { + wwlog.Output("%s", f.Buffer.String()) } } - if !Quiet { - wwlog.Info("backupFile: %v", *backupFile) - wwlog.Info("writeFile: %v", *writeFile) - wwlog.Info("Filename: %s", destFileName) - } - wwlog.Output("%s", outBuffer.String()) } return nil } diff --git a/internal/pkg/overlay/funcmap.go b/internal/pkg/overlay/funcmap.go index 040858d7..dae38948 100644 --- a/internal/pkg/overlay/funcmap.go +++ b/internal/pkg/overlay/funcmap.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path" - "path/filepath" "strings" warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" @@ -118,19 +117,6 @@ func createIgnitionJson(node *node.Node) string { 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 // 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 diff --git a/internal/pkg/overlay/overlay.go b/internal/pkg/overlay/overlay.go index dd5eae18..92357abc 100644 --- a/internal/pkg/overlay/overlay.go +++ b/internal/pkg/overlay/overlay.go @@ -1,8 +1,8 @@ package overlay import ( - "bufio" "bytes" + "errors" "fmt" "io/fs" "os" @@ -294,7 +294,13 @@ func (overlay Overlay) ParseVarFields(file string) map[string]FieldInfo { 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) if err != nil { 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 } -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. func BuildOverlayIndir(nodeData node.Node, allNodes []node.Node, overlayNames []string, outputDir string) error { if len(overlayNames) == 0 { @@ -1005,7 +1001,7 @@ func BuildOverlayIndir(nodeData node.Node, allNodes []node.Node, overlayNames [] } else if filepath.Ext(walkPath) == ".ww" { originalOutputPath := outputPath - outputPath := strings.TrimSuffix(outputPath, ".ww") + defaultOutputPath := strings.TrimSuffix(outputPath, ".ww") tstruct, err := InitStruct(overlayName, nodeData, allNodes) if err != nil { 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 wwlog.Verbose("Evaluating overlay template file: %s", walkPath) - buffer, backupFile, writeFile, err := RenderTemplateFile(walkPath, tstruct) + rendered, err := RenderTemplate(walkPath, tstruct) if err != nil { return fmt.Errorf("failed to render template %s: %w", walkPath, err) } - if !*writeFile { + if !rendered.WriteFile { 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 { wwlog.Debug("Found symlink %s", walkPath) target, err := os.Readlink(walkPath) @@ -1153,94 +1145,177 @@ func CarefulWriteBuffer(destFile string, buffer bytes.Buffer, backupFile bool, p return err } -// getTemplateFuncMap returns a template.FuncMap with all the functions -// for warewulf templates. -func getTemplateFuncMap(fileName string, data TemplateStruct) (funcMap template.FuncMap, writeFile, backupFile *bool) { - // Build our FuncMap - _writeFile := true - _backupFile := true - writeFile = &_writeFile - backupFile = &_backupFile - funcMap = template.FuncMap{ - "Include": templateFileInclude, - "IncludeFrom": templateImageFileInclude, - "IncludeBlock": templateFileBlock, - "ImportLink": importSoftlink, - "basename": path.Base, - "inc": func(i int) int { return i + 1 }, - "dec": func(i int) int { return i - 1 }, - "file": func(str string) string { return fmt.Sprintf("{{ /* file \"%s\" */ }}", str) }, - "softlink": softlink, - "readlink": filepath.EvalSymlinks, - "IgnitionJson": func() string { - return createIgnitionJson(data.ThisNode) - }, - "abort": func() string { - wwlog.Debug("abort file called in %s", fileName) - *writeFile = false - return "" - }, - "nobackup": func() string { - wwlog.Debug("not backup for %s", fileName) - *backupFile = false - return "" - }, +// errAbort is returned by the abort() template function to halt template execution +// at the exact point of the call. RenderTemplate catches this sentinel and sets +// result.WriteFile = false rather than propagating it as a real error. +var errAbort = errors.New("abort") + +// RenderedFile represents one output file or symlink produced by a .ww template. +// Template rendering uses a state-based multi-file writer instead of post-processing +// sentinel strings. When a .ww template calls file("name"), the fileFn closure appends +// a RenderedFile to the result and redirects the multiFileWriter to that file's buffer. +// All subsequent template output flows into that buffer until the next file() call. +// This approach is whitespace-trim safe: the old sentinel approach broke when {{- -}} +// caused adjacent file() calls to collapse onto a single line, causing the greedy regex +// to match only the last sentinel. +// +// A RenderedFile with an empty Name is the default slot. It holds all template +// output when no file() calls are made, or the content written before the first +// file() call when file() calls are present. The default slot is always present +// in Files[0]; callers are responsible for deciding how to handle it. +type RenderedFile struct { + Name string + IsSymlink bool + Target string + Buffer bytes.Buffer +} + +// RenderedTemplate is the complete output of rendering a .ww template file. +// Files is always initialized with a default entry (Name == "") before rendering. +// When no file() calls are present, all content goes to that entry and it is the +// only element. When file() calls are present, named entries follow the default. +// The default entry is always present in Files[0] regardless of whether its +// 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, "SystemdEscape": unit.UnitNameEscape, "SystemdEscapePath": unit.UnitNamePathEscape, } - // Merge sprig.FuncMap with our FuncMap for key, value := range sprig.TxtFuncMap() { funcMap[key] = value } - return funcMap, writeFile, backupFile + return funcMap } -/* -Parses the template with the given filename, variables must be in data. Returns the -parsed template as bytes.Buffer, and the bool variables for backupFile and writeFile. -If something goes wrong an error is returned. -*/ +// RenderTemplate renders the .ww template at fileName with the provided data, +// returning a RenderedTemplate that describes all output files. The file() and +// softlink() template functions update state directly rather than emitting +// 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) ( buffer bytes.Buffer, backupFile, writeFile *bool, err error, ) { - - funcMap, writeFile, backupFile := getTemplateFuncMap(fileName, data) - - // Create the template with the merged FuncMap - tmpl, err := template.New(path.Base(fileName)).Option("missingkey=default").Funcs(funcMap).ParseGlob(fileName) - 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) + rendered, renderErr := RenderTemplate(fileName, data) + if renderErr != nil { + err = renderErr return } + backupFile = &rendered.BackupFile + writeFile = &rendered.WriteFile + buffer = rendered.Files[0].Buffer 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 func (overlay Overlay) GetFiles() (files []string, err error) { err = filepath.Walk(overlay.Rootfs(), func(path string, info fs.FileInfo, err error) error { diff --git a/internal/pkg/overlay/overlay_test.go b/internal/pkg/overlay/overlay_test.go index dd3485dc..7ddb4319 100644 --- a/internal/pkg/overlay/overlay_test.go +++ b/internal/pkg/overlay/overlay_test.go @@ -230,8 +230,79 @@ T3 outputDir: "/image", outputFiles: map[string]string{ "t1.txt": "\nT1\n", - "t2.txt": "T2\n", - "t3.txt": "T3\n", + "t2.txt": "\nT2\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": { @@ -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) { tests := []struct { 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 { f, err := os.Open(name) if err != nil { diff --git a/internal/pkg/warewulfd/api/overlay.go b/internal/pkg/warewulfd/api/overlay.go index 656bba6d..5d3a8470 100644 --- a/internal/pkg/warewulfd/api/overlay.go +++ b/internal/pkg/warewulfd/api/overlay.go @@ -6,6 +6,7 @@ import ( "net/url" "os" "path" + "strings" "syscall" "github.com/swaggest/usecase" @@ -148,12 +149,46 @@ func (of *OverlayFile) renderContents(nodeName string) (string, error) { } tstruct.BuildSource = of.Path - buffer, _, _, renderErr := overlay.RenderTemplateFile(of.FullPath(), tstruct) + rendered, renderErr := overlay.RenderTemplate(of.FullPath(), tstruct) if renderErr != nil { 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) { diff --git a/internal/pkg/warewulfd/api/overlay_test.go b/internal/pkg/warewulfd/api/overlay_test.go index 955468af..3f0f158d 100644 --- a/internal/pkg/warewulfd/api/overlay_test.go +++ b/internal/pkg/warewulfd/api/overlay_test.go @@ -18,6 +18,29 @@ import ( 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 { name string initFiles map[string]string @@ -106,6 +129,96 @@ var overlayTests = []struct { }, 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": "<>", + "uid": "<>", + "gid": "<>" + }`, + }, + { + 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": "<>", + "uid": "<>", + "gid": "<>" + }`, + }, + { + 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": "<>", + "uid": "<>", + "gid": "<>" + }`, + }, + { + 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": "<>", + "uid": "<>", + "gid": "<>" + }`, + }, + { + 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": "<>", + "uid": "<>", + "gid": "<>" + }`, + }, { name: "delete overlay file", initFiles: map[string]string{ diff --git a/overlays/NetworkManager/internal/networkmanager_test.go b/overlays/NetworkManager/internal/networkmanager_test.go index d4684749..bfa9dd65 100644 --- a/overlays/NetworkManager/internal/networkmanager_test.go +++ b/overlays/NetworkManager/internal/networkmanager_test.go @@ -87,7 +87,6 @@ nodes: log: `backupFile: true writeFile: true Filename: warewulf-default.conf - # This file is autogenerated by warewulf [connection] @@ -178,7 +177,6 @@ nodes: log: `backupFile: true writeFile: true Filename: warewulf-default.conf - # This file is autogenerated by warewulf [connection] @@ -278,7 +276,6 @@ nodes: log: `backupFile: true writeFile: true Filename: warewulf-tagged.conf - # This file is autogenerated by warewulf [connection] @@ -343,7 +340,6 @@ nodes: log: `backupFile: true writeFile: true Filename: warewulf-bond0.conf - # This file is autogenerated by warewulf [connection] @@ -436,7 +432,6 @@ nodes: log: `backupFile: true writeFile: true Filename: warewulf-bond0.conf - # This file is autogenerated by warewulf [connection] @@ -528,7 +523,6 @@ nodes: log: `backupFile: true writeFile: true Filename: warewulf-bond0.conf - # This file is autogenerated by warewulf [connection] @@ -620,7 +614,6 @@ nodes: log: `backupFile: true writeFile: true Filename: warewulf-bond0.conf - # This file is autogenerated by warewulf [connection] diff --git a/overlays/NetworkManager/rootfs/etc/NetworkManager/conf.d/ww4-unmanaged.ww b/overlays/NetworkManager/rootfs/etc/NetworkManager/conf.d/ww4-unmanaged.ww index 4b66b892..35418e9f 100644 --- a/overlays/NetworkManager/rootfs/etc/NetworkManager/conf.d/ww4-unmanaged.ww +++ b/overlays/NetworkManager/rootfs/etc/NetworkManager/conf.d/ww4-unmanaged.ww @@ -1,4 +1,4 @@ -{{ file "warewulf-unmanaged.conf" }} +{{- file "warewulf-unmanaged.conf" -}} # This file is autogenerated by warewulf [main] diff --git a/overlays/NetworkManager/rootfs/etc/NetworkManager/system-connections/ww4-managed.ww b/overlays/NetworkManager/rootfs/etc/NetworkManager/system-connections/ww4-managed.ww index fd170add..288dcc87 100644 --- a/overlays/NetworkManager/rootfs/etc/NetworkManager/system-connections/ww4-managed.ww +++ b/overlays/NetworkManager/rootfs/etc/NetworkManager/system-connections/ww4-managed.ww @@ -1,5 +1,5 @@ {{- range $devname, $netdev := .NetDevs }} -{{ file (print "warewulf-" $devname ".conf") }} +{{- file (print "warewulf-" $devname ".conf") -}} # This file is autogenerated by warewulf [connection] diff --git a/overlays/ifcfg/internal/ifcfg_test.go b/overlays/ifcfg/internal/ifcfg_test.go index 9f4d5807..421f7b76 100644 --- a/overlays/ifcfg/internal/ifcfg_test.go +++ b/overlays/ifcfg/internal/ifcfg_test.go @@ -46,7 +46,6 @@ nodes: log: `backupFile: true writeFile: true Filename: ifcfg-default.conf - # This file is autogenerated by warewulf TYPE=Ethernet DEVICE=wwnet0 @@ -116,7 +115,6 @@ nodes: log: `backupFile: true writeFile: true Filename: ifcfg-default.conf - # This file is autogenerated by warewulf TYPE=Ethernet NAME=default @@ -210,7 +208,6 @@ nodes: log: `backupFile: true writeFile: true Filename: ifcfg-tagged.conf - # This file is autogenerated by warewulf VLAN=yes DEVICE=eth0.902 @@ -263,7 +260,6 @@ nodes: log: `backupFile: true writeFile: true Filename: ifcfg-bond0.conf - # This file is autogenerated by warewulf TYPE=Bond DEVICE=bond0 @@ -333,10 +329,8 @@ nodes: log: `backupFile: true writeFile: true Filename: route-tagged.conf - # This file is autogenerated by warewulf 192.168.1.0/24 via 192.168.2.254 - backupFile: true writeFile: true Filename: route-untagged.conf @@ -363,7 +357,6 @@ nodes: log: `backupFile: true writeFile: true Filename: ifcfg-tagged.conf - # This file is autogenerated by warewulf VLAN=yes DEVICE=eth0.902 diff --git a/overlays/ifcfg/rootfs/etc/sysconfig/network-scripts/ifcfg.ww b/overlays/ifcfg/rootfs/etc/sysconfig/network-scripts/ifcfg.ww index c246d7a1..e577a575 100644 --- a/overlays/ifcfg/rootfs/etc/sysconfig/network-scripts/ifcfg.ww +++ b/overlays/ifcfg/rootfs/etc/sysconfig/network-scripts/ifcfg.ww @@ -1,5 +1,5 @@ {{- range $devname, $netdev := .NetDevs }} -{{ file (print "ifcfg-" $devname ".conf") }} +{{- file (print "ifcfg-" $devname ".conf") -}} # This file is autogenerated by warewulf {{- if eq $netdev.Type "vlan" }} VLAN=yes diff --git a/overlays/ifcfg/rootfs/etc/sysconfig/network-scripts/route.ww b/overlays/ifcfg/rootfs/etc/sysconfig/network-scripts/route.ww index 5c01e854..6ede7514 100644 --- a/overlays/ifcfg/rootfs/etc/sysconfig/network-scripts/route.ww +++ b/overlays/ifcfg/rootfs/etc/sysconfig/network-scripts/route.ww @@ -1,5 +1,5 @@ {{- range $devname, $netdev := .NetDevs }} -{{ file (print "route-" $devname ".conf") }} +{{- file (print "route-" $devname ".conf") -}} # This file is autogenerated by warewulf {{- range $tk, $tv := $netdev.Tags }} {{- if eq (substr 0 5 $tk) "route" }} diff --git a/overlays/ifupdown/internal/ifupdown_test.go b/overlays/ifupdown/internal/ifupdown_test.go index 3e1d9ec4..6632051c 100644 --- a/overlays/ifupdown/internal/ifupdown_test.go +++ b/overlays/ifupdown/internal/ifupdown_test.go @@ -60,7 +60,6 @@ func Test_wickedOverlay(t *testing.T) { const expected_log string = `backupFile: true writeFile: true Filename: default - # This file is autogenerated by warewulf auto wwnet0 allow-hotplug wwnet0 @@ -69,7 +68,6 @@ iface wwnet0 inet static netmask 255.255.255.0 gateway 192.168.3.1 mtu 1500 - backupFile: true writeFile: true Filename: secondary diff --git a/overlays/ifupdown/rootfs/etc/network/interfaces.d/default.ww b/overlays/ifupdown/rootfs/etc/network/interfaces.d/default.ww index bd03c914..84e35605 100644 --- a/overlays/ifupdown/rootfs/etc/network/interfaces.d/default.ww +++ b/overlays/ifupdown/rootfs/etc/network/interfaces.d/default.ww @@ -1,5 +1,5 @@ {{- range $devname, $netdev := .ThisNode.NetDevs }} -{{ file $devname }} +{{- file $devname -}} # This file is autogenerated by warewulf {{- if $netdev.OnBoot.BoolDefaultTrue }} auto {{ $netdev.Device }} diff --git a/overlays/localtime/internal/localtime_test.go b/overlays/localtime/internal/localtime_test.go index 28c247c2..ffc7064a 100644 --- a/overlays/localtime/internal/localtime_test.go +++ b/overlays/localtime/internal/localtime_test.go @@ -52,5 +52,5 @@ func Test_localtimeOverlay(t *testing.T) { const localtime string = `backupFile: true writeFile: true Filename: etc/localtime -{{ /* softlink "/usr/share/zoneinfo/GMT" */ }} +Symlink: /usr/share/zoneinfo/GMT ` diff --git a/overlays/systemd.mount/internal/systemd_mount_test.go b/overlays/systemd.mount/internal/systemd_mount_test.go index a8e79226..fb9783cc 100644 --- a/overlays/systemd.mount/internal/systemd_mount_test.go +++ b/overlays/systemd.mount/internal/systemd_mount_test.go @@ -35,8 +35,6 @@ nodes: output: `backupFile: true writeFile: true Filename: -.mount - - [Unit] Before=local-fs.target @@ -50,7 +48,6 @@ RequiredBy=local-fs.target backupFile: true writeFile: true Filename: scratch.mount - [Unit] Before=local-fs.target @@ -83,12 +80,11 @@ nodes: output: `backupFile: true writeFile: true Filename: -.mount - -{{ /* softlink "/etc/systemd/system/-.mount" */ }} +Symlink: /etc/systemd/system/-.mount backupFile: true writeFile: true Filename: scratch.mount -{{ /* softlink "/etc/systemd/system/scratch.mount" */ }} +Symlink: /etc/systemd/system/scratch.mount `, }, } diff --git a/overlays/systemd.mount/rootfs/etc/systemd/system/disk.mount.ww b/overlays/systemd.mount/rootfs/etc/systemd/system/disk.mount.ww index 61698f7f..fe7181de 100644 --- a/overlays/systemd.mount/rootfs/etc/systemd/system/disk.mount.ww +++ b/overlays/systemd.mount/rootfs/etc/systemd/system/disk.mount.ww @@ -2,7 +2,7 @@ {{- range $fs := .ThisNode.FileSystemList }} {{- if and $fs.Path (ne $fs.Format "swap") }} {{- $abort = false }} -{{ file (print ($fs.Path | SystemdEscapePath) ".mount") }} +{{- file (print ($fs.Path | SystemdEscapePath) ".mount") -}} [Unit] Before=local-fs.target diff --git a/overlays/systemd.mount/rootfs/etc/systemd/system/local-fs.target.wants/disk.mount.ww b/overlays/systemd.mount/rootfs/etc/systemd/system/local-fs.target.wants/disk.mount.ww index d80ae239..d23817fc 100644 --- a/overlays/systemd.mount/rootfs/etc/systemd/system/local-fs.target.wants/disk.mount.ww +++ b/overlays/systemd.mount/rootfs/etc/systemd/system/local-fs.target.wants/disk.mount.ww @@ -2,7 +2,7 @@ {{- range $fs := .ThisNode.FileSystemList }} {{- if and $fs.Path (ne $fs.Format "swap") }} {{- $abort = false }} -{{ file (print ($fs.Path | SystemdEscapePath) ".mount") }} +{{- file (print ($fs.Path | SystemdEscapePath) ".mount") -}} {{ softlink (print "/etc/systemd/system/" ($fs.Path | SystemdEscapePath) ".mount") }} {{- end }} {{- end }} diff --git a/overlays/systemd.netname/rootfs/etc/systemd/network/10-ww4-netname.link.ww b/overlays/systemd.netname/rootfs/etc/systemd/network/10-ww4-netname.link.ww index 725221f4..096d7bfe 100644 --- a/overlays/systemd.netname/rootfs/etc/systemd/network/10-ww4-netname.link.ww +++ b/overlays/systemd.netname/rootfs/etc/systemd/network/10-ww4-netname.link.ww @@ -1,5 +1,5 @@ {{ range $devname, $netdev := .NetDevs -}} -{{ file (print "10-ww4-netname-" $devname ".link") }} +{{- file (print "10-ww4-netname-" $devname ".link") -}} # This file is autogenerated by warewulf {{- if and $netdev.Hwaddr $netdev.Device }} [Match] diff --git a/overlays/systemd.networkd/internal/systemd_networkd_test.go b/overlays/systemd.networkd/internal/systemd_networkd_test.go index a13ad4bc..68d7a8e1 100644 --- a/overlays/systemd.networkd/internal/systemd_networkd_test.go +++ b/overlays/systemd.networkd/internal/systemd_networkd_test.go @@ -59,7 +59,6 @@ func Test_systemdNetworkOverlay(t *testing.T) { const expected_log_with_hwaddr string = `backupFile: true writeFile: true Filename: 10-ww4-networkd-default.network - # This file is autogenerated by warewulf [Match] Name=wwnet0 @@ -104,7 +103,6 @@ DUIDType=link-layer const expected_log_without_hwaddr string = `backupFile: true writeFile: true Filename: 10-ww4-networkd-default.network - # This file is autogenerated by warewulf [Match] Name=wwnet0 diff --git a/overlays/systemd.networkd/rootfs/etc/systemd/network/default.network.ww b/overlays/systemd.networkd/rootfs/etc/systemd/network/default.network.ww index 9559629b..6e963f54 100644 --- a/overlays/systemd.networkd/rootfs/etc/systemd/network/default.network.ww +++ b/overlays/systemd.networkd/rootfs/etc/systemd/network/default.network.ww @@ -1,5 +1,5 @@ {{- 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 [Match] {{- if $netdev.Device }} diff --git a/overlays/systemd.swap/internal/systemd_swap_test.go b/overlays/systemd.swap/internal/systemd_swap_test.go index 2f361ec9..6a88527c 100644 --- a/overlays/systemd.swap/internal/systemd_swap_test.go +++ b/overlays/systemd.swap/internal/systemd_swap_test.go @@ -35,8 +35,6 @@ nodes: output: `backupFile: true writeFile: true Filename: dev-disk-by\x2dpartlabel-swap.swap - - [Unit] Before=swap.target @@ -67,8 +65,7 @@ nodes: output: `backupFile: true writeFile: true Filename: dev-disk-by\x2dpartlabel-swap.swap - -{{ /* softlink "/etc/systemd/system/dev-disk-by\x2dpartlabel-swap.swap" */ }} +Symlink: /etc/systemd/system/dev-disk-by\x2dpartlabel-swap.swap `, }, } diff --git a/overlays/systemd.swap/rootfs/etc/systemd/system/disk.swap.ww b/overlays/systemd.swap/rootfs/etc/systemd/system/disk.swap.ww index 51c5495b..a75c29d3 100644 --- a/overlays/systemd.swap/rootfs/etc/systemd/system/disk.swap.ww +++ b/overlays/systemd.swap/rootfs/etc/systemd/system/disk.swap.ww @@ -2,7 +2,7 @@ {{- range $fs := .ThisNode.FileSystemList }} {{- if eq $fs.Format "swap" }} {{- $abort = false }} -{{ file (print ($fs.Id | SystemdEscapePath) ".swap") }} +{{- file (print ($fs.Id | SystemdEscapePath) ".swap") -}} [Unit] Before=swap.target diff --git a/overlays/systemd.swap/rootfs/etc/systemd/system/local-fs.target.wants/disk.swap.ww b/overlays/systemd.swap/rootfs/etc/systemd/system/local-fs.target.wants/disk.swap.ww index 12f60fe7..71a366a6 100644 --- a/overlays/systemd.swap/rootfs/etc/systemd/system/local-fs.target.wants/disk.swap.ww +++ b/overlays/systemd.swap/rootfs/etc/systemd/system/local-fs.target.wants/disk.swap.ww @@ -2,7 +2,7 @@ {{- range $fs := .ThisNode.FileSystemList }} {{- if eq $fs.Format "swap" }} {{- $abort = false }} -{{ file (print ($fs.Id | SystemdEscapePath) ".swap") }} +{{- file (print ($fs.Id | SystemdEscapePath) ".swap") -}} {{ softlink (print "/etc/systemd/system/" ($fs.Id | SystemdEscapePath) ".swap") }} {{- end }} {{- end }} diff --git a/overlays/wicked/internal/wicked_test.go b/overlays/wicked/internal/wicked_test.go index 4972b11a..0a4f19ba 100644 --- a/overlays/wicked/internal/wicked_test.go +++ b/overlays/wicked/internal/wicked_test.go @@ -58,7 +58,6 @@ func Test_wickedOverlay(t *testing.T) { const wicked string = `backupFile: true writeFile: true Filename: ifcfg-default.xml - @@ -90,7 +89,6 @@ This file is autogenerated by warewulf false - backupFile: true writeFile: true Filename: ifcfg-secondary.xml @@ -130,7 +128,6 @@ This file is autogenerated by warewulf const wicked_vlans string = `backupFile: true writeFile: true Filename: ifcfg-tagged.xml - @@ -168,7 +165,6 @@ This file is autogenerated by warewulf false - backupFile: true writeFile: true Filename: ifcfg-untagged.xml diff --git a/overlays/wicked/rootfs/etc/wicked/ifconfig/ifcfg.xml.ww b/overlays/wicked/rootfs/etc/wicked/ifconfig/ifcfg.xml.ww index 139d60ca..c3d87c3e 100644 --- a/overlays/wicked/rootfs/etc/wicked/ifconfig/ifcfg.xml.ww +++ b/overlays/wicked/rootfs/etc/wicked/ifconfig/ifcfg.xml.ww @@ -1,6 +1,6 @@ {{- $NetDevs := .NetDevs }} {{- range $devname, $netdev := .ThisNode.NetDevs }} -{{ file (print "ifcfg-" $devname ".xml") }} +{{- file (print "ifcfg-" $devname ".xml") -}} diff --git a/userdocs/overlays/templates.rst b/userdocs/overlays/templates.rst index b3a69d9d..c511ce3c 100644 --- a/userdocs/overlays/templates.rst +++ b/userdocs/overlays/templates.rst @@ -115,17 +115,17 @@ referenced symlink. .. code-block:: - {{ ImportLink "/etc/localtime" }} + {{- ImportLink "/etc/localtime" -}} When paired with ``file``, ``ImportLink`` can create multiple symlinks from a single template. .. code-block:: - {{ file "/tmp/test-link1"}} - {{ softlink "/tmp/test-target1" }} - {{ file "/tmp/test-link2"}} - {{ softlink "/tmp/test-target2" }} + {{- file "/tmp/test-link1" -}} + {{- softlink "/tmp/test-target1" -}} + {{- file "/tmp/test-link2" -}} + {{- softlink "/tmp/test-target2" -}} basename -------- @@ -146,11 +146,21 @@ file 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. +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:: {{- range $devname, $netdev := .NetDevs }} {{- $filename := print "ifcfg-" $devname ".conf" }} - {{ file $filename }} + {{- file $filename -}} {{/* content here */}} {{- end }} @@ -163,7 +173,7 @@ Causes the processed template file to become a symlink to the referenced target. .. 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 single template. @@ -198,7 +208,7 @@ Immediately aborts processing the template and does not write a file. .. code-block:: - {{ abort }} + {{- abort -}} nobackup -------- @@ -208,7 +218,7 @@ nobackup .. code-block:: - {{ nobackup }} + {{- nobackup -}} .. _UniqueField: @@ -245,10 +255,11 @@ Escapes a path for use in a systemd unit file. .. code-block:: - {{ file (print ($fs.path | SystemdEscapePath) ".mount") }} + {{- file (print ($fs.path | SystemdEscapePath) ".mount") -}} Escape rules are documented at `systemd.unit. `_ + Examples ========