]> ruderich.org/simon Gitweb - safcm/safcm.git/blob - cmd/safcm/sync.go
Use SPDX license identifiers
[safcm/safcm.git] / cmd / safcm / sync.go
1 // "sync" sub-command: sync data to remote hosts
2
3 // SPDX-License-Identifier: GPL-3.0-or-later
4 // Copyright (C) 2021-2024  Simon Ruderich
5
6 package main
7
8 import (
9         "flag"
10         "fmt"
11         "io/fs"
12         "log"
13         "os"
14         "runtime"
15         "sort"
16         "strings"
17
18         "golang.org/x/term"
19
20         "ruderich.org/simon/safcm"
21         "ruderich.org/simon/safcm/cmd/safcm/config"
22         "ruderich.org/simon/safcm/frontend"
23         "ruderich.org/simon/safcm/rpc"
24 )
25
26 type Sync struct {
27         host *config.Host
28
29         config    *config.Config      // global configuration
30         allHosts  *config.Hosts       // known hosts
31         allGroups map[string][]string // known groups
32
33         isTTY bool
34
35         loop    *frontend.Loop
36         logFunc func(level safcm.LogLevel, escaped bool, msg string)
37 }
38
39 func MainSync(args []string) error {
40         flag.Usage = func() {
41                 fmt.Fprintf(os.Stderr,
42                         "usage: %s sync [<options>] <host|group...>\n",
43                         args[0])
44                 flag.PrintDefaults()
45         }
46
47         optionDryRun := flag.Bool("n", false,
48                 "dry-run, show diff but don't perform any changes")
49         optionQuiet := flag.Bool("q", false,
50                 "hide successful, non-trigger commands with no output from host changes listing")
51         optionLog := flag.String("log", "info", "set log `level`; "+
52                 "levels: error, info, verbose, debug, debug2, debug3")
53         optionSshConfig := flag.String("sshconfig", "",
54                 "`path` to ssh configuration file; used for tests")
55
56         flag.CommandLine.Parse(args[2:]) //nolint:errcheck
57
58         level, err := safcm.ParseLogLevel(*optionLog)
59         if err != nil {
60                 return fmt.Errorf("-log: %v", err)
61         }
62
63         names := flag.Args()
64         if len(names) == 0 {
65                 flag.Usage()
66                 os.Exit(1)
67         }
68
69         if runtime.GOOS == "windows" {
70                 log.Print("WARNING: Windows support is experimental!")
71         }
72
73         cfg, allHosts, allGroups, err := LoadBaseFiles()
74         if err != nil {
75                 return err
76         }
77         cfg.DryRun = *optionDryRun
78         cfg.Quiet = *optionQuiet
79         cfg.LogLevel = level
80         cfg.SshConfig = *optionSshConfig
81
82         toSync, err := hostsToSync(names, allHosts, allGroups)
83         if err != nil {
84                 return err
85         }
86         if len(toSync) == 0 {
87                 return fmt.Errorf("no hosts found")
88         }
89
90         isTTY := term.IsTerminal(int(os.Stdout.Fd())) &&
91                 term.IsTerminal(int(os.Stderr.Fd()))
92
93         loop := &frontend.Loop{
94                 DebugConn: cfg.LogLevel >= safcm.LogDebug3,
95                 LogEventFunc: func(x frontend.Event, failed *bool) {
96                         frontend.LogEvent(x, cfg.LogLevel, isTTY, failed)
97                 },
98                 SyncHostFunc: func(conn *rpc.Conn, host frontend.Host) error {
99                         return host.(*Sync).Host(conn)
100                 },
101         }
102
103         var hosts []frontend.Host
104         for _, x := range toSync {
105                 s := &Sync{
106                         host:      x,
107                         config:    cfg,
108                         allHosts:  allHosts,
109                         allGroups: allGroups,
110                         isTTY:     isTTY,
111                         loop:      loop,
112                 }
113                 s.logFunc = func(level safcm.LogLevel, escaped bool,
114                         msg string) {
115                         s.loop.Log(s, level, escaped, msg)
116                 }
117                 hosts = append(hosts, s)
118         }
119
120         succ := loop.Run(hosts)
121
122         if !succ {
123                 // Exit instead of returning an error to prevent an extra log
124                 // message from main()
125                 os.Exit(1)
126         }
127         return nil
128 }
129
130 // hostsToSync returns the list of hosts to sync based on the command line
131 // arguments.
132 //
133 // Full host and group matches are required to prevent unexpected behavior. No
134 // arguments does not expand to all hosts to prevent accidents; "all" can be
135 // used instead. Both host and group names are permitted as these are unique.
136 //
137 // TODO: Add option to permit partial/glob matches
138 func hostsToSync(names []string, allHosts *config.Hosts,
139         allGroups map[string][]string) ([]*config.Host, error) {
140
141         detectedMap := config.TransitivelyDetectedGroups(allGroups)
142
143         const detectedErr = `
144
145 Groups depending on "detected" groups cannot be used to select hosts as these
146 are only available after the hosts were contacted.
147 `
148
149         nameMap := make(map[string]bool)
150         for _, x := range names {
151                 if detectedMap[x] {
152                         return nil, fmt.Errorf(
153                                 "group %q depends on \"detected\" groups%s",
154                                 x, detectedErr)
155                 }
156                 nameMap[x] = true
157         }
158         nameMatched := make(map[string]bool)
159         // To detect typos we must check all given names but one host can be
160         // matched by multiple names (e.g. two groups with overlapping hosts)
161         hostAdded := make(map[string]bool)
162
163         var res []*config.Host
164         for _, host := range allHosts.List {
165                 if nameMap[host.Name] {
166                         res = append(res, host)
167                         hostAdded[host.Name] = true
168                         nameMatched[host.Name] = true
169                 }
170
171                 groups, err := config.ResolveHostGroups(host.Name,
172                         allGroups, nil)
173                 if err != nil {
174                         return nil, err
175                 }
176                 for _, x := range groups {
177                         if nameMap[x] {
178                                 if !hostAdded[host.Name] {
179                                         res = append(res, host)
180                                         hostAdded[host.Name] = true
181                                 }
182                                 nameMatched[x] = true
183                         }
184                 }
185         }
186
187         // Warn about unmatched names to detect typos
188         if len(nameMap) != len(nameMatched) {
189                 var unmatched []string
190                 for x := range nameMap {
191                         if !nameMatched[x] {
192                                 unmatched = append(unmatched,
193                                         fmt.Sprintf("%q", x))
194                         }
195                 }
196                 sort.Strings(unmatched)
197                 return nil, fmt.Errorf("hosts/groups not found: %s",
198                         strings.Join(unmatched, " "))
199         }
200
201         return res, nil
202 }
203
204 func (s *Sync) Name() string {
205         return s.host.Name
206 }
207
208 func (s *Sync) Dial(conn *rpc.Conn) error {
209         helpers, err := fs.Sub(RemoteHelpers, "remote")
210         if err != nil {
211                 return err
212         }
213
214         // Connect to remote host
215         user := s.host.SshUser
216         if user == "" {
217                 user = s.config.SshUser
218         }
219         return conn.DialSSH(rpc.SSHConfig{
220                 Host:          s.host.Name,
221                 User:          user,
222                 SshConfig:     s.config.SshConfig,
223                 RemoteHelpers: helpers,
224         })
225 }
226
227 func (s *Sync) Host(conn *rpc.Conn) error {
228         // Collect information about remote host
229         detectedGroups, err := s.hostInfo(conn)
230         if err != nil {
231                 return err
232         }
233
234         // Sync state to remote host
235         err = s.hostSync(conn, detectedGroups)
236         if err != nil {
237                 return err
238         }
239
240         return nil
241 }
242
243 func (s *Sync) log(level safcm.LogLevel, escaped bool, msg string) {
244         s.logFunc(level, escaped, msg)
245 }
246 func (s *Sync) logDebugf(format string, a ...interface{}) {
247         s.log(safcm.LogDebug, false, fmt.Sprintf(format, a...))
248 }
249 func (s *Sync) logVerbosef(format string, a ...interface{}) {
250         s.log(safcm.LogVerbose, false, fmt.Sprintf(format, a...))
251 }