1 // "sync" sub-command: sync data to remote hosts
3 // Copyright (C) 2021 Simon Ruderich
5 // This program is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
10 // This program is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 // GNU General Public License for more details.
15 // You should have received a copy of the GNU General Public License
16 // along with this program. If not, see <http://www.gnu.org/licenses/>.
34 "ruderich.org/simon/safcm"
35 "ruderich.org/simon/safcm/cmd/safcm/config"
36 "ruderich.org/simon/safcm/rpc"
42 config *config.Config // global configuration
43 allHosts *config.Hosts // known hosts
44 allGroups map[string][]string // known groups
46 events chan<- Event // all events generated by/for this host
54 // Only one of Error, Log and ConnEvent is set in a single event
57 ConnEvent rpc.ConnEvent
59 Escaped bool // true if untrusted input is already escaped
67 func MainSync(args []string) error {
69 fmt.Fprintf(os.Stderr,
70 "usage: %s sync [<options>] <host|group...>\n",
75 optionDryRun := flag.Bool("n", false,
76 "dry-run, show diff but don't perform any changes")
77 optionQuiet := flag.Bool("q", false,
78 "hide successful, non-trigger commands with no output from host changes listing")
79 optionLog := flag.String("log", "info", "set log `level`; "+
80 "levels: error, info, verbose, debug, debug2, debug3")
81 optionSshConfig := flag.String("sshconfig", "",
82 "`path` to ssh configuration file; used for tests")
84 flag.CommandLine.Parse(args[2:])
86 var level safcm.LogLevel
89 level = safcm.LogError
93 level = safcm.LogVerbose
95 level = safcm.LogDebug
97 level = safcm.LogDebug2
99 level = safcm.LogDebug3
101 return fmt.Errorf("invalid -log value %q", *optionLog)
110 if runtime.GOOS == "windows" {
111 log.Print("WARNING: Windows support is experimental!")
114 cfg, allHosts, allGroups, err := LoadBaseFiles()
118 cfg.DryRun = *optionDryRun
119 cfg.Quiet = *optionQuiet
121 cfg.SshConfig = *optionSshConfig
123 toSync, err := hostsToSync(names, allHosts, allGroups)
127 if len(toSync) == 0 {
128 return fmt.Errorf("no hosts found")
131 isTTY := term.IsTerminal(int(os.Stdout.Fd())) &&
132 term.IsTerminal(int(os.Stderr.Fd()))
134 done := make(chan bool)
135 // Collect events from all hosts and print them
136 events := make(chan Event)
144 logEvent(x, cfg.LogLevel, isTTY, &failed)
149 hostsLeft := make(map[string]bool)
150 for _, x := range toSync {
151 hostsLeft[x.Name] = true
153 var hostsLeftMutex sync.Mutex // protects hostsLeft
155 // Show unfinished hosts on Ctrl-C
156 sigint := make(chan os.Signal, 1) // buffered for Notify()
157 signal.Notify(sigint, os.Interrupt) // = SIGINT = Ctrl-C
159 // Running `ssh` processes get killed by SIGINT which is sent
163 log.Print("Received SIGINT, aborting ...")
165 // Print all queued events
166 events <- Event{} // poison pill
168 // "races" with <-done in the main function and will hang here
169 // if the other is faster. This is fine because then all hosts
170 // were synced successfully.
172 hostsLeftMutex.Lock()
174 for x := range hostsLeft {
175 hosts = append(hosts, x)
178 log.Fatalf("Failed to sync %s", strings.Join(hosts, ", "))
181 // Sync all hosts concurrently
182 var wg sync.WaitGroup
183 for _, x := range toSync {
186 // Once in sync.Host() and once in the go func below
194 allGroups: allGroups,
198 err := sync.Host(&wg)
207 hostsLeftMutex.Lock()
208 defer hostsLeftMutex.Unlock()
209 delete(hostsLeft, x.Name)
214 events <- Event{} // poison pill
218 // Exit instead of returning an error to prevent an extra log
219 // message from main()
225 // hostsToSync returns the list of hosts to sync based on the command line
228 // Full host and group matches are required to prevent unexpected behavior. No
229 // arguments does not expand to all hosts to prevent accidents; "all" can be
230 // used instead. Both host and group names are permitted as these are unique.
232 // TODO: Add option to permit partial/glob matches
233 func hostsToSync(names []string, allHosts *config.Hosts,
234 allGroups map[string][]string) ([]*config.Host, error) {
236 detectedMap := config.TransitivelyDetectedGroups(allGroups)
238 const detectedErr = `
240 Groups depending on "detected" groups cannot be used to select hosts as these
241 are only available after the hosts were contacted.
244 nameMap := make(map[string]bool)
245 for _, x := range names {
247 return nil, fmt.Errorf(
248 "group %q depends on \"detected\" groups%s",
253 nameMatched := make(map[string]bool)
254 // To detect typos we must check all given names but one host can be
255 // matched by multiple names (e.g. two groups with overlapping hosts)
256 hostAdded := make(map[string]bool)
258 var res []*config.Host
259 for _, host := range allHosts.List {
260 if nameMap[host.Name] {
261 res = append(res, host)
262 hostAdded[host.Name] = true
263 nameMatched[host.Name] = true
266 groups, err := config.ResolveHostGroups(host.Name,
271 for _, x := range groups {
273 if !hostAdded[host.Name] {
274 res = append(res, host)
275 hostAdded[host.Name] = true
277 nameMatched[x] = true
282 // Warn about unmatched names to detect typos
283 if len(nameMap) != len(nameMatched) {
284 var unmatched []string
285 for x := range nameMap {
287 unmatched = append(unmatched,
288 fmt.Sprintf("%q", x))
291 sort.Strings(unmatched)
292 return nil, fmt.Errorf("hosts/groups not found: %s",
293 strings.Join(unmatched, " "))
299 func logEvent(x Event, level safcm.LogLevel, isTTY bool, failed *bool) {
300 // We have multiple event sources so this is somewhat ugly.
301 var prefix, data string
305 data = x.Error.Error()
307 // We logged an error, tell the caller
309 } else if x.Log.Level != 0 {
310 // LogError and LogDebug3 should not occur here
314 case safcm.LogVerbose:
318 case safcm.LogDebug2:
321 prefix = fmt.Sprintf("[INVALID=%d]", x.Log.Level)
326 switch x.ConnEvent.Type {
327 case rpc.ConnEventStderr:
329 case rpc.ConnEventDebug:
331 case rpc.ConnEventUpload:
332 if level < safcm.LogInfo {
336 x.ConnEvent.Data = "remote helper upload in progress"
338 prefix = fmt.Sprintf("[INVALID=%d]", x.ConnEvent.Type)
341 data = x.ConnEvent.Data
346 host = ColorString(isTTY, color, host)
348 // Make sure to escape control characters to prevent terminal
351 data = EscapeControlCharacters(isTTY, data)
353 log.Printf("%-9s [%s] %s", prefix, host, data)
356 func (s *Sync) Host(wg *sync.WaitGroup) error {
357 conn := rpc.NewConn(s.config.LogLevel >= safcm.LogDebug3)
358 // Pass all connection events to main loop
361 x, ok := <-conn.Events
373 helpers, err := fs.Sub(RemoteHelpers, "remote")
379 // Connect to remote host
380 user := s.host.SshUser
382 user = s.config.SshUser
384 err = conn.DialSSH(rpc.SSHConfig{
387 SshConfig: s.config.SshConfig,
388 RemoteHelpers: helpers,
396 // Collect information about remote host
397 detectedGroups, err := s.hostInfo(conn)
402 // Sync state to remote host
403 err = s.hostSync(conn, detectedGroups)
408 // Terminate connection to remote host
409 err = conn.Send(safcm.MsgQuitReq{})
425 func (s *Sync) log(level safcm.LogLevel, escaped bool, msg string) {
426 if s.config.LogLevel < level {
438 func (s *Sync) logDebugf(format string, a ...interface{}) {
439 s.log(safcm.LogDebug, false, fmt.Sprintf(format, a...))
441 func (s *Sync) logVerbosef(format string, a ...interface{}) {
442 s.log(safcm.LogVerbose, false, fmt.Sprintf(format, a...))
445 // sendRecv sends a message over conn and waits for the response. Any MsgLog
446 // messages received before the final (non MsgLog) response are passed to
448 func (s *Sync) sendRecv(conn *rpc.Conn, msg safcm.Msg) (safcm.Msg, error) {
449 err := conn.Send(msg)
454 x, err := conn.Recv()
458 log, ok := x.(safcm.MsgLog)
460 s.log(log.Level, false, log.Text)