// "sync" sub-command: sync data to remote hosts // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2021-2024 Simon Ruderich package main import ( "flag" "fmt" "io/fs" "log" "os" "runtime" "sort" "strings" "golang.org/x/term" "ruderich.org/simon/safcm" "ruderich.org/simon/safcm/cmd/safcm/config" "ruderich.org/simon/safcm/frontend" "ruderich.org/simon/safcm/rpc" ) type Sync struct { host *config.Host config *config.Config // global configuration allHosts *config.Hosts // known hosts allGroups map[string][]string // known groups isTTY bool loop *frontend.Loop logFunc func(level safcm.LogLevel, escaped bool, msg string) } func MainSync(args []string) error { flag.Usage = func() { fmt.Fprintf(os.Stderr, "usage: %s sync [] \n", args[0]) flag.PrintDefaults() } optionDryRun := flag.Bool("n", false, "dry-run, show diff but don't perform any changes") optionQuiet := flag.Bool("q", false, "hide successful, non-trigger commands with no output from host changes listing") optionLog := flag.String("log", "info", "set log `level`; "+ "levels: error, info, verbose, debug, debug2, debug3") optionSshConfig := flag.String("sshconfig", "", "`path` to ssh configuration file; used for tests") flag.CommandLine.Parse(args[2:]) //nolint:errcheck level, err := safcm.ParseLogLevel(*optionLog) if err != nil { return fmt.Errorf("-log: %v", err) } names := flag.Args() if len(names) == 0 { flag.Usage() os.Exit(1) } if runtime.GOOS == "windows" { log.Print("WARNING: Windows support is experimental!") } cfg, allHosts, allGroups, err := LoadBaseFiles() if err != nil { return err } cfg.DryRun = *optionDryRun cfg.Quiet = *optionQuiet cfg.LogLevel = level cfg.SshConfig = *optionSshConfig toSync, err := hostsToSync(names, allHosts, allGroups) if err != nil { return err } if len(toSync) == 0 { return fmt.Errorf("no hosts found") } isTTY := term.IsTerminal(int(os.Stdout.Fd())) && term.IsTerminal(int(os.Stderr.Fd())) loop := &frontend.Loop{ DebugConn: cfg.LogLevel >= safcm.LogDebug3, LogEventFunc: func(x frontend.Event, failed *bool) { frontend.LogEvent(x, cfg.LogLevel, isTTY, failed) }, SyncHostFunc: func(conn *rpc.Conn, host frontend.Host) error { return host.(*Sync).Host(conn) }, } var hosts []frontend.Host for _, x := range toSync { s := &Sync{ host: x, config: cfg, allHosts: allHosts, allGroups: allGroups, isTTY: isTTY, loop: loop, } s.logFunc = func(level safcm.LogLevel, escaped bool, msg string) { s.loop.Log(s, level, escaped, msg) } hosts = append(hosts, s) } succ := loop.Run(hosts) if !succ { // Exit instead of returning an error to prevent an extra log // message from main() os.Exit(1) } return nil } // hostsToSync returns the list of hosts to sync based on the command line // arguments. // // Full host and group matches are required to prevent unexpected behavior. No // arguments does not expand to all hosts to prevent accidents; "all" can be // used instead. Both host and group names are permitted as these are unique. // // TODO: Add option to permit partial/glob matches func hostsToSync(names []string, allHosts *config.Hosts, allGroups map[string][]string) ([]*config.Host, error) { detectedMap := config.TransitivelyDetectedGroups(allGroups) const detectedErr = ` Groups depending on "detected" groups cannot be used to select hosts as these are only available after the hosts were contacted. ` nameMap := make(map[string]bool) for _, x := range names { if detectedMap[x] { return nil, fmt.Errorf( "group %q depends on \"detected\" groups%s", x, detectedErr) } nameMap[x] = true } nameMatched := make(map[string]bool) // To detect typos we must check all given names but one host can be // matched by multiple names (e.g. two groups with overlapping hosts) hostAdded := make(map[string]bool) var res []*config.Host for _, host := range allHosts.List { if nameMap[host.Name] { res = append(res, host) hostAdded[host.Name] = true nameMatched[host.Name] = true } groups, err := config.ResolveHostGroups(host.Name, allGroups, nil) if err != nil { return nil, err } for _, x := range groups { if nameMap[x] { if !hostAdded[host.Name] { res = append(res, host) hostAdded[host.Name] = true } nameMatched[x] = true } } } // Warn about unmatched names to detect typos if len(nameMap) != len(nameMatched) { var unmatched []string for x := range nameMap { if !nameMatched[x] { unmatched = append(unmatched, fmt.Sprintf("%q", x)) } } sort.Strings(unmatched) return nil, fmt.Errorf("hosts/groups not found: %s", strings.Join(unmatched, " ")) } return res, nil } func (s *Sync) Name() string { return s.host.Name } func (s *Sync) Dial(conn *rpc.Conn) error { helpers, err := fs.Sub(RemoteHelpers, "remote") if err != nil { return err } // Connect to remote host user := s.host.SshUser if user == "" { user = s.config.SshUser } return conn.DialSSH(rpc.SSHConfig{ Host: s.host.Name, User: user, SshConfig: s.config.SshConfig, RemoteHelpers: helpers, }) } func (s *Sync) Host(conn *rpc.Conn) error { // Collect information about remote host detectedGroups, err := s.hostInfo(conn) if err != nil { return err } // Sync state to remote host err = s.hostSync(conn, detectedGroups) if err != nil { return err } return nil } func (s *Sync) log(level safcm.LogLevel, escaped bool, msg string) { s.logFunc(level, escaped, msg) } func (s *Sync) logDebugf(format string, a ...interface{}) { s.log(safcm.LogDebug, false, fmt.Sprintf(format, a...)) } func (s *Sync) logVerbosef(format string, a ...interface{}) { s.log(safcm.LogVerbose, false, fmt.Sprintf(format, a...)) }