Files
warewulf/internal/pkg/node/mergo.go
Jonathon Anderson 081d2ec61e Update linter for golang v1.25 compatibility
Signed-off-by: Jonathon Anderson <janderson@ciq.com>
2026-03-11 09:32:49 +01:00

224 lines
7.3 KiB
Go

package node
import (
"net"
"reflect"
"strings"
"dario.cat/mergo"
"github.com/mohae/deepcopy"
"github.com/warewulf/warewulf/internal/pkg/wwlog"
)
// getNodeProfiles returns a deduplicated list of profile identifiers associated with the given node.
// It expands nested profiles from the node's profile list and then cleans the result by removing
// duplicate entries and any profiles prefixed with '~' (which denote negated profiles).
//
// If the specified node is not found in the configuration, the function returns an empty slice.
//
// Parameters:
//
// id - the identifier of the node whose profiles are to be retrieved.
//
// Returns:
//
// A slice of profile IDs associated with the given node, with nested profiles expanded and cleaned.
func (config *NodesYaml) getNodeProfiles(id string) (profiles []string) {
visited := make(map[string]bool)
if node, ok := config.Nodes[id]; ok {
profiles = cleanList(config.getProfilesProfiles(node.Profiles, visited))
}
return profiles
}
// getProfilesProfiles recursively expands a list of profile identifiers by processing their nested profiles.
// For each profile ID in the input slice:
// - If the profile ID begins with '~', it is treated as a negation and appended directly to the output.
// - If the profile ID has already been processed (as tracked by the visited map), it is skipped to prevent duplication and infinite recursion.
// - Otherwise, the profile ID is marked as visited. If a corresponding profile exists in the configuration's NodeProfiles,
// its nested profiles are recursively processed and appended to the output before appending the profile ID itself.
//
// Parameters:
//
// input - A slice of profile IDs to be expanded.
// visited - A map tracking profile IDs that have already been processed to avoid duplicates and cycles.
//
// Returns:
//
// A slice of profile IDs that includes the original and all recursively expanded nested profiles.
func (config *NodesYaml) getProfilesProfiles(input []string, visited map[string]bool) (output []string) {
for _, id := range input {
if strings.HasPrefix(id, "~") {
output = append(output, id)
continue
} else if visited[id] {
continue
} else {
visited[id] = true
if profile, ok := config.NodeProfiles[id]; ok {
output = append(output, config.getProfilesProfiles(profile.Profiles, visited)...)
}
output = append(output, id)
}
}
return output
}
type Transformer struct{}
func (t Transformer) Transformer(typ reflect.Type) func(dst, src reflect.Value) error {
if typ == reflect.TypeOf((*bool)(nil)) {
return func(dst, src reflect.Value) error {
if !src.IsValid() {
return nil
}
// Always override *bool values when source is set
if !src.IsNil() {
dst.Set(src)
}
return nil
}
} else if typ == reflect.TypeOf(net.IP{}) {
return func(dst, src reflect.Value) error {
if !src.IsValid() || src.IsNil() {
return nil
}
dst.Set(src)
return nil
}
} else if typ.Kind() == reflect.Interface {
return func(dst, src reflect.Value) error {
if !src.IsValid() || src.IsZero() {
return nil
}
// Handle merging of concrete values
switch src.Interface().(type) {
case map[string]interface{}:
if dst.IsNil() {
dst.Set(reflect.New(src.Elem().Type()).Elem())
}
return mergo.Merge(dst.Interface(), src.Interface(), mergo.WithAppendSlice, mergo.WithOverride, mergo.WithTransformers(t))
case []interface{}:
dst.Set(src)
default:
dst.Set(src)
}
return nil
}
}
return nil
}
// MergeNode merges the configuration of a node identified by `id` with all the profiles
// associated with it, producing a fully composed `Node` and a `fieldMap` detailing the
// sources of various configuration fields.
//
// It works by:
// - Retrieving the base node configuration using `GetNodeOnly`.
// - Gathering all profile IDs associated with the node via `getNodeProfiles`.
// - For each profile:
// - Merging fields from a deep copy of each profile into the node,
// recording the origin of each configuration field (i.e., which profile provided it)
// in a `fieldMap` so that traceability is maintained.
// - Finally, merging the original node configuration back into the processed node, ensuring
// that any fields not set by the profiles are preserved, and updating the `fieldMap`
// accordingly.
//
// Parameters:
// - id: The identifier of the node to be merged with its profiles.
//
// Returns:
// - node: The resulting merged `Node` configuration.
// - fields: A `fieldMap` detailing the source(s) of each configuration field.
// - err: An error if any node or profile retrieval or merging operations fail.
func (config *NodesYaml) MergeNode(id string) (node Node, fields fieldMap, err error) {
node, err = config.GetNodeOnly(id)
if err != nil {
return node, fields, err
}
originalNode := node
node = Node{}
fields = make(fieldMap)
for _, profileID := range config.getNodeProfiles(id) {
if profile, err := config.GetProfile(profileID); err != nil {
wwlog.Warn("profile not found: %s", profileID)
continue
} else {
profile := deepcopy.Copy(profile)
if err = merge(&node.Profile, profile, fields, profileID, profileID); err != nil {
return node, fields, err
}
}
}
if err = merge(&node, originalNode, fields, "", id); err != nil {
return node, fields, err
}
node.Profiles = originalNode.Profiles
if len(node.Profiles) > 0 {
fields.Set("Profiles", "", strings.Join(originalNode.Profiles, ","))
fields["Profiles"].Source = ""
} else {
delete(fields, "Profiles")
}
node.Comment = originalNode.Comment
if node.Comment != "" {
fields.Set("Comment", "", node.Comment)
fields["Comment"].Source = ""
} else {
delete(fields, "Comment")
}
node.setIds(id)
node.valid = true
node.updatePrimaryNetDev()
node.cleanLists()
return node, fields, nil
}
// merge merges the fields of src (a data object) into dst (a pointer) associated with it. Used by
// MergeNode to provide consistent behavior when merging profiles and nodes.
//
// merge further tracks the source of each field in the provided fields.
//
// Because the source label behavior differs between multi-valued fields (e.g., slices) and
// single-valued slices, two source names must be provided: srcName is used for single-valued
// fields, and multipleSrcName is used for multi-sourced fields.
//
// Returns an error if the merging operation fails.
func merge(dest, src interface{}, fields fieldMap, srcName string, multipleSrcName string) error {
if err := mergo.Merge(dest, src, mergo.WithAppendSlice, mergo.WithOverride, mergo.WithTransformers(Transformer{})); err != nil {
return err
}
for _, fieldName := range listFields(src) {
if value, err := getNestedFieldValue(src, fieldName); err == nil && valueStr(value) != "" {
srcName := srcName
prevSource := fields.Source(fieldName)
if prevSource != "" {
switch value.Kind() {
case reflect.Slice:
if value.Type() != reflect.TypeOf(net.IP{}) {
srcName = strings.Join([]string{prevSource, multipleSrcName}, ",")
}
case reflect.Interface:
if _, ok := value.Interface().([]interface{}); ok {
srcName = strings.Join([]string{prevSource, multipleSrcName}, ",")
}
}
}
if value, err := getNestedFieldString(reflect.ValueOf(dest).Elem().Interface(), fieldName); err == nil {
fields.Set(fieldName, srcName, value)
}
}
}
return nil
}