]> ruderich.org/simon Gitweb - safcm/safcm.git/blob - cmd/safcm/sync.go
safcm: move sync.sendRecv to frontend package
[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         "io/fs"
24         "log"
25         "os"
26         "runtime"
27         "sort"
28         "strings"
29
30         "golang.org/x/term"
31
32         "ruderich.org/simon/safcm"
33         "ruderich.org/simon/safcm/cmd/safcm/config"
34         "ruderich.org/simon/safcm/frontend"
35         "ruderich.org/simon/safcm/rpc"
36 )
37
38 type Sync struct {
39         host *config.Host
40
41         config    *config.Config      // global configuration
42         allHosts  *config.Hosts       // known hosts
43         allGroups map[string][]string // known groups
44
45         isTTY bool
46
47         loop    *frontend.Loop
48         logFunc func(level safcm.LogLevel, escaped bool, msg string)
49 }
50
51 func MainSync(args []string) error {
52         flag.Usage = func() {
53                 fmt.Fprintf(os.Stderr,
54                         "usage: %s sync [<options>] <host|group...>\n",
55                         args[0])
56                 flag.PrintDefaults()
57         }
58
59         optionDryRun := flag.Bool("n", false,
60                 "dry-run, show diff but don't perform any changes")
61         optionQuiet := flag.Bool("q", false,
62                 "hide successful, non-trigger commands with no output from host changes listing")
63         optionLog := flag.String("log", "info", "set log `level`; "+
64                 "levels: error, info, verbose, debug, debug2, debug3")
65         optionSshConfig := flag.String("sshconfig", "",
66                 "`path` to ssh configuration file; used for tests")
67
68         flag.CommandLine.Parse(args[2:])
69
70         level, err := safcm.ParseLogLevel(*optionLog)
71         if err != nil {
72                 return fmt.Errorf("-log: %v", err)
73         }
74
75         names := flag.Args()
76         if len(names) == 0 {
77                 flag.Usage()
78                 os.Exit(1)
79         }
80
81         if runtime.GOOS == "windows" {
82                 log.Print("WARNING: Windows support is experimental!")
83         }
84
85         cfg, allHosts, allGroups, err := LoadBaseFiles()
86         if err != nil {
87                 return err
88         }
89         cfg.DryRun = *optionDryRun
90         cfg.Quiet = *optionQuiet
91         cfg.LogLevel = level
92         cfg.SshConfig = *optionSshConfig
93
94         toSync, err := hostsToSync(names, allHosts, allGroups)
95         if err != nil {
96                 return err
97         }
98         if len(toSync) == 0 {
99                 return fmt.Errorf("no hosts found")
100         }
101
102         isTTY := term.IsTerminal(int(os.Stdout.Fd())) &&
103                 term.IsTerminal(int(os.Stderr.Fd()))
104
105         loop := &frontend.Loop{
106                 DebugConn: cfg.LogLevel >= safcm.LogDebug3,
107                 LogEventFunc: func(x frontend.Event, failed *bool) {
108                         logEvent(x, cfg.LogLevel, isTTY, failed)
109                 },
110                 SyncHostFunc: func(conn *rpc.Conn, host frontend.Host) error {
111                         return host.(*Sync).Host(conn)
112                 },
113         }
114
115         var hosts []frontend.Host
116         for _, x := range toSync {
117                 s := &Sync{
118                         host:      x,
119                         config:    cfg,
120                         allHosts:  allHosts,
121                         allGroups: allGroups,
122                         isTTY:     isTTY,
123                         loop:      loop,
124                 }
125                 s.logFunc = func(level safcm.LogLevel, escaped bool,
126                         msg string) {
127                         s.loop.Log(s, level, escaped, msg)
128                 }
129                 hosts = append(hosts, s)
130         }
131
132         succ := loop.Run(hosts)
133
134         if !succ {
135                 // Exit instead of returning an error to prevent an extra log
136                 // message from main()
137                 os.Exit(1)
138         }
139         return nil
140 }
141
142 // hostsToSync returns the list of hosts to sync based on the command line
143 // arguments.
144 //
145 // Full host and group matches are required to prevent unexpected behavior. No
146 // arguments does not expand to all hosts to prevent accidents; "all" can be
147 // used instead. Both host and group names are permitted as these are unique.
148 //
149 // TODO: Add option to permit partial/glob matches
150 func hostsToSync(names []string, allHosts *config.Hosts,
151         allGroups map[string][]string) ([]*config.Host, error) {
152
153         detectedMap := config.TransitivelyDetectedGroups(allGroups)
154
155         const detectedErr = `
156
157 Groups depending on "detected" groups cannot be used to select hosts as these
158 are only available after the hosts were contacted.
159 `
160
161         nameMap := make(map[string]bool)
162         for _, x := range names {
163                 if detectedMap[x] {
164                         return nil, fmt.Errorf(
165                                 "group %q depends on \"detected\" groups%s",
166                                 x, detectedErr)
167                 }
168                 nameMap[x] = true
169         }
170         nameMatched := make(map[string]bool)
171         // To detect typos we must check all given names but one host can be
172         // matched by multiple names (e.g. two groups with overlapping hosts)
173         hostAdded := make(map[string]bool)
174
175         var res []*config.Host
176         for _, host := range allHosts.List {
177                 if nameMap[host.Name] {
178                         res = append(res, host)
179                         hostAdded[host.Name] = true
180                         nameMatched[host.Name] = true
181                 }
182
183                 groups, err := config.ResolveHostGroups(host.Name,
184                         allGroups, nil)
185                 if err != nil {
186                         return nil, err
187                 }
188                 for _, x := range groups {
189                         if nameMap[x] {
190                                 if !hostAdded[host.Name] {
191                                         res = append(res, host)
192                                         hostAdded[host.Name] = true
193                                 }
194                                 nameMatched[x] = true
195                         }
196                 }
197         }
198
199         // Warn about unmatched names to detect typos
200         if len(nameMap) != len(nameMatched) {
201                 var unmatched []string
202                 for x := range nameMap {
203                         if !nameMatched[x] {
204                                 unmatched = append(unmatched,
205                                         fmt.Sprintf("%q", x))
206                         }
207                 }
208                 sort.Strings(unmatched)
209                 return nil, fmt.Errorf("hosts/groups not found: %s",
210                         strings.Join(unmatched, " "))
211         }
212
213         return res, nil
214 }
215
216 func logEvent(x frontend.Event, level safcm.LogLevel, isTTY bool, failed *bool) {
217         // We have multiple event sources so this is somewhat ugly.
218         var prefix, data string
219         var color frontend.Color
220         if x.Error != nil {
221                 prefix = "[error]"
222                 data = x.Error.Error()
223                 color = frontend.ColorRed
224                 // We logged an error, tell the caller
225                 *failed = true
226         } else if x.Log.Level != 0 {
227                 if level < x.Log.Level {
228                         return
229                 }
230                 // LogError and LogDebug3 should not occur here
231                 switch x.Log.Level {
232                 case safcm.LogInfo:
233                         prefix = "[info]"
234                 case safcm.LogVerbose:
235                         prefix = "[verbose]"
236                 case safcm.LogDebug:
237                         prefix = "[debug]"
238                 case safcm.LogDebug2:
239                         prefix = "[debug2]"
240                 default:
241                         prefix = fmt.Sprintf("[INVALID=%d]", x.Log.Level)
242                         color = frontend.ColorRed
243                 }
244                 data = x.Log.Text
245         } else {
246                 switch x.ConnEvent.Type {
247                 case rpc.ConnEventStderr:
248                         prefix = "[stderr]"
249                 case rpc.ConnEventDebug:
250                         prefix = "[debug3]"
251                 case rpc.ConnEventUpload:
252                         if level < safcm.LogInfo {
253                                 return
254                         }
255                         prefix = "[info]"
256                         x.ConnEvent.Data = "remote helper upload in progress"
257                 default:
258                         prefix = fmt.Sprintf("[INVALID=%d]", x.ConnEvent.Type)
259                         color = frontend.ColorRed
260                 }
261                 data = x.ConnEvent.Data
262         }
263
264         host := x.Host.Name()
265         if color != 0 {
266                 host = frontend.ColorString(isTTY, color, host)
267         }
268         // Make sure to escape control characters to prevent terminal
269         // injection attacks
270         if !x.Escaped {
271                 data = frontend.EscapeControlCharacters(isTTY, data)
272         }
273         log.Printf("%-9s [%s] %s", prefix, host, data)
274 }
275
276 func (s *Sync) Name() string {
277         return s.host.Name
278 }
279
280 func (s *Sync) Dial(conn *rpc.Conn) error {
281         helpers, err := fs.Sub(RemoteHelpers, "remote")
282         if err != nil {
283                 return err
284         }
285
286         // Connect to remote host
287         user := s.host.SshUser
288         if user == "" {
289                 user = s.config.SshUser
290         }
291         return conn.DialSSH(rpc.SSHConfig{
292                 Host:          s.host.Name,
293                 User:          user,
294                 SshConfig:     s.config.SshConfig,
295                 RemoteHelpers: helpers,
296         })
297 }
298
299 func (s *Sync) Host(conn *rpc.Conn) error {
300         // Collect information about remote host
301         detectedGroups, err := s.hostInfo(conn)
302         if err != nil {
303                 return err
304         }
305
306         // Sync state to remote host
307         err = s.hostSync(conn, detectedGroups)
308         if err != nil {
309                 return err
310         }
311
312         return nil
313 }
314
315 func (s *Sync) log(level safcm.LogLevel, escaped bool, msg string) {
316         s.logFunc(level, escaped, msg)
317 }
318 func (s *Sync) logDebugf(format string, a ...interface{}) {
319         s.log(safcm.LogDebug, false, fmt.Sprintf(format, a...))
320 }
321 func (s *Sync) logVerbosef(format string, a ...interface{}) {
322         s.log(safcm.LogVerbose, false, fmt.Sprintf(format, a...))
323 }