1 // "sync" sub-command: sync data to remote hosts
3 // SPDX-License-Identifier: GPL-3.0-or-later
4 // Copyright (C) 2021-2024 Simon Ruderich
20 "ruderich.org/simon/safcm"
21 "ruderich.org/simon/safcm/cmd/safcm/config"
22 "ruderich.org/simon/safcm/frontend"
23 "ruderich.org/simon/safcm/rpc"
29 config *config.Config // global configuration
30 allHosts *config.Hosts // known hosts
31 allGroups map[string][]string // known groups
36 logFunc func(level safcm.LogLevel, escaped bool, msg string)
39 func MainSync(args []string) error {
41 fmt.Fprintf(os.Stderr,
42 "usage: %s sync [<options>] <host|group...>\n",
47 optionDryRun := flag.Bool("n", false,
48 "dry-run, show diff but don't perform any changes")
49 optionQuiet := flag.Bool("q", false,
50 "hide successful, non-trigger commands with no output from host changes listing")
51 optionLog := flag.String("log", "info", "set log `level`; "+
52 "levels: error, info, verbose, debug, debug2, debug3")
53 optionSshConfig := flag.String("sshconfig", "",
54 "`path` to ssh configuration file; used for tests")
56 flag.CommandLine.Parse(args[2:]) //nolint:errcheck
58 level, err := safcm.ParseLogLevel(*optionLog)
60 return fmt.Errorf("-log: %v", err)
69 if runtime.GOOS == "windows" {
70 log.Print("WARNING: Windows support is experimental!")
73 cfg, allHosts, allGroups, err := LoadBaseFiles()
77 cfg.DryRun = *optionDryRun
78 cfg.Quiet = *optionQuiet
80 cfg.SshConfig = *optionSshConfig
82 toSync, err := hostsToSync(names, allHosts, allGroups)
87 return fmt.Errorf("no hosts found")
90 isTTY := term.IsTerminal(int(os.Stdout.Fd())) &&
91 term.IsTerminal(int(os.Stderr.Fd()))
93 loop := &frontend.Loop{
94 DebugConn: cfg.LogLevel >= safcm.LogDebug3,
95 LogEventFunc: func(x frontend.Event, failed *bool) {
96 frontend.LogEvent(x, cfg.LogLevel, isTTY, failed)
98 SyncHostFunc: func(conn *rpc.Conn, host frontend.Host) error {
99 return host.(*Sync).Host(conn)
103 var hosts []frontend.Host
104 for _, x := range toSync {
109 allGroups: allGroups,
113 s.logFunc = func(level safcm.LogLevel, escaped bool,
115 s.loop.Log(s, level, escaped, msg)
117 hosts = append(hosts, s)
120 succ := loop.Run(hosts)
123 // Exit instead of returning an error to prevent an extra log
124 // message from main()
130 // hostsToSync returns the list of hosts to sync based on the command line
133 // Full host and group matches are required to prevent unexpected behavior. No
134 // arguments does not expand to all hosts to prevent accidents; "all" can be
135 // used instead. Both host and group names are permitted as these are unique.
137 // TODO: Add option to permit partial/glob matches
138 func hostsToSync(names []string, allHosts *config.Hosts,
139 allGroups map[string][]string) ([]*config.Host, error) {
141 detectedMap := config.TransitivelyDetectedGroups(allGroups)
143 const detectedErr = `
145 Groups depending on "detected" groups cannot be used to select hosts as these
146 are only available after the hosts were contacted.
149 nameMap := make(map[string]bool)
150 for _, x := range names {
152 return nil, fmt.Errorf(
153 "group %q depends on \"detected\" groups%s",
158 nameMatched := make(map[string]bool)
159 // To detect typos we must check all given names but one host can be
160 // matched by multiple names (e.g. two groups with overlapping hosts)
161 hostAdded := make(map[string]bool)
163 var res []*config.Host
164 for _, host := range allHosts.List {
165 if nameMap[host.Name] {
166 res = append(res, host)
167 hostAdded[host.Name] = true
168 nameMatched[host.Name] = true
171 groups, err := config.ResolveHostGroups(host.Name,
176 for _, x := range groups {
178 if !hostAdded[host.Name] {
179 res = append(res, host)
180 hostAdded[host.Name] = true
182 nameMatched[x] = true
187 // Warn about unmatched names to detect typos
188 if len(nameMap) != len(nameMatched) {
189 var unmatched []string
190 for x := range nameMap {
192 unmatched = append(unmatched,
193 fmt.Sprintf("%q", x))
196 sort.Strings(unmatched)
197 return nil, fmt.Errorf("hosts/groups not found: %s",
198 strings.Join(unmatched, " "))
204 func (s *Sync) Name() string {
208 func (s *Sync) Dial(conn *rpc.Conn) error {
209 helpers, err := fs.Sub(RemoteHelpers, "remote")
214 // Connect to remote host
215 user := s.host.SshUser
217 user = s.config.SshUser
219 return conn.DialSSH(rpc.SSHConfig{
222 SshConfig: s.config.SshConfig,
223 RemoteHelpers: helpers,
227 func (s *Sync) Host(conn *rpc.Conn) error {
228 // Collect information about remote host
229 detectedGroups, err := s.hostInfo(conn)
234 // Sync state to remote host
235 err = s.hostSync(conn, detectedGroups)
243 func (s *Sync) log(level safcm.LogLevel, escaped bool, msg string) {
244 s.logFunc(level, escaped, msg)
246 func (s *Sync) logDebugf(format string, a ...interface{}) {
247 s.log(safcm.LogDebug, false, fmt.Sprintf(format, a...))
249 func (s *Sync) logVerbosef(format string, a ...interface{}) {
250 s.log(safcm.LogVerbose, false, fmt.Sprintf(format, a...))