// "sync" sub-command: sync files // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2021-2024 Simon Ruderich package main import ( "fmt" "math" "os" "sort" "strings" "ruderich.org/simon/safcm" "ruderich.org/simon/safcm/cmd/safcm/config" "ruderich.org/simon/safcm/frontend" "ruderich.org/simon/safcm/rpc" ) func (s *Sync) hostSync(conn *rpc.Conn, detectedGroups []string) error { req, err := s.hostSyncReq(detectedGroups) if err != nil { return err } resp, err := s.loop.HostSyncMsg(s, conn, req) if err != nil { return err } // Display changes c := frontend.Changes{ DryRun: s.config.DryRun, Quiet: s.config.Quiet, IsTTY: s.isTTY, } changes := c.FormatChanges(resp) if changes != "" { s.log(safcm.LogInfo, true, changes) } if resp.Error != "" { return fmt.Errorf("%s", resp.Error) } return nil } func (s *Sync) hostSyncReq(detectedGroups []string) ( safcm.MsgSyncReq, error) { var empty safcm.MsgSyncReq groups, groupPriority, err := s.resolveHostGroups(detectedGroups) if err != nil { return empty, err } { // Don't leak internal group priority which is confusing // without knowing the implementation details. groupsSorted := make([]string, len(groups)) copy(groupsSorted, groups) sort.Strings(groupsSorted) s.logVerbosef("host groups: %s", strings.Join(groupsSorted, " ")) // Don't leak internal priority values. Instead, order groups // by priority. var priorities []string for x := range groupPriority { priorities = append(priorities, x) } sort.Slice(priorities, func(i, j int) bool { a := priorities[i] b := priorities[j] return groupPriority[a] > groupPriority[b] }) s.logVerbosef("host group priorities (descending): %v", strings.Join(priorities, " ")) } allFiles := make(map[string]*safcm.File) allPackagesMap := make(map[string]bool) // map to deduplicate allServicesMap := make(map[string]bool) // map to deduplicate var allCommands []*safcm.Command for _, group := range groups { // Skip non-existent group directories _, err := os.Stat(group) if os.IsNotExist(err) { continue } files, err := config.LoadFiles(group) if err != nil { return empty, err } err = config.LoadPermissions(group, files) if err != nil { return empty, err } err = config.LoadTemplates(group, files, s.host.Name, groups, s.allHosts, s.allGroups) if err != nil { return empty, err } err = config.LoadTriggers(group, files) if err != nil { return empty, err } for k, v := range files { err := s.checkFileConflict(group, k, v, allFiles, groupPriority) if err != nil { return empty, err } v.OrigGroup = group allFiles[k] = v } packages, err := config.LoadPackages(group) if err != nil { return empty, err } for _, x := range packages { allPackagesMap[x] = true } services, err := config.LoadServices(group) if err != nil { return empty, err } for _, x := range services { allServicesMap[x] = true } commands, err := config.LoadCommands(group) if err != nil { return empty, err } allCommands = append(allCommands, commands...) } resolveFileDirConflicts(allFiles) var allPackages []string var allServices []string for x := range allPackagesMap { allPackages = append(allPackages, x) } for x := range allServicesMap { allServices = append(allServices, x) } // Sort for deterministic results sort.Strings(allPackages) sort.Strings(allServices) return safcm.MsgSyncReq{ DryRun: s.config.DryRun, Groups: groups, Files: allFiles, Packages: allPackages, Services: allServices, Commands: allCommands, }, nil } // resolveHostGroups returns the groups and group priorities of the current // host. func (s *Sync) resolveHostGroups(detectedGroups []string) ( []string, map[string]int, error) { groups, err := config.ResolveHostGroups(s.host.Name, s.allGroups, detectedGroups) if err != nil { return nil, nil, err } // Early entries in "group_priority" have higher priorities groupPriority := make(map[string]int) for i, x := range s.config.GroupPriority { groupPriority[x] = len(s.config.GroupPriority) - i } // Host itself always has highest priority groupPriority[s.host.Name] = math.MaxInt32 // Sort groups after priority and name sort.Slice(groups, func(i, j int) bool { a := groups[i] b := groups[j] if groupPriority[a] < groupPriority[b] { return true } else if groupPriority[a] > groupPriority[b] { return false } else { return a < b } }) return groups, groupPriority, nil } func (s *Sync) checkFileConflict(group string, path string, file *safcm.File, allFiles map[string]*safcm.File, groupPriority map[string]int) error { old, ok := allFiles[path] if !ok { return nil } newPrio := groupPriority[group] oldPrio := groupPriority[old.OrigGroup] if oldPrio < newPrio { if old.Mode.IsDir() && file.Mode.IsDir() && old.TriggerCommands != nil { s.logDebugf("files: %q: "+ "group %s overwrites triggers from group %s", path, group, old.OrigGroup) } return nil } else if oldPrio > newPrio { // Should never happen, groups are sorted by priority panic("invalid group priorities") } // Directories with default permissions and no triggers do not count // as conflict if file.Mode.IsDir() && file.Mode == old.Mode && config.FileModeToFullPerm(file.Mode) == 0755 && file.TriggerCommands == nil && old.TriggerCommands == nil { return nil } return fmt.Errorf("groups %s and %s both provide %q\n"+ "Use 'group_priority' in config.yaml to declare preference", group, old.OrigGroup, path) } func resolveFileDirConflicts(files map[string]*safcm.File) { var paths []string for x := range files { paths = append(paths, x) } sort.Slice(paths, func(i, j int) bool { return paths[i] < paths[j] }) // Slash separated paths are used for the configuration const sep = "/" // Remove invalid paths which can result from group_priority // overriding paths from another group (e.g. "/foo" as file from one // group and "/foo/bar" from another). var last *safcm.File for _, x := range paths { file := files[x] if last != nil && !last.Mode.IsDir() && strings.HasPrefix(file.Path, last.Path+sep) { delete(files, x) continue } last = file } }