package overlay import ( "bytes" "errors" "fmt" "io/fs" "os" "path" "path/filepath" "reflect" "regexp" "strings" "sync" "text/template" "text/template/parse" "github.com/Masterminds/sprig/v3" "github.com/coreos/go-systemd/v22/unit" "github.com/warewulf/warewulf/internal/pkg/config" "github.com/warewulf/warewulf/internal/pkg/node" "github.com/warewulf/warewulf/internal/pkg/util" "github.com/warewulf/warewulf/internal/pkg/wwlog" ) var ErrDoesNotExist = fmt.Errorf("overlay does not exist") // Overlay represents an overlay directory path. type Overlay string // Name returns the base name of the overlay directory. // // This is derived from the full path of the overlay. func (overlay Overlay) Name() string { return path.Base(overlay.Path()) } // Path returns the string representation of the overlay path. // // This method allows the Overlay type to be easily converted back to its // underlying string representation. func (overlay Overlay) Path() string { return string(overlay) } // Rootfs returns the path to the root filesystem (rootfs) within the overlay. // // If the "rootfs" directory exists inside the overlay path, it returns the // path to the "rootfs" directory. Otherwise, it checks if the overlay path // itself is a directory and returns that. If neither exists, it defaults to // returning the "rootfs" path. func (overlay Overlay) Rootfs() string { rootfs := path.Join(overlay.Path(), "rootfs") if util.IsDir(rootfs) { return rootfs } else if util.IsDir(overlay.Path()) { return overlay.Path() } else { return rootfs } } // File constructs a full path to a file within the overlay's root filesystem. // // Parameters: // - filePath: The relative path of the file within the overlay. // // Returns: // - The full path to the specified file in the overlay's rootfs. // If the specified path is not contained within the overlay, the empty string is returned. func (overlay Overlay) File(filePath string) string { rootfs := overlay.Rootfs() fullPath := path.Join(rootfs, filePath) cleanPath := filepath.Clean(fullPath) cleanRootfs := filepath.Clean(rootfs) rel, err := filepath.Rel(cleanRootfs, cleanPath) if err != nil { return "" } if strings.HasPrefix(rel, "..") { return "" } return cleanPath } // Exists checks whether the overlay path exists and is a directory. // // Returns: // - true if the overlay path exists and is a directory; false otherwise. func (overlay Overlay) Exists() bool { return util.IsDir(overlay.Path()) } // IsSiteOverlay determines whether the overlay is a site overlay. // // A site overlay is identified by its parent directory matching the configured // site overlay directory path. // // Returns: // - true if the overlay is a site overlay; false otherwise. func (overlay Overlay) IsSiteOverlay() bool { siteDir := filepath.Clean(config.Get().Paths.SiteOverlaydir()) overlayPath := filepath.Clean(overlay.Path()) if rel, err := filepath.Rel(siteDir, overlayPath); err != nil { return false } else { return !strings.HasPrefix(rel, "..") } } // IsDistributionOverlay determines whether the overlay is a distribution overlay. // // A distribution overlay is identified by its parent directory matching the configured // distribution overlay directory path. // // Returns: // - true if the overlay is a distribution overlay; false otherwise. func (overlay Overlay) IsDistributionOverlay() bool { siteDir := filepath.Clean(config.Get().Paths.DistributionOverlaydir()) overlayPath := filepath.Clean(overlay.Path()) if rel, err := filepath.Rel(siteDir, overlayPath); err != nil { return false } else { return !strings.HasPrefix(rel, "..") } } func (overlay Overlay) AddFile(filePath string, content []byte, parents bool, force bool) error { wwlog.Info("Creating file %s in overlay %s, force: %v", filePath, overlay.Name(), force) if !overlay.IsSiteOverlay() { siteOverlay, err := overlay.CloneToSite() if err != nil { return fmt.Errorf("failed to clone distribution overlay '%s' to site overlay: %w", overlay.Name(), err) } // replace the overlay with newly created siteOverlay overlay = siteOverlay } fullPath := overlay.File(filePath) // create necessary parent directories if parents { if err := os.MkdirAll(path.Dir(fullPath), 0o755); err != nil { return fmt.Errorf("failed to create parent directories for %s: %w", fullPath, err) } } // if the file already exists and force is false, return an error if util.IsFile(fullPath) { if force { return os.WriteFile(fullPath, content, 0o644) } return fmt.Errorf("file %s already exists in overlay %s", filePath, overlay.Name()) } return os.WriteFile(fullPath, content, 0o644) } func (overlay Overlay) Delete(force bool) (err error) { wwlog.Info("Deleting overlay %s, force: %v", overlay.Name(), force) if overlay.IsDistributionOverlay() { return fmt.Errorf("cannot delete a distribution overlay: %s", overlay.Name()) } if force { err := os.RemoveAll(overlay.Path()) if err != nil { return fmt.Errorf("failed to delete overlay forcely: %w", err) } } else { // remove rootfs at first if err = os.Remove(overlay.Rootfs()); err != nil { return fmt.Errorf("failed to delete overlay: %w", err) } if overlay.Exists() { if err = os.Remove(overlay.Path()); err != nil { return fmt.Errorf("failed to delete overlay: %w", err) } } } return nil } // DeleteFile deletes a file or the entire overlay directory. // before deletion. func (overlay Overlay) DeleteFile(filePath string, force, cleanup bool) (err error) { wwlog.Info("Deleting file %s from overlay %s, force: %v, cleanup: %v", filePath, overlay.Name(), force, cleanup) // first check if file exists if !util.IsFile(overlay.File(filePath)) { return fmt.Errorf("file %s does not exist in overlay %s", filePath, overlay.Name()) } if overlay.IsDistributionOverlay() { siteOverlay, err := overlay.CloneToSite() if err != nil { return fmt.Errorf("failed to clone distribution overlay '%s' to site overlay: %w", overlay.Name(), err) } // replace the overlay with newly created siteOverlay overlay = siteOverlay } fullPath := overlay.File(filePath) if force { if err := os.RemoveAll(fullPath); err != nil { return fmt.Errorf("failed to delete file %s from overlay %s: %w", filePath, overlay.Name(), err) } } else { if err := os.Remove(fullPath); err != nil { return fmt.Errorf("failed to delete file %s from overlay %s: %w", filePath, overlay.Name(), err) } } if cleanup { // cleanup the empty parents i := path.Dir(fullPath) for i != overlay.Rootfs() { wwlog.Debug("Evaluating directory to remove: %s", i) err := os.Remove(i) if err != nil { // if the directory is not empty, we stop here if !os.IsNotExist(err) { wwlog.Debug("Could not remove directory %s: %v", i, err) } break } wwlog.Debug("Removed empty directory: %s", i) i = path.Dir(i) } } return nil } // chmod for the given path in the overlay func (overlay Overlay) Chmod(path string, mode uint64) (err error) { if !overlay.IsSiteOverlay() { overlay, err = overlay.CloneToSite() if err != nil { return err } } fullPath := overlay.File(path) if !util.IsFile(fullPath) && !util.IsDir(fullPath) { return fmt.Errorf("file does not exist within overlay: %s:%s", overlay.Name(), fullPath) } return os.Chmod(fullPath, os.FileMode(mode)) } // chown file or dir in overlay func (overlay Overlay) Chown(path string, uid, gid int) (err error) { if !overlay.IsSiteOverlay() { overlay, err = overlay.CloneToSite() if err != nil { return err } } fullPath := overlay.File(path) if !util.IsFile(fullPath) && !util.IsDir(fullPath) { return fmt.Errorf("file does not exist within overlay: %s:%s", overlay.Name(), fullPath) } return os.Chown(fullPath, uid, gid) } func (overlay Overlay) Mkdir(path string, mode int32) (err error) { if !overlay.IsSiteOverlay() { overlay, err = overlay.CloneToSite() if err != nil { return err } } fullPath := overlay.File(path) if util.IsFile(fullPath) || util.IsDir(fullPath) { wwlog.Warn("path already exists, overwriting permissions: %s:%s", overlay.Name(), fullPath) } return os.MkdirAll(fullPath, os.FileMode(mode)) } // FieldInfo contains detailed type information about a template variable type FieldInfo struct { Field reflect.StructField // Complete field metadata including tags ParentType reflect.Type // The containing struct type FullPath string // Full path like ".NetDevs.Ipaddr6" VarName string // Original variable name from template } // ParseVarFields returns detailed type information for each variable in the template // by using reflection to resolve the actual struct fields being referenced. func (overlay Overlay) ParseVarFields(file string) map[string]FieldInfo { if !strings.HasSuffix(file, ".ww") { return nil } fullPath := overlay.File(file) if !util.IsFile(fullPath) { wwlog.Error("Template file does not exist in overlay %s: %s", overlay.Name(), file) return nil } 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) return nil } result := make(map[string]FieldInfo) rootType := reflect.TypeOf(TemplateStruct{}) // Track range variables and their types rangeVars := make(map[string]reflect.Type) // Initialize $ to refer to the root template context rangeVars["$"] = rootType if tmpl.Tree != nil && tmpl.Root != nil { walkParseTree(tmpl.Root, rootType, "", rangeVars, result) } return result } // ParseCommentVars parses a template file for comments that contain variable documentations. // The comments must be in the format `{{/* key: value */}}`. The content is parsed as YAML. func (overlay Overlay) ParseCommentVars(file string) (retMap map[string]string) { retMap = make(map[string]string) if !strings.HasSuffix(file, ".ww") { return nil } fullPath := overlay.File(file) if !util.IsFile(fullPath) { wwlog.Error("Template file does not exist in overlay %s: %s", overlay.Name(), file) return nil } content, err := os.ReadFile(fullPath) if err != nil { wwlog.Error("Could not read template file %s: %s", fullPath, err) return nil } re := regexp.MustCompile(`{{-?\s*/\*\s*(.*?):\s*(.*?)\s*\*/\s*-?}}`) matches := re.FindAllStringSubmatch(string(content), -1) if len(matches) > 0 { wwlog.Debug("matches: %v len(%d:%d)", matches, len(matches), len(matches[0])) } else { wwlog.Debug("matches: [] len(0)") } for i := range matches { if len(matches[i]) > 2 { retMap[matches[i][1]] = matches[i][2] } } return } // walkParseTree recursively traverses the template's parse tree and resolves // variable references to actual struct fields using reflection. func walkParseTree(node parse.Node, currentType reflect.Type, currentPath string, rangeVars map[string]reflect.Type, result map[string]FieldInfo) { if node == nil { return } switch n := node.(type) { case *parse.ActionNode: // Handle variable assignments like $var := expr if n.Pipe != nil && len(n.Pipe.Decl) > 0 && len(n.Pipe.Cmds) > 0 { // Try to resolve the type of the expression being assigned cmd := n.Pipe.Cmds[0] if len(cmd.Args) > 0 { // Check for field access (e.g., $NetDevs := .NetDevs) if field, ok := cmd.Args[0].(*parse.FieldNode); ok { fieldInfo := resolveFieldChain(currentType, field.Ident, currentPath) if fieldInfo != nil { // Record all declared variables with this type for _, decl := range n.Pipe.Decl { rangeVars[decl.Ident[0]] = fieldInfo.Field.Type } } } else if varNode, ok := cmd.Args[0].(*parse.VariableNode); ok { // Check for variable-to-variable assignment (e.g., $b := $a) if len(varNode.Ident) == 1 { // Simple variable reference varBaseName := varNode.Ident[0] if varType, exists := rangeVars[varBaseName]; exists { for _, decl := range n.Pipe.Decl { rangeVars[decl.Ident[0]] = varType } } } } } } walkParseTree(n.Pipe, currentType, currentPath, rangeVars, result) case *parse.IfNode: walkParseTree(n.Pipe, currentType, currentPath, rangeVars, result) walkParseTree(n.List, currentType, currentPath, rangeVars, result) walkParseTree(n.ElseList, currentType, currentPath, rangeVars, result) case *parse.ListNode: if n != nil { for _, child := range n.Nodes { walkParseTree(child, currentType, currentPath, rangeVars, result) } } case *parse.RangeNode: // Handle range statements like {{range $key, $val := .NetDevs}} // First, walk the pipe to record the variable being ranged over walkParseTree(n.Pipe, currentType, currentPath, rangeVars, result) if n.Pipe != nil && len(n.Pipe.Cmds) > 0 { // Get what's being ranged over to determine element type var fieldInfo *FieldInfo // Check for both FieldNode (e.g., .NetDevs) and VariableNode (e.g., $disk.PartitionList) rangeField := extractFieldFromPipe(n.Pipe) if rangeField != nil { // Resolve the type of the field being ranged over fieldInfo = resolveFieldChain(currentType, rangeField.Ident, currentPath) } else { // Check if it's a variable access for _, cmd := range n.Pipe.Cmds { for _, arg := range cmd.Args { if varNode, ok := arg.(*parse.VariableNode); ok { varBaseName := varNode.Ident[0] if varType, exists := rangeVars[varBaseName]; exists { if len(varNode.Ident) > 1 { // Multi-part variable like $disk.PartitionList fieldIdents := varNode.Ident[1:] // Skip the variable name fieldInfo = resolveFieldChain(varType, fieldIdents, currentPath) } else { // Simple variable like $NetDevs fieldInfo = &FieldInfo{ Field: reflect.StructField{ Name: varBaseName, Type: varType, }, ParentType: currentType, FullPath: currentPath, } } break } } } if fieldInfo != nil { break } } } if fieldInfo != nil { rangeType := fieldInfo.Field.Type // For slices and arrays, get the element type if rangeType.Kind() == reflect.Slice || rangeType.Kind() == reflect.Array { rangeType = rangeType.Elem() } // For maps, get the value type if rangeType.Kind() == reflect.Map { rangeType = rangeType.Elem() } // Dereference pointers if rangeType.Kind() == reflect.Ptr { rangeType = rangeType.Elem() } // Track the range variable assignments if len(n.Pipe.Decl) > 0 { if len(n.Pipe.Decl) == 2 { // Two-variable range: $key, $val := .Map or $index, $val := .Slice keyType := reflect.TypeOf(0) // Default to int for slice indices if fieldInfo.Field.Type.Kind() == reflect.Map { keyType = fieldInfo.Field.Type.Key() } rangeVars[n.Pipe.Decl[0].Ident[0]] = keyType // First variable is key/index rangeVars[n.Pipe.Decl[1].Ident[0]] = rangeType // Second variable is value } else { // Single-variable range: $val := .Slice rangeVars[n.Pipe.Decl[0].Ident[0]] = rangeType } } // Walk the range body with the element type walkParseTree(n.List, rangeType, fieldInfo.FullPath, rangeVars, result) } } walkParseTree(n.ElseList, currentType, currentPath, rangeVars, result) case *parse.WithNode: walkParseTree(n.Pipe, currentType, currentPath, rangeVars, result) walkParseTree(n.List, currentType, currentPath, rangeVars, result) walkParseTree(n.ElseList, currentType, currentPath, rangeVars, result) case *parse.TemplateNode: walkParseTree(n.Pipe, currentType, currentPath, rangeVars, result) case *parse.PipeNode: if n != nil { for _, cmd := range n.Cmds { for _, arg := range cmd.Args { walkParseTree(arg, currentType, currentPath, rangeVars, result) } } } case *parse.FieldNode: // Field access like .Ipmi.Ipaddr or $netdev.Ipaddr varName := n.String() // Determine the base type for field resolution baseType := currentType basePath := currentPath // Check if this is a variable access (starts with $) if strings.HasPrefix(varName, "$") { // Extract variable name (e.g., "$netdev.Device" -> "netdev") parts := strings.SplitN(varName[1:], ".", 2) if len(parts) > 0 { varBaseName := parts[0] if varType, exists := rangeVars[varBaseName]; exists { baseType = varType basePath = currentPath } } } fieldInfo := resolveFieldChain(baseType, n.Ident, basePath) // If resolution failed, try adding "P" suffix to last identifier // This handles methods like Primary() backed by PrimaryP field if fieldInfo == nil && len(n.Ident) >= 1 { identWithP := make([]string, len(n.Ident)) copy(identWithP, n.Ident) identWithP[len(identWithP)-1] += "P" fieldInfo = resolveFieldChain(baseType, identWithP, basePath) // Use the original variable name (without P) if fieldInfo != nil { fieldInfo.VarName = varName } } if fieldInfo != nil { if fieldInfo.VarName == "" { fieldInfo.VarName = varName } result[varName] = *fieldInfo } case *parse.VariableNode: // Variable reference like $netdev or possibly $netdev.Field varName := n.String() // Check if this is a simple variable or a field access on a variable if len(n.Ident) > 1 { // This is $var.Field or $var.Field.Method - handle like a FieldNode varBaseName := n.Ident[0] if varType, exists := rangeVars[varBaseName]; exists { // Resolve the field chain starting from the variable's type fieldIdents := n.Ident[1:] // Skip the variable name, keep the fields fieldInfo := resolveFieldChain(varType, fieldIdents, currentPath) // If resolution failed and we have multiple parts, the last part might be a method // Try resolving without the last identifier (e.g., OnBoot.BoolDefaultTrue -> OnBoot) if fieldInfo == nil && len(fieldIdents) > 1 { fieldIdents = fieldIdents[:len(fieldIdents)-1] fieldInfo = resolveFieldChain(varType, fieldIdents, currentPath) // Use a simplified variable name without the method if fieldInfo != nil { // Build simplified name: $varBaseName.field1.field2 (without method) simplifiedName := varBaseName for _, ident := range fieldIdents { simplifiedName += "." + ident } fieldInfo.VarName = simplifiedName } } // If resolution still failed, try adding "P" suffix to last identifier // This handles methods like Primary() backed by PrimaryP field if fieldInfo == nil && len(fieldIdents) >= 1 { // Try with "P" suffix on the last identifier fieldIdentsWithP := make([]string, len(fieldIdents)) copy(fieldIdentsWithP, fieldIdents) fieldIdentsWithP[len(fieldIdentsWithP)-1] += "P" fieldInfo = resolveFieldChain(varType, fieldIdentsWithP, currentPath) // Use the original variable name (without P) if fieldInfo != nil { fieldInfo.VarName = varName } } if fieldInfo != nil { if fieldInfo.VarName == "" { fieldInfo.VarName = varName } result[fieldInfo.VarName] = *fieldInfo } } } else if len(n.Ident) == 1 { // Simple variable reference varBaseName := n.Ident[0] if varType, exists := rangeVars[varBaseName]; exists { result[varName] = FieldInfo{ VarName: varName, ParentType: varType, FullPath: currentPath, } } } } } // extractFieldFromPipe extracts the FieldNode from a pipe (used in range statements) func extractFieldFromPipe(pipe *parse.PipeNode) *parse.FieldNode { if pipe == nil || len(pipe.Cmds) == 0 { return nil } for _, cmd := range pipe.Cmds { for _, arg := range cmd.Args { if field, ok := arg.(*parse.FieldNode); ok { return field } } } return nil } // resolveFieldChain walks a chain of field identifiers (like ["Ipmi", "Ipaddr"]) // and returns the final field's information using reflection. // Methods are resolved and reported. If a method has a backing field with "P" suffix, // the backing field's metadata is used; otherwise, the method's return type is used. func resolveFieldChain(rootType reflect.Type, idents []string, basePath string) *FieldInfo { if rootType.Kind() == reflect.Ptr { rootType = rootType.Elem() } if len(idents) == 0 { return nil } currentType := rootType fullPath := basePath var finalField reflect.StructField var parentType reflect.Type for i, fieldName := range idents { if currentType.Kind() != reflect.Struct { return nil } field, found := currentType.FieldByName(fieldName) if !found { // Field not found - try to find a method with this name // This handles methods like DiskList(), PartitionList(), Id(), ShouldExist() ptrType := reflect.PointerTo(currentType) method, methodFound := ptrType.MethodByName(fieldName) if !methodFound { return nil } // Get the method's return type (first return value) methodType := method.Type if methodType.NumOut() == 0 { return nil } returnType := methodType.Out(0) // Check if there's a backing field with "P" suffix // Only methods with backing fields should be reported as user-facing variables backingFieldName := fieldName + "P" backingField, backingFound := currentType.FieldByName(backingFieldName) // For the last identifier in the chain if i == len(idents)-1 { if backingFound { // Use the backing field's metadata (tags) but the method's return type // This gives us the documentation from ShouldExistP but the type from ShouldExist() finalField = reflect.StructField{ Name: fieldName, Type: returnType, // Use method's return type, not backing field's type Tag: backingField.Tag, } parentType = currentType fullPath += "." + fieldName // Use method name in path, not field name currentType = returnType } else { // No backing field - create a field with the method's return type // This allows documenting methods via comments finalField = reflect.StructField{ Name: fieldName, Type: returnType, } parentType = currentType fullPath += "." + fieldName currentType = returnType } } else { // Not the last identifier - we're in the middle of a chain (e.g., DiskList in node.DiskList.PartitionList) // Continue walking with the method's return type for type resolution parentType = currentType fullPath += "." + fieldName currentType = returnType // We don't have a real field yet, just continue to next identifier // Don't set finalField here as we need to keep walking continue } } else { // Field found finalField = field parentType = currentType fullPath += "." + fieldName currentType = field.Type } // Dereference pointer types for next iteration if currentType.Kind() == reflect.Ptr { currentType = currentType.Elem() } // For map types, remaining identifiers are map keys, not fields if currentType.Kind() == reflect.Map && i < len(idents)-1 { // Append remaining path as map keys mapKeyPath := "" for j := i + 1; j < len(idents); j++ { mapKeyPath += "." + idents[j] } fullPath += mapKeyPath // Create a synthetic field with the map's value type // For Tags (map[string]string), accessing Tags.key should return string, not map[string]string valueType := currentType.Elem() return &FieldInfo{ Field: reflect.StructField{ Name: idents[len(idents)-1], // Use the last key as the field name Type: valueType, // Use the map's value type }, ParentType: parentType, FullPath: fullPath, } } } return &FieldInfo{ Field: finalField, ParentType: parentType, FullPath: fullPath, } } func BuildAllOverlays(nodes []node.Node, allNodes []node.Node, workerCount int) error { nodeChan := make(chan node.Node, len(nodes)) errChan := make(chan error, len(nodes)*2) var wg sync.WaitGroup worker := func() { for n := range nodeChan { wwlog.Info("Building system overlay image for %s", n.Id()) wwlog.Debug("System overlays for %s: [%s]", n.Id(), strings.Join(n.SystemOverlay, ", ")) if len(n.SystemOverlay) < 1 { wwlog.Warn("No system overlays defined for %s", n.Id()) } if err := BuildOverlay(n, allNodes, "system", n.SystemOverlay); err != nil { errChan <- fmt.Errorf("could not build system overlays %v for node %s: %w", n.SystemOverlay, n.Id(), err) } wwlog.Info("Building runtime overlay image for %s", n.Id()) wwlog.Debug("Runtime overlays for %s: [%s]", n.Id(), strings.Join(n.RuntimeOverlay, ", ")) if len(n.RuntimeOverlay) < 1 { wwlog.Warn("No runtime overlays defined for %s", n.Id()) } if err := BuildOverlay(n, allNodes, "runtime", n.RuntimeOverlay); err != nil { errChan <- fmt.Errorf("could not build runtime overlays %v for node %s: %w", n.RuntimeOverlay, n.Id(), err) } } wg.Done() } for i := 0; i < workerCount; i++ { wg.Add(1) go worker() } for _, n := range nodes { nodeChan <- n } close(nodeChan) wg.Wait() close(errChan) for err := range errChan { return err } return nil } func BuildSpecificOverlays(nodes []node.Node, allNodes []node.Node, overlayNames []string, workerCount int) error { nodeChan := make(chan node.Node, len(nodes)) errChan := make(chan error, len(nodes)) var wg sync.WaitGroup worker := func() { for n := range nodeChan { wwlog.Info("Building overlay for %s: %v", n.Id(), overlayNames) for _, overlayName := range overlayNames { err := BuildOverlay(n, allNodes, "", []string{overlayName}) if err != nil { errChan <- fmt.Errorf("could not build overlay %s for node %s: %w", overlayName, n.Id(), err) } } } wg.Done() } for i := 0; i < workerCount; i++ { wg.Add(1) go worker() } for _, n := range nodes { nodeChan <- n } close(nodeChan) wg.Wait() close(errChan) for err := range errChan { return err } return nil } /* Build overlay for the host, so no argument needs to be given */ func BuildHostOverlay() error { hostname, _ := os.Hostname() hostData := node.NewNode(hostname) wwlog.Info("Building overlay for %s: host", hostname) hostdir, err := Get("host") if err != nil { return err } stats, err := os.Stat(hostdir.Rootfs()) if err != nil { return fmt.Errorf("could not build host overlay: %w ", err) } if stats.Mode() != os.FileMode(0o750|os.ModeDir) && stats.Mode() != os.FileMode(0o700|os.ModeDir) { wwlog.SecWarn("Permissions of host overlay dir %s are %s (750 is considered as secure)", hostdir.Rootfs(), stats.Mode()) } registry, err := node.New() if err != nil { return err } var allNodes []node.Node allNodes, err = registry.FindAllNodes() if err != nil { return err } return BuildOverlayIndir(hostData, allNodes, []string{"host"}, "/") } /* Get all overlays present in warewulf */ func FindOverlays() (overlayList []string) { dotfilecheck, _ := regexp.Compile(`^\..*`) controller := config.Get() var files []fs.DirEntry if distfiles, err := os.ReadDir(controller.Paths.DistributionOverlaydir()); err != nil { wwlog.Warn("error reading overlays from %s: %s", controller.Paths.DistributionOverlaydir(), err) } else { files = append(files, distfiles...) } if sitefiles, err := os.ReadDir(controller.Paths.SiteOverlaydir()); err != nil { wwlog.Warn("error reading overlays from %s: %s", controller.Paths.SiteOverlaydir(), err) } else { files = append(files, sitefiles...) } for _, file := range files { wwlog.Debug("Evaluating overlay source: %s", file.Name()) isdotfile := dotfilecheck.MatchString(file.Name()) if file.IsDir() && !isdotfile && !util.InSlice(overlayList, file.Name()) { overlayList = append(overlayList, file.Name()) } } return overlayList } /* Build the given overlays for a node and create an image for them */ func BuildOverlay(nodeConf node.Node, allNodes []node.Node, context string, overlayNames []string) error { if len(overlayNames) == 0 && context == "" { return nil } // create the dir where the overlay images will reside var name string if context != "" { name = fmt.Sprintf("%s %s overlay", nodeConf.Id(), context) } else { name = fmt.Sprintf("%s overlay/%v", nodeConf.Id(), overlayNames) } overlayImage := Image(nodeConf.Id(), context, overlayNames) overlayImageDir := path.Dir(overlayImage) err := os.MkdirAll(overlayImageDir, 0o750) if err != nil { return fmt.Errorf("failed to create directory for %s: %s: %w", name, overlayImageDir, err) } wwlog.Debug("Created directory for %s: %s", name, overlayImageDir) buildDir, err := os.MkdirTemp(os.TempDir(), ".wwctl-overlay-") if err != nil { return fmt.Errorf("failed to create temporary directory for %s: %w", name, err) } defer func() { if err := os.RemoveAll(buildDir); err != nil { wwlog.Warn("failed to remove temporary build directory %s: %s", buildDir, err) } }() wwlog.Debug("Created temporary directory for %s: %s", name, buildDir) err = BuildOverlayIndir(nodeConf, allNodes, overlayNames, buildDir) if err != nil { return fmt.Errorf("failed to generate files for %s: %w", name, err) } wwlog.Debug("Generated files for %s", name) err = util.BuildFsImage( name, buildDir, overlayImage, []string{"*"}, []string{}, // ignore cross-device files true, "newc") return err } // 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 { return nil } if !util.IsDir(outputDir) { return fmt.Errorf("output must a be a directory: %s", outputDir) } if !util.ValidString("^[a-zA-Z0-9-._:]+$", strings.Join(overlayNames, "")) { return fmt.Errorf("overlay names contains illegal characters: %v", overlayNames) } wwlog.Verbose("Processing node/overlays: %s/%s", nodeData.Id(), strings.Join(overlayNames, ",")) for _, overlayName := range overlayNames { wwlog.Verbose("Building overlay %s for node %s in %s", overlayName, nodeData.Id(), outputDir) overlayRootfs, err := Get(overlayName) if err != nil { return err } wwlog.Debug("Walking the overlay structure: %s", overlayRootfs.Rootfs()) err = filepath.Walk(overlayRootfs.Rootfs(), func(walkPath string, info os.FileInfo, err error) error { if err != nil { return fmt.Errorf("error for %s: %w", walkPath, err) } wwlog.Debug("Found overlay file: %s", walkPath) relPath, relErr := filepath.Rel(overlayRootfs.Rootfs(), walkPath) if relErr != nil { wwlog.Warn("Error computing relative path for %s: %v", walkPath, relErr) return relErr } outputPath := path.Join(outputDir, relPath) if info.IsDir() { wwlog.Debug("Found directory: %s", walkPath) if err = os.MkdirAll(outputPath, info.Mode()); err != nil { return fmt.Errorf("could not create directory within overlay: %w", err) } if err = util.CopyUIDGID(walkPath, outputPath); err != nil { return fmt.Errorf("failed setting permissions on overlay directory: %w", err) } wwlog.Debug("Created directory in overlay: %s", outputPath) } else if filepath.Ext(walkPath) == ".ww" { originalOutputPath := outputPath 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) } tstruct.BuildSource = walkPath wwlog.Verbose("Evaluating overlay template file: %s", walkPath) rendered, err := RenderTemplate(walkPath, tstruct) if err != nil { return fmt.Errorf("failed to render template %s: %w", walkPath, err) } if !rendered.WriteFile { return nil } // 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) if err != nil { return fmt.Errorf("failed reading symlink: %w", err) } if util.IsFile(outputPath) { backupPath := outputPath + ".wwbackup" if !util.IsFile(backupPath) { wwlog.Debug("Output file already exists: moving to backup file") if err = os.Rename(outputPath, backupPath); err != nil { return fmt.Errorf("failed renaming to backup file: %w", err) } } else { wwlog.Debug("%s exists, keeping the backup file", backupPath) if err = os.Remove(outputPath); err != nil { return fmt.Errorf("failed removing existing file: %w", err) } } } if err = os.Symlink(target, outputPath); err != nil { return fmt.Errorf("failed creating symlink: %w", err) } wwlog.Debug("Created symlink file: %s", outputPath) } else { if err := util.CopyFile(walkPath, outputPath); err != nil { return fmt.Errorf("could not copy file into overlay: %w", err) } wwlog.Debug("Copied overlay file: %s", outputPath) } return nil }) if err != nil { return fmt.Errorf("failed to build overlay image directory: %w", err) } } return nil } /* Writes buffer to the destination file. If wwbackup is set a wwbackup will be created. */ func CarefulWriteBuffer(destFile string, buffer bytes.Buffer, backupFile bool, perm fs.FileMode) (err error) { wwlog.Debug("Trying to careful write file (%d bytes): %s", buffer.Len(), destFile) if backupFile { if !util.IsFile(destFile+".wwbackup") && util.IsFile(destFile) { err := util.CopyFile(destFile, destFile+".wwbackup") if err != nil { return fmt.Errorf("failed to create backup: %s -> %s.wwbackup %w", destFile, destFile, err) } } } w, err := os.OpenFile(destFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) if err != nil { return fmt.Errorf("could not open new file for template %w", err) } defer func() { if cerr := w.Close(); cerr != nil && err == nil { err = fmt.Errorf("failed to close file %s: %w", destFile, cerr) } }() _, err = buffer.WriteTo(w) return err } // 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, } for key, value := range sprig.TxtFuncMap() { funcMap[key] = value } return funcMap } // 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, ) { rendered, renderErr := RenderTemplate(fileName, data) if renderErr != nil { err = renderErr return } backupFile = &rendered.BackupFile writeFile = &rendered.WriteFile buffer = rendered.Files[0].Buffer return } // 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 { if util.IsFile(path) { files = append(files, strings.TrimPrefix(path, overlay.Rootfs())) } return nil }) return }