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/>.
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"
41 config *config.Config // global configuration
42 allHosts *config.Hosts // known hosts
43 allGroups map[string][]string // known groups
47 logFunc func(level safcm.LogLevel, escaped bool, msg string)
50 func MainSync(args []string) error {
52 fmt.Fprintf(os.Stderr,
53 "usage: %s sync [<options>] <host|group...>\n",
58 optionDryRun := flag.Bool("n", false,
59 "dry-run, show diff but don't perform any changes")
60 optionQuiet := flag.Bool("q", false,
61 "hide successful, non-trigger commands with no output from host changes listing")
62 optionLog := flag.String("log", "info", "set log `level`; "+
63 "levels: error, info, verbose, debug, debug2, debug3")
64 optionSshConfig := flag.String("sshconfig", "",
65 "`path` to ssh configuration file; used for tests")
67 flag.CommandLine.Parse(args[2:])
69 var level safcm.LogLevel
72 level = safcm.LogError
76 level = safcm.LogVerbose
78 level = safcm.LogDebug
80 level = safcm.LogDebug2
82 level = safcm.LogDebug3
84 return fmt.Errorf("invalid -log value %q", *optionLog)
93 if runtime.GOOS == "windows" {
94 log.Print("WARNING: Windows support is experimental!")
97 cfg, allHosts, allGroups, err := LoadBaseFiles()
101 cfg.DryRun = *optionDryRun
102 cfg.Quiet = *optionQuiet
104 cfg.SshConfig = *optionSshConfig
106 toSync, err := hostsToSync(names, allHosts, allGroups)
110 if len(toSync) == 0 {
111 return fmt.Errorf("no hosts found")
114 isTTY := term.IsTerminal(int(os.Stdout.Fd())) &&
115 term.IsTerminal(int(os.Stderr.Fd()))
117 loop := &frontend.Loop{
118 DebugConn: cfg.LogLevel >= safcm.LogDebug3,
119 LogEventFunc: func(x frontend.Event, failed *bool) {
120 logEvent(x, cfg.LogLevel, isTTY, failed)
122 SyncHostFunc: func(conn *rpc.Conn, host frontend.Host) error {
123 return host.(*Sync).Host(conn)
127 var hosts []frontend.Host
128 for _, x := range toSync {
133 allGroups: allGroups,
136 s.logFunc = func(level safcm.LogLevel, escaped bool,
138 loop.Log(s, level, escaped, msg)
140 hosts = append(hosts, s)
143 succ := loop.Run(hosts)
146 // Exit instead of returning an error to prevent an extra log
147 // message from main()
153 // hostsToSync returns the list of hosts to sync based on the command line
156 // Full host and group matches are required to prevent unexpected behavior. No
157 // arguments does not expand to all hosts to prevent accidents; "all" can be
158 // used instead. Both host and group names are permitted as these are unique.
160 // TODO: Add option to permit partial/glob matches
161 func hostsToSync(names []string, allHosts *config.Hosts,
162 allGroups map[string][]string) ([]*config.Host, error) {
164 detectedMap := config.TransitivelyDetectedGroups(allGroups)
166 const detectedErr = `
168 Groups depending on "detected" groups cannot be used to select hosts as these
169 are only available after the hosts were contacted.
172 nameMap := make(map[string]bool)
173 for _, x := range names {
175 return nil, fmt.Errorf(
176 "group %q depends on \"detected\" groups%s",
181 nameMatched := make(map[string]bool)
182 // To detect typos we must check all given names but one host can be
183 // matched by multiple names (e.g. two groups with overlapping hosts)
184 hostAdded := make(map[string]bool)
186 var res []*config.Host
187 for _, host := range allHosts.List {
188 if nameMap[host.Name] {
189 res = append(res, host)
190 hostAdded[host.Name] = true
191 nameMatched[host.Name] = true
194 groups, err := config.ResolveHostGroups(host.Name,
199 for _, x := range groups {
201 if !hostAdded[host.Name] {
202 res = append(res, host)
203 hostAdded[host.Name] = true
205 nameMatched[x] = true
210 // Warn about unmatched names to detect typos
211 if len(nameMap) != len(nameMatched) {
212 var unmatched []string
213 for x := range nameMap {
215 unmatched = append(unmatched,
216 fmt.Sprintf("%q", x))
219 sort.Strings(unmatched)
220 return nil, fmt.Errorf("hosts/groups not found: %s",
221 strings.Join(unmatched, " "))
227 func logEvent(x frontend.Event, level safcm.LogLevel, isTTY bool, failed *bool) {
228 // We have multiple event sources so this is somewhat ugly.
229 var prefix, data string
233 data = x.Error.Error()
235 // We logged an error, tell the caller
237 } else if x.Log.Level != 0 {
238 if level < x.Log.Level {
241 // LogError and LogDebug3 should not occur here
245 case safcm.LogVerbose:
249 case safcm.LogDebug2:
252 prefix = fmt.Sprintf("[INVALID=%d]", x.Log.Level)
257 switch x.ConnEvent.Type {
258 case rpc.ConnEventStderr:
260 case rpc.ConnEventDebug:
262 case rpc.ConnEventUpload:
263 if level < safcm.LogInfo {
267 x.ConnEvent.Data = "remote helper upload in progress"
269 prefix = fmt.Sprintf("[INVALID=%d]", x.ConnEvent.Type)
272 data = x.ConnEvent.Data
275 host := x.Host.Name()
277 host = ColorString(isTTY, color, host)
279 // Make sure to escape control characters to prevent terminal
282 data = EscapeControlCharacters(isTTY, data)
284 log.Printf("%-9s [%s] %s", prefix, host, data)
287 func (s *Sync) Name() string {
291 func (s *Sync) Dial(conn *rpc.Conn) error {
292 helpers, err := fs.Sub(RemoteHelpers, "remote")
297 // Connect to remote host
298 user := s.host.SshUser
300 user = s.config.SshUser
302 return conn.DialSSH(rpc.SSHConfig{
305 SshConfig: s.config.SshConfig,
306 RemoteHelpers: helpers,
310 func (s *Sync) Host(conn *rpc.Conn) error {
311 // Collect information about remote host
312 detectedGroups, err := s.hostInfo(conn)
317 // Sync state to remote host
318 err = s.hostSync(conn, detectedGroups)
326 func (s *Sync) log(level safcm.LogLevel, escaped bool, msg string) {
327 s.logFunc(level, escaped, msg)
329 func (s *Sync) logDebugf(format string, a ...interface{}) {
330 s.log(safcm.LogDebug, false, fmt.Sprintf(format, a...))
332 func (s *Sync) logVerbosef(format string, a ...interface{}) {
333 s.log(safcm.LogVerbose, false, fmt.Sprintf(format, a...))
336 // sendRecv sends a message over conn and waits for the response. Any MsgLog
337 // messages received before the final (non MsgLog) response are passed to
339 func (s *Sync) sendRecv(conn *rpc.Conn, msg safcm.Msg) (safcm.Msg, error) {
340 err := conn.Send(msg)
345 x, err := conn.Recv()
349 log, ok := x.(safcm.MsgLog)
351 s.log(log.Level, false, log.Text)