]> ruderich.org/simon Gitweb - safcm/safcm.git/blob - cmd/safcm/sync.go
safcm: add -q (quiet) command line option
[safcm/safcm.git] / cmd / safcm / sync.go
1 // "sync" sub-command: sync data to remote hosts
2
3 // Copyright (C) 2021  Simon Ruderich
4 //
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.
9 //
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.
14 //
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/>.
17
18 package main
19
20 import (
21         "flag"
22         "fmt"
23         "log"
24         "os"
25         "sort"
26         "strings"
27         "sync"
28
29         "golang.org/x/term"
30
31         "ruderich.org/simon/safcm"
32         "ruderich.org/simon/safcm/cmd/safcm/config"
33         "ruderich.org/simon/safcm/rpc"
34 )
35
36 type Sync struct {
37         host *config.Host
38
39         config    *config.Config      // global configuration
40         allHosts  *config.Hosts       // known hosts
41         allGroups map[string][]string // known groups
42
43         events chan<- Event // all events generated by/for this host
44
45         isTTY bool
46 }
47
48 type Event struct {
49         Host *config.Host
50
51         // Only one of Error, Log and ConnEvent is set in a single event
52         Error     error
53         Log       Log
54         ConnEvent rpc.ConnEvent
55
56         Escaped bool // true if untrusted input is already escaped
57 }
58
59 type Log struct {
60         Level safcm.LogLevel
61         Text  string
62 }
63
64 func MainSync(args []string) error {
65         flag.Usage = func() {
66                 fmt.Fprintf(os.Stderr,
67                         "usage: %s sync [<options>] <host|group...>\n",
68                         args[0])
69                 flag.PrintDefaults()
70         }
71
72         optionDryRun := flag.Bool("n", false,
73                 "dry-run, show diff but don't perform any changes")
74         optionQuiet := flag.Bool("q", false,
75                 "hide successful, non-trigger commands with no output from host changes listing")
76         optionLog := flag.String("log", "info", "set log `level`; "+
77                 "levels: error, info, verbose, debug, debug2, debug3")
78
79         flag.CommandLine.Parse(args[2:])
80
81         var level safcm.LogLevel
82         switch *optionLog {
83         case "error":
84                 level = safcm.LogError
85         case "info":
86                 level = safcm.LogInfo
87         case "verbose":
88                 level = safcm.LogVerbose
89         case "debug":
90                 level = safcm.LogDebug
91         case "debug2":
92                 level = safcm.LogDebug2
93         case "debug3":
94                 level = safcm.LogDebug3
95         default:
96                 return fmt.Errorf("invalid -log value %q", *optionLog)
97         }
98
99         names := flag.Args()
100         if len(names) == 0 {
101                 flag.Usage()
102                 os.Exit(1)
103         }
104
105         cfg, allHosts, allGroups, err := LoadBaseFiles()
106         if err != nil {
107                 return err
108         }
109         cfg.DryRun = *optionDryRun
110         cfg.Quiet = *optionQuiet
111         cfg.LogLevel = level
112
113         toSync, err := hostsToSync(names, allHosts, allGroups)
114         if err != nil {
115                 return err
116         }
117         if len(toSync) == 0 {
118                 return fmt.Errorf("no hosts found")
119         }
120
121         isTTY := term.IsTerminal(int(os.Stdout.Fd()))
122
123         done := make(chan bool)
124         // Collect events from all hosts and print them
125         events := make(chan Event)
126         go func() {
127                 var failed bool
128                 for {
129                         x := <-events
130                         if x.Host == nil {
131                                 break
132                         }
133                         logEvent(x, cfg.LogLevel, isTTY, &failed)
134                 }
135                 done <- failed
136         }()
137
138         // Sync all hosts concurrently
139         var wg sync.WaitGroup
140         for _, x := range toSync {
141                 x := x
142
143                 // Once in sync.Host() and once in the go func below
144                 wg.Add(2)
145
146                 go func() {
147                         sync := Sync{
148                                 host:      x,
149                                 config:    cfg,
150                                 allHosts:  allHosts,
151                                 allGroups: allGroups,
152                                 events:    events,
153                                 isTTY:     isTTY,
154                         }
155                         err := sync.Host(&wg)
156                         if err != nil {
157                                 events <- Event{
158                                         Host:  x,
159                                         Error: err,
160                                 }
161                         }
162                         wg.Done()
163                 }()
164         }
165
166         wg.Wait()
167         events <- Event{} // poison pill
168         failed := <-done
169
170         if failed {
171                 // Exit instead of returning an error to prevent an extra log
172                 // message from main()
173                 os.Exit(1)
174         }
175         return nil
176 }
177
178 // hostsToSync returns the list of hosts to sync based on the command line
179 // arguments.
180 //
181 // Full host and group matches are required to prevent unexpected behavior. No
182 // arguments does not expand to all hosts to prevent accidents; "all" can be
183 // used instead. Both host and group names are permitted as these are unique.
184 //
185 // TODO: Add option to permit partial/glob matches
186 func hostsToSync(names []string, allHosts *config.Hosts,
187         allGroups map[string][]string) ([]*config.Host, error) {
188
189         nameMap := make(map[string]bool)
190         for _, x := range names {
191                 nameMap[x] = true
192         }
193         nameMatched := make(map[string]bool)
194         // To detect typos we must check all given names but only want to add
195         // each match once
196         hostMatched := make(map[string]bool)
197
198         var res []*config.Host
199         for _, host := range allHosts.List {
200                 if nameMap[host.Name] {
201                         res = append(res, host)
202                         hostMatched[host.Name] = true
203                         nameMatched[host.Name] = true
204                 }
205
206                 // TODO: don't permit groups which contain "detected" groups
207                 // because these are not available yet
208                 groups, err := config.ResolveHostGroups(host.Name,
209                         allGroups, nil)
210                 if err != nil {
211                         return nil, err
212                 }
213                 for _, x := range groups {
214                         if nameMap[x] {
215                                 if !hostMatched[host.Name] {
216                                         res = append(res, host)
217                                         hostMatched[host.Name] = true
218                                 }
219                                 nameMatched[x] = true
220                         }
221                 }
222         }
223
224         // Warn about unmatched names to detect typos
225         if len(nameMap) != len(nameMatched) {
226                 var unmatched []string
227                 for x := range nameMap {
228                         if !nameMatched[x] {
229                                 unmatched = append(unmatched,
230                                         fmt.Sprintf("%q", x))
231                         }
232                 }
233                 sort.Strings(unmatched)
234                 return nil, fmt.Errorf("hosts/groups not found: %s",
235                         strings.Join(unmatched, " "))
236         }
237
238         return res, nil
239 }
240
241 func logEvent(x Event, level safcm.LogLevel, isTTY bool, failed *bool) {
242         // We have multiple event sources so this is somewhat ugly.
243         var prefix, data string
244         var color Color
245         if x.Error != nil {
246                 prefix = "[error]"
247                 data = x.Error.Error()
248                 color = ColorRed
249                 // We logged an error, tell the caller
250                 *failed = true
251         } else if x.Log.Level != 0 {
252                 // LogError and LogDebug3 should not occur here
253                 switch x.Log.Level {
254                 case safcm.LogInfo:
255                         prefix = "[info]"
256                 case safcm.LogVerbose:
257                         prefix = "[verbose]"
258                 case safcm.LogDebug:
259                         prefix = "[debug]"
260                 case safcm.LogDebug2:
261                         prefix = "[debug2]"
262                 default:
263                         prefix = fmt.Sprintf("[INVALID=%d]", x.Log.Level)
264                         color = ColorRed
265                 }
266                 data = x.Log.Text
267         } else {
268                 switch x.ConnEvent.Type {
269                 case rpc.ConnEventStderr:
270                         prefix = "[stderr]"
271                 case rpc.ConnEventDebug:
272                         prefix = "[debug3]"
273                 case rpc.ConnEventUpload:
274                         if level < safcm.LogInfo {
275                                 return
276                         }
277                         prefix = "[info]"
278                         x.ConnEvent.Data = "remote helper upload in progress"
279                 default:
280                         prefix = fmt.Sprintf("[INVALID=%d]", x.ConnEvent.Type)
281                         color = ColorRed
282                 }
283                 data = x.ConnEvent.Data
284         }
285
286         host := x.Host.Name
287         if color != 0 {
288                 host = ColorString(isTTY, color, host)
289         }
290         // Make sure to escape control characters to prevent terminal
291         // injection attacks
292         if !x.Escaped {
293                 data = EscapeControlCharacters(isTTY, data)
294         }
295         log.Printf("%-9s [%s] %s", prefix, host, data)
296 }
297
298 func (s *Sync) Host(wg *sync.WaitGroup) error {
299         conn := rpc.NewConn(s.config.LogLevel >= safcm.LogDebug3)
300         // Pass all connection events to main loop
301         go func() {
302                 for {
303                         x, ok := <-conn.Events
304                         if !ok {
305                                 break
306                         }
307                         s.events <- Event{
308                                 Host:      s.host,
309                                 ConnEvent: x,
310                         }
311                 }
312                 wg.Done()
313         }()
314
315         // Connect to remote host
316         err := conn.DialSSH(s.host.SshUser, s.host.Name)
317         if err != nil {
318                 return err
319         }
320         defer conn.Kill()
321
322         // Collect information about remote host
323         detectedGroups, err := s.hostInfo(conn)
324         if err != nil {
325                 return err
326         }
327
328         // Sync state to remote host
329         err = s.hostSync(conn, detectedGroups)
330         if err != nil {
331                 return err
332         }
333
334         // Terminate connection to remote host
335         err = conn.Send(safcm.MsgQuitReq{})
336         if err != nil {
337                 return err
338         }
339         _, err = conn.Recv()
340         if err != nil {
341                 return err
342         }
343         err = conn.Wait()
344         if err != nil {
345                 return err
346         }
347
348         return nil
349 }
350
351 func (s *Sync) logf(level safcm.LogLevel, escaped bool,
352         format string, a ...interface{}) {
353
354         if s.config.LogLevel < level {
355                 return
356         }
357         s.events <- Event{
358                 Host: s.host,
359                 Log: Log{
360                         Level: level,
361                         Text:  fmt.Sprintf(format, a...),
362                 },
363                 Escaped: escaped,
364         }
365 }
366 func (s *Sync) logDebugf(format string, a ...interface{}) {
367         s.logf(safcm.LogDebug, false, format, a...)
368 }
369 func (s *Sync) logVerbosef(format string, a ...interface{}) {
370         s.logf(safcm.LogVerbose, false, format, a...)
371 }
372
373 // sendRecv sends a message over conn and waits for the response. Any MsgLog
374 // messages received before the final (non MsgLog) response are passed to
375 // s.log.
376 func (s *Sync) sendRecv(conn *rpc.Conn, msg safcm.Msg) (safcm.Msg, error) {
377         err := conn.Send(msg)
378         if err != nil {
379                 return nil, err
380         }
381         for {
382                 x, err := conn.Recv()
383                 if err != nil {
384                         return nil, err
385                 }
386                 log, ok := x.(safcm.MsgLog)
387                 if ok {
388                         s.logf(log.Level, false, "%s", log.Text)
389                         continue
390                 }
391                 return x, nil
392         }
393 }