Files
warewulf/internal/pkg/overlay/overlay_test.go
Jonathon Anderson 2bad789f4f 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>
2026-04-17 22:51:10 -06:00

1097 lines
34 KiB
Go

package overlay
import (
"fmt"
"io"
"os"
"path"
"path/filepath"
"runtime"
"sort"
"testing"
"github.com/stretchr/testify/assert"
warewulfconf "github.com/warewulf/warewulf/internal/pkg/config"
"github.com/warewulf/warewulf/internal/pkg/node"
"github.com/warewulf/warewulf/internal/pkg/testenv"
"github.com/warewulf/warewulf/internal/pkg/util"
"github.com/cavaliergopher/cpio"
)
func Test_FindOverlays(t *testing.T) {
tests := map[string]struct {
distOverlays []string
siteOverlays []string
overlayList []string
}{
"dist overlays": {
distOverlays: []string{"do1", "do2", "do3"},
overlayList: []string{"do1", "do2", "do3"},
},
"site overlays": {
siteOverlays: []string{"so1", "so2", "so3"},
overlayList: []string{"so1", "so2", "so3"},
},
"both overlays": {
distOverlays: []string{"do1", "do2"},
siteOverlays: []string{"so3"},
overlayList: []string{"do1", "do2", "so3"},
},
"shadowed overlay": {
distOverlays: []string{"do1", "o1"},
siteOverlays: []string{"o1", "so1"},
overlayList: []string{"do1", "o1", "so1"},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
env := testenv.New(t)
defer env.RemoveAll()
for _, overlay := range tt.distOverlays {
env.MkdirAll(filepath.Join("usr/share/warewulf/overlays", overlay))
}
for _, overlay := range tt.siteOverlays {
env.MkdirAll(filepath.Join("var/lib/warewulf/overlays", overlay))
}
overlayList := FindOverlays()
assert.Equal(t, tt.overlayList, overlayList)
})
}
}
func Test_OverlayMethods(t *testing.T) {
env := testenv.New(t)
defer env.RemoveAll()
// Setup test data
sitedir := "/var/lib/warewulf/overlays/"
distdir := "/usr/share/warewulf/overlays/"
env.WriteFile(path.Join(sitedir, "siteonly/rootfs/testfile"), "a site overlay")
env.WriteFile(path.Join(distdir, "distonly/rootfs/testfile"), "a distribution overlay")
env.WriteFile(path.Join(sitedir, "legacy/testfile"), "a legacy overlay")
env.WriteFile(path.Join(sitedir, "both/rootfs/testfile"), "the site version")
env.WriteFile(path.Join(distdir, "both/rootfs/testfile"), "the distribution version")
tests := map[string]struct {
name string
path string
rootfs string
file string
content string
exists bool
isSite bool
isDist bool
}{
"site overlay": {
name: "siteonly",
path: path.Join(sitedir, "siteonly"),
rootfs: path.Join(sitedir, "siteonly/rootfs"),
file: path.Join(sitedir, "siteonly/rootfs/testfile"),
content: "a site overlay",
exists: true,
isSite: true,
isDist: false,
},
"distribution overlay": {
name: "distonly",
path: path.Join(distdir, "distonly"),
rootfs: path.Join(distdir, "distonly/rootfs"),
file: path.Join(distdir, "distonly/rootfs/testfile"),
content: "a distribution overlay",
exists: true,
isSite: false,
isDist: true,
},
"overlapping overlay": {
name: "both",
path: path.Join(sitedir, "both"),
rootfs: path.Join(sitedir, "both/rootfs"),
file: path.Join(sitedir, "both/rootfs/testfile"),
content: "the site version",
exists: true,
isSite: true,
isDist: false,
},
"legacy overlay": {
name: "legacy",
path: path.Join(sitedir, "legacy"),
rootfs: path.Join(sitedir, "legacy"),
file: path.Join(sitedir, "legacy/testfile"),
content: "",
exists: true,
isSite: true,
isDist: false,
},
"missing overlay": {
name: "absent",
path: path.Join(sitedir, "absent"),
rootfs: path.Join(sitedir, "absent/rootfs"),
file: path.Join(sitedir, "absent/rootfs/testfile"),
content: "",
exists: false,
isSite: true,
isDist: false,
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
overlay, err := Get(tt.name)
if tt.exists {
assert.NoError(t, err)
assert.Equal(t, tt.name, overlay.Name())
assert.Equal(t, env.GetPath(tt.path), overlay.Path())
assert.Equal(t, env.GetPath(tt.rootfs), overlay.Rootfs())
assert.Equal(t, env.GetPath(tt.file), overlay.File("testfile"))
if tt.content != "" {
buffer, err := os.ReadFile(overlay.File("testfile"))
assert.NoError(t, err)
assert.Equal(t, tt.content, string(buffer))
}
assert.Equal(t, tt.exists, overlay.Exists())
assert.Equal(t, tt.isSite, overlay.IsSiteOverlay())
assert.Equal(t, tt.isDist, overlay.IsDistributionOverlay())
}
})
}
}
func Test_BuildOverlayIndir(t *testing.T) {
tests := map[string]struct {
node node.Node
overlays []string
overlayFiles map[string]string
overlayDirs []string
overlaySymlinks map[string]string
outputDir string
outputFiles map[string]string
outputDirs []string
outputSymlinks map[string]string
}{
"empty": {
outputDir: "/image",
},
"empty directory": {
overlays: []string{"o1"},
overlayDirs: []string{"/var/lib/warewulf/overlays/o1/rootfs/testdir"},
outputDir: "/image",
outputDirs: []string{"testdir"},
},
"single flat file": {
overlays: []string{"o1"},
overlayFiles: map[string]string{
"/var/lib/warewulf/overlays/o1/rootfs/testfile": "A test file from o1",
},
outputDir: "/image",
outputFiles: map[string]string{
"testfile": "A test file from o1",
},
},
"multiple overlays": {
overlays: []string{"o1", "o2"},
overlayFiles: map[string]string{
"/var/lib/warewulf/overlays/o1/rootfs/t1.txt": "A test file from o1",
"/var/lib/warewulf/overlays/o2/rootfs/t2.txt": "A test file from o2",
},
outputDir: "/image",
outputFiles: map[string]string{
"t1.txt": "A test file from o1",
"t2.txt": "A test file from o2",
},
},
"template": {
node: node.Node{Profile: node.Profile{Comment: "A node comment"}},
overlays: []string{"o1"},
overlayFiles: map[string]string{
"/var/lib/warewulf/overlays/o1/rootfs/t1.txt.ww": "{{ .Comment }}",
"/image/t1.txt": "Previous content",
},
outputDir: "/image",
outputFiles: map[string]string{
"t1.txt": "A node comment",
"t1.txt.wwbackup": "Previous content",
},
},
"multifile": {
overlays: []string{"o1"},
overlayFiles: map[string]string{
"/var/lib/warewulf/overlays/o1/rootfs/file.txt.ww": `
{{ file "t1.txt" }}
T1
{{ file "t2.txt" }}
T2
{{ file "t3.txt" }}
T3
`,
},
outputDir: "/image",
outputFiles: map[string]string{
"t1.txt": "\nT1\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": {
overlays: []string{"o1"},
overlaySymlinks: map[string]string{
"/var/lib/warewulf/overlays/o1/rootfs/test-link": "/test-target",
},
outputDir: "/image",
outputSymlinks: map[string]string{
"test-link": "/test-target",
},
},
"symlink from template": {
overlays: []string{"o1"},
overlayFiles: map[string]string{
"/var/lib/warewulf/overlays/o1/rootfs/test-link.ww": `
{{- softlink "/test-target" }}
`,
},
outputDir: "/image",
outputSymlinks: map[string]string{
"test-link": "/test-target",
},
},
"multiple symlinks from template": {
overlays: []string{"o1"},
overlayFiles: map[string]string{
"/var/lib/warewulf/overlays/o1/rootfs/test-link.ww": `
{{ file "test-link1"}}
{{ softlink "/test-target1" }}
{{ file "test-link2"}}
{{ softlink "/test-target2" }}
`,
},
outputDir: "/image",
outputSymlinks: map[string]string{
"test-link1": "/test-target1",
"test-link2": "/test-target2",
},
},
"expansion of nodes": {
overlays: []string{"o1"},
overlayFiles: map[string]string{
"/var/lib/warewulf/overlays/o1/rootfs/node.txt.ww": `
IPMI user:{{ .Ipmi.UserName}}
Kernel Version:{{.Kernel.Version}}
Kernel Args:{{.Kernel.Args | join " "}}
NetDevs:{{.NetDevs}}
Tags:{{.Tags}}
`,
},
outputDir: "/image",
outputFiles: map[string]string{
"node.txt": `
IPMI user:
Kernel Version:
Kernel Args:
NetDevs:map[]
Tags:map[]
`,
},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
env := testenv.New(t)
defer env.RemoveAll()
for fileName, content := range tt.overlayFiles {
env.WriteFile(fileName, content)
}
for _, dirName := range tt.overlayDirs {
env.MkdirAll(dirName)
}
for linkName, target := range tt.overlaySymlinks {
env.Symlink(target, linkName)
}
env.MkdirAll(tt.outputDir)
assert.NoError(t, BuildOverlayIndir(tt.node, []node.Node{tt.node}, tt.overlays, env.GetPath(tt.outputDir)))
dirFiles := tt.outputDirs
for outputFile := range tt.outputFiles {
dirFiles = append(dirFiles, outputFile)
}
for outputFile := range tt.outputSymlinks {
dirFiles = append(dirFiles, outputFile)
}
sort.Strings(dirFiles)
assert.Equal(t, dirFiles, env.ReadDir(tt.outputDir))
for fileName, content := range tt.outputFiles {
assert.Equal(t, content, env.ReadFile(path.Join(tt.outputDir, fileName)))
}
for _, dirName := range tt.outputDirs {
assert.True(t, util.IsDir(env.GetPath(path.Join(tt.outputDir, dirName))), fmt.Sprintf("%s is not a directory", dirName))
}
for linkName, expTarget := range tt.outputSymlinks {
target, err := os.Readlink(env.GetPath(path.Join(tt.outputDir, linkName)))
assert.NoError(t, err)
assert.Equal(t, expTarget, target)
}
})
}
}
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
nodeName string
context string
overlays []string
image string
contents []string
perms []int
hasFiles bool
}{
{
description: "if no node, context, or overlays are specified then no overlay image is generated",
nodeName: "",
context: "",
overlays: nil,
image: "",
contents: nil,
},
{
description: "if only node is specified then no overlay image is generated",
nodeName: "node1",
context: "",
overlays: nil,
image: "",
contents: nil,
},
{
description: "if only context is specified then context named image is generated",
nodeName: "",
context: "system",
overlays: nil,
image: "__SYSTEM__.img",
contents: nil,
},
{
description: "if an overlay is specified without a node, then the overlay is built directly in the overlay directory",
nodeName: "",
context: "",
overlays: []string{"o1"},
image: "o1.img",
contents: []string{"o1.txt"},
perms: []int{0o644},
},
{
description: "if multiple overlays are specified without a node, then the combined overlay is built directly in the overlay directory",
nodeName: "",
context: "",
overlays: []string{"o1", "o2"},
image: "o1-o2.img",
contents: []string{"o1.txt", "o2.txt"},
perms: []int{0o644, 0o644},
},
{
description: "if a single node overlay is specified, then the overlay is built in a node overlay directory",
nodeName: "node1",
context: "",
overlays: []string{"o1"},
image: "node1/o1.img",
contents: []string{"o1.txt"},
perms: []int{0o644},
},
{
description: "if multiple node overlays are specified, then the combined overlay is built in a node overlay directory",
nodeName: "node1",
context: "",
overlays: []string{"o1", "o2"},
image: "node1/o1-o2.img",
contents: []string{"o1.txt", "o2.txt"},
perms: []int{0o644, 0o644},
},
{
description: "if no node system overlays are specified, then context pointed overlay is generated",
nodeName: "node1",
context: "system",
overlays: nil,
image: "node1/__SYSTEM__.img",
contents: nil,
},
{
description: "if no node runtime overlays are specified, then context pointed overlay is generated",
nodeName: "node1",
context: "runtime",
overlays: nil,
image: "node1/__RUNTIME__.img",
contents: nil,
},
{
description: "if a single node system overlay is specified, then a system overlay image is generated in a node overlay directory",
nodeName: "node1",
context: "system",
overlays: []string{"o1"},
image: "node1/__SYSTEM__.img",
contents: []string{"o1.txt"},
perms: []int{0o644},
},
{
description: "if a single node runtime overlay is specified, then a runtime overlay image is generated in a node overlay directory",
nodeName: "node1",
context: "runtime",
overlays: []string{"o1"},
image: "node1/__RUNTIME__.img",
contents: []string{"o1.txt"},
perms: []int{0o644},
},
{
description: "if multiple node system overlays are specified, then a system overlay image is generated with the contents of both overlays",
nodeName: "node1",
context: "system",
overlays: []string{"o1", "o2"},
image: "node1/__SYSTEM__.img",
contents: []string{"o1.txt", "o2.txt"},
perms: []int{0o644, 0o644},
},
{
description: "if multiple node runtime overlays are specified, then a runtime overlay image is generated with the contents of both overlays",
nodeName: "node1",
context: "runtime",
overlays: []string{"o1", "o2"},
image: "node1/__RUNTIME__.img",
contents: []string{"o1.txt", "o2.txt"},
perms: []int{0o644, 0o644},
},
{
description: "validating altered permissions are retained",
nodeName: "node1",
context: "runtime",
overlays: []string{"o3"},
image: "node1/__RUNTIME__.img",
contents: []string{"subdir", "subdir/o3.txt"},
perms: []int{0o700, 0o600},
},
}
env := testenv.New(t)
defer env.RemoveAll()
env.CreateFile("var/lib/warewulf/overlays/o1/rootfs/o1.txt")
env.Chmod("var/lib/warewulf/overlays/o1/rootfs/o1.txt", 0o644)
env.CreateFile("var/lib/warewulf/overlays/o2/rootfs/o2.txt")
env.Chmod("var/lib/warewulf/overlays/o2/rootfs/o2.txt", 0o644)
env.CreateFile("var/lib/warewulf/overlays/o3/rootfs/subdir/o3.txt.ww")
env.Chmod("var/lib/warewulf/overlays/o3/rootfs/subdir", 0o700)
env.Chmod("var/lib/warewulf/overlays/o3/rootfs/subdir/o3.txt.ww", 0o600)
for _, tt := range tests {
nodeInfo := node.NewNode(tt.nodeName)
t.Run(tt.description, func(t *testing.T) {
err := BuildOverlay(nodeInfo, []node.Node{nodeInfo}, tt.context, tt.overlays)
assert.NoError(t, err)
if tt.image != "" {
image := env.GetPath(path.Join("srv/warewulf/overlays", tt.image))
assert.FileExists(t, image)
sort.Strings(tt.contents)
headers, err := readCpio(image)
assert.NoError(t, err)
assert.Equal(t, len(tt.contents), len(headers))
for i, file := range tt.contents {
assert.Equal(t, file, headers[file].Name)
if len(tt.perms) > i {
assert.Equal(t, tt.perms[i], int(headers[file].Mode.Perm()))
}
}
} else {
dirName := env.GetPath(path.Join("srv/warewulf/overlays", tt.nodeName))
isEmpty := dirIsEmpty(t, dirName)
assert.True(t, isEmpty, "%v should be empty, but isn't", dirName)
}
})
}
}
func Test_BuildAllOverlays(t *testing.T) {
tests := []struct {
description string
nodes []string
systemOverlays [][]string
runtimeOverlays [][]string
createdOverlays []string
}{
{
description: "empty input creates no overlays",
nodes: nil,
systemOverlays: nil,
runtimeOverlays: nil,
createdOverlays: nil,
},
{
description: "a node with no overlays creates default system/runtime overlays",
nodes: []string{"node1"},
systemOverlays: [][]string{{"o1"}},
runtimeOverlays: [][]string{{"o1"}},
createdOverlays: []string{"node1/__SYSTEM__.img.gz", "node1/__RUNTIME__.img.gz"},
},
{
description: "multiple nodes with no overlays creates default system/runtime overlays",
nodes: []string{"node1", "node2"},
systemOverlays: [][]string{{"o1"}, {"o2"}},
runtimeOverlays: [][]string{{"o1"}, {"o2"}},
createdOverlays: []string{"node1/__SYSTEM__.img.gz", "node1/__RUNTIME__.img.gz", "node2/__SYSTEM__.img.gz", "node2/__RUNTIME__.img.gz"},
},
{
description: "a system overlay for a node generates a system overlay for that node",
nodes: []string{"node1"},
systemOverlays: [][]string{{"o1"}},
runtimeOverlays: nil,
createdOverlays: []string{"node1/__SYSTEM__.img.gz"},
},
{
description: "two nodes with different system overlays generates a system overlay for each node",
nodes: []string{"node1", "node2"},
systemOverlays: [][]string{{"o1"}, {"o1", "o2"}},
runtimeOverlays: nil,
createdOverlays: []string{"node1/__SYSTEM__.img.gz", "node2/__SYSTEM__.img.gz"},
},
{
description: "two nodes with a single runtime overlay generates a runtime overlay for the first node",
nodes: []string{"node1"},
systemOverlays: nil,
runtimeOverlays: [][]string{{"o1"}},
createdOverlays: []string{"node1/__RUNTIME__.img.gz"},
},
{
description: "two nodes with different runtime overlays generates a system overlay for each node",
nodes: []string{"node1", "node2"},
systemOverlays: nil,
runtimeOverlays: [][]string{{"o1"}, {"o1", "o2"}},
createdOverlays: []string{"node1/__RUNTIME__.img.gz", "node2/__RUNTIME__.img.gz"},
},
{
description: "a node with both a runtime and system overlay generates an image for each",
nodes: []string{"node1"},
systemOverlays: [][]string{{"o1"}},
runtimeOverlays: [][]string{{"o2"}},
createdOverlays: []string{"node1/__RUNTIME__.img.gz", "node1/__SYSTEM__.img.gz"},
},
{
description: "two nodes with both runtime and system overlays generates each image for each node",
nodes: []string{"node1", "node2"},
systemOverlays: [][]string{{"o1"}, {"o1", "o2"}},
runtimeOverlays: [][]string{{"o2"}, {"o2"}},
createdOverlays: []string{"node1/__RUNTIME__.img.gz", "node1/__SYSTEM__.img.gz", "node2/__RUNTIME__.img.gz", "node2/__SYSTEM__.img.gz"},
},
}
conf := warewulfconf.Get()
overlayDir, overlayDirErr := os.MkdirTemp(os.TempDir(), "ww-test-overlay-*")
assert.NoError(t, overlayDirErr)
defer func() { _ = os.RemoveAll(overlayDir) }()
conf.Paths.WWOverlaydir = overlayDir
assert.NoError(t, os.Mkdir(path.Join(overlayDir, "o1"), 0o700))
assert.NoError(t, os.Mkdir(path.Join(overlayDir, "o2"), 0o700))
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
provisionDir, provisionDirErr := os.MkdirTemp(os.TempDir(), "ww-test-provision-*")
assert.NoError(t, provisionDirErr)
defer func() { _ = os.RemoveAll(provisionDir) }()
conf.Paths.WWProvisiondir = provisionDir
var nodes []node.Node
for i, nodeName := range tt.nodes {
nodeInfo := node.NewNode(nodeName)
if tt.systemOverlays != nil {
nodeInfo.SystemOverlay = tt.systemOverlays[i]
}
if tt.runtimeOverlays != nil {
nodeInfo.RuntimeOverlay = tt.runtimeOverlays[i]
}
nodes = append(nodes, nodeInfo)
}
err := BuildAllOverlays(nodes, nodes, runtime.NumCPU())
assert.NoError(t, err)
if tt.createdOverlays == nil {
dirName := path.Join(provisionDir, "overlays")
assert.True(t, dirIsEmpty(t, dirName), "%v should be empty, but isn't", dirName)
}
for _, overlayPath := range tt.createdOverlays {
assert.FileExists(t, path.Join(provisionDir, "overlays", overlayPath))
}
})
}
}
func Test_BuildSpecificOverlays(t *testing.T) {
tests := []struct {
description string
nodes []string
overlays []string
images []string
succeed bool
}{
{
description: "building no overlays for no nodes generates no error and no images",
nodes: nil,
overlays: nil,
images: nil,
succeed: true,
},
{
description: "building no overlays for a node generates no error and no images",
nodes: []string{"node1"},
overlays: nil,
images: nil,
succeed: true,
},
{
description: "building no overlays for two nodes generates no error and no images",
nodes: []string{"node1", "node2"},
overlays: nil,
images: nil,
succeed: true,
},
{
description: "building an overlay for a node generates an overlay image in that node's overlay directory",
nodes: []string{"node1"},
overlays: []string{"o1"},
images: []string{"node1/o1.img"},
succeed: true,
},
{
description: "building an overlay for two nodes generates an overlay image in each node's overlay directory",
nodes: []string{"node1", "node2"},
overlays: []string{"o1"},
images: []string{"node1/o1.img", "node2/o1.img"},
succeed: true,
},
{
description: "building multiple overlays for a node generates an overlay image for each overlay in the node's overlay directory",
nodes: []string{"node1"},
overlays: []string{"o1", "o2"},
images: []string{"node1/o1.img", "node1/o2.img"},
succeed: true,
},
{
description: "building multiple overlays for two nodes generates an overlay image for each overlay in each node's overlay directory",
nodes: []string{"node1", "node2"},
overlays: []string{"o1", "o2"},
images: []string{"node1/o1.img", "node1/o2.img", "node2/o1.img", "node2/o2.img"},
succeed: true,
},
}
conf := warewulfconf.Get()
overlayDir, overlayDirErr := os.MkdirTemp(os.TempDir(), "ww-test-overlay-*")
assert.NoError(t, overlayDirErr)
defer func() { _ = os.RemoveAll(overlayDir) }()
conf.Paths.WWOverlaydir = overlayDir
assert.NoError(t, os.Mkdir(path.Join(overlayDir, "o1"), 0o700))
assert.NoError(t, os.Mkdir(path.Join(overlayDir, "o2"), 0o700))
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
provisionDir, provisionDirErr := os.MkdirTemp(os.TempDir(), "ww-test-provision-*")
assert.NoError(t, provisionDirErr)
defer func() { _ = os.RemoveAll(provisionDir) }()
conf.Paths.WWProvisiondir = provisionDir
var nodes []node.Node
for _, nodeName := range tt.nodes {
nodeInfo := node.NewNode(nodeName)
nodes = append(nodes, nodeInfo)
}
err := BuildSpecificOverlays(nodes, nodes, tt.overlays, runtime.NumCPU())
if !tt.succeed {
assert.Error(t, err)
} else {
assert.NoError(t, err)
for _, image := range tt.images {
assert.FileExists(t, path.Join(provisionDir, "overlays", image))
}
}
})
}
}
func Test_CreateOverlayFile(t *testing.T) {
tests := []struct {
name string
overlayName string
filePath string
content []byte
force bool
}{
{"create file", "test", "test.ww", []byte("new file"), false},
{"overwrite existing file", "test", "test.ww", []byte("overwrite file"), true},
}
conf := warewulfconf.Get()
overlayDir, overlayDirErr := os.MkdirTemp(os.TempDir(), "ww-test-overlay-*")
assert.NoError(t, overlayDirErr)
defer func() { _ = os.RemoveAll(overlayDir) }()
conf.Paths.WWOverlaydir = overlayDir
conf.Paths.Datadir = "/dev/null"
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
newOverlay, err := Get(tt.overlayName)
if err != nil {
newOverlay, err = Create(tt.overlayName)
}
assert.NoError(t, err)
err = newOverlay.AddFile(tt.filePath, tt.content, true, tt.force)
assert.NoError(t, err)
newFile := newOverlay.File(tt.filePath)
assert.FileExists(t, newFile)
readContent, err := os.ReadFile(newFile)
assert.NoError(t, err)
assert.Equal(t, tt.content, readContent)
})
}
}
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 {
t.Log(err)
return true
}
defer func() { _ = f.Close() }()
dirnames, err2 := f.Readdirnames(1)
if err2 == io.EOF {
t.Log(err2)
return true
}
t.Log(dirnames)
return false
}
func readCpio(name string) (headers map[string]*cpio.Header, err error) {
f, err := os.Open(name)
if err != nil {
return headers, err
}
defer func() { _ = f.Close() }()
reader := cpio.NewReader(f)
headers = make(map[string]*cpio.Header)
for {
header, err := reader.Next()
if err == io.EOF {
return headers, nil
}
if err != nil {
return headers, err
}
headers[header.Name] = header
}
}