1 // "sync" sub-command: sync data to remote hosts
3 // Copyright (C) 2021-2022 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
48 logFunc func(level safcm.LogLevel, escaped bool, msg string)
51 func MainSync(args []string) error {
53 fmt.Fprintf(os.Stderr,
54 "usage: %s sync [<options>] <host|group...>\n",
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")
68 flag.CommandLine.Parse(args[2:]) //nolint:errcheck
70 level, err := safcm.ParseLogLevel(*optionLog)
72 return fmt.Errorf("-log: %v", err)
81 if runtime.GOOS == "windows" {
82 log.Print("WARNING: Windows support is experimental!")
85 cfg, allHosts, allGroups, err := LoadBaseFiles()
89 cfg.DryRun = *optionDryRun
90 cfg.Quiet = *optionQuiet
92 cfg.SshConfig = *optionSshConfig
94 toSync, err := hostsToSync(names, allHosts, allGroups)
99 return fmt.Errorf("no hosts found")
102 isTTY := term.IsTerminal(int(os.Stdout.Fd())) &&
103 term.IsTerminal(int(os.Stderr.Fd()))
105 loop := &frontend.Loop{
106 DebugConn: cfg.LogLevel >= safcm.LogDebug3,
107 LogEventFunc: func(x frontend.Event, failed *bool) {
108 frontend.LogEvent(x, cfg.LogLevel, isTTY, failed)
110 SyncHostFunc: func(conn *rpc.Conn, host frontend.Host) error {
111 return host.(*Sync).Host(conn)
115 var hosts []frontend.Host
116 for _, x := range toSync {
121 allGroups: allGroups,
125 s.logFunc = func(level safcm.LogLevel, escaped bool,
127 s.loop.Log(s, level, escaped, msg)
129 hosts = append(hosts, s)
132 succ := loop.Run(hosts)
135 // Exit instead of returning an error to prevent an extra log
136 // message from main()
142 // hostsToSync returns the list of hosts to sync based on the command line
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.
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) {
153 detectedMap := config.TransitivelyDetectedGroups(allGroups)
155 const detectedErr = `
157 Groups depending on "detected" groups cannot be used to select hosts as these
158 are only available after the hosts were contacted.
161 nameMap := make(map[string]bool)
162 for _, x := range names {
164 return nil, fmt.Errorf(
165 "group %q depends on \"detected\" groups%s",
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)
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
183 groups, err := config.ResolveHostGroups(host.Name,
188 for _, x := range groups {
190 if !hostAdded[host.Name] {
191 res = append(res, host)
192 hostAdded[host.Name] = true
194 nameMatched[x] = true
199 // Warn about unmatched names to detect typos
200 if len(nameMap) != len(nameMatched) {
201 var unmatched []string
202 for x := range nameMap {
204 unmatched = append(unmatched,
205 fmt.Sprintf("%q", x))
208 sort.Strings(unmatched)
209 return nil, fmt.Errorf("hosts/groups not found: %s",
210 strings.Join(unmatched, " "))
216 func (s *Sync) Name() string {
220 func (s *Sync) Dial(conn *rpc.Conn) error {
221 helpers, err := fs.Sub(RemoteHelpers, "remote")
226 // Connect to remote host
227 user := s.host.SshUser
229 user = s.config.SshUser
231 return conn.DialSSH(rpc.SSHConfig{
234 SshConfig: s.config.SshConfig,
235 RemoteHelpers: helpers,
239 func (s *Sync) Host(conn *rpc.Conn) error {
240 // Collect information about remote host
241 detectedGroups, err := s.hostInfo(conn)
246 // Sync state to remote host
247 err = s.hostSync(conn, detectedGroups)
255 func (s *Sync) log(level safcm.LogLevel, escaped bool, msg string) {
256 s.logFunc(level, escaped, msg)
258 func (s *Sync) logDebugf(format string, a ...interface{}) {
259 s.log(safcm.LogDebug, false, fmt.Sprintf(format, a...))
261 func (s *Sync) logVerbosef(format string, a ...interface{}) {
262 s.log(safcm.LogVerbose, false, fmt.Sprintf(format, a...))