--- /dev/null
+// "sync" sub-command: sync files
+
+// Copyright (C) 2021 Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "ruderich.org/simon/safcm"
+ "ruderich.org/simon/safcm/cmd/safcm/config"
+ "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
+ }
+ x, err := s.sendRecv(conn, req)
+ if err != nil {
+ return err
+ }
+ resp, ok := x.(safcm.MsgSyncResp)
+ if !ok {
+ return fmt.Errorf("unexpected response %v", x)
+ }
+
+ // Display changes
+ var changes []string
+ if len(resp.FileChanges) > 0 {
+ changes = append(changes,
+ s.formatFileChanges(resp.FileChanges))
+ }
+ if len(resp.PackageChanges) > 0 {
+ changes = append(changes,
+ s.formatPackageChanges(resp.PackageChanges))
+ }
+ if len(resp.ServiceChanges) > 0 {
+ changes = append(changes,
+ s.formatServiceChanges(resp.ServiceChanges))
+ }
+ if len(resp.CommandChanges) > 0 {
+ changes = append(changes,
+ s.formatCommandChanges(resp.CommandChanges))
+ }
+ if len(changes) > 0 {
+ s.logf(safcm.LogInfo, true, "%s",
+ "\n"+strings.Join(changes, "\n"))
+ }
+
+ 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 order 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 (desc. order): %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 []string
+
+ 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 have higher priorities
+ groupPriority := make(map[string]int)
+ for i, x := range s.config.GroupOrder {
+ groupPriority[x] = i + 1
+ }
+ // Host itself always has highest priority
+ groupPriority[s.host.Name] = -1
+
+ // 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 file %q\n"+
+ "Use 'group_order' 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]
+ })
+
+ const sep = string(filepath.Separator)
+
+ // Remove invalid paths which can result from group_order 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
+ }
+}