]> ruderich.org/simon Gitweb - safcm/safcm.git/blob - cmd/safcm/sync_changes.go
safcm: add ParseLogLevel()
[safcm/safcm.git] / cmd / safcm / sync_changes.go
1 // "sync" sub-command: format changes
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         "fmt"
22         "io/fs"
23         "strings"
24
25         "ruderich.org/simon/safcm"
26         "ruderich.org/simon/safcm/cmd/safcm/config"
27 )
28
29 // NOTE: Be careful when implementing new format* functions. All input from
30 // the remote helper is untrusted and must be either escaped with %q or by
31 // calling EscapeControlCharacters().
32
33 func (s *Sync) formatChanges(resp safcm.MsgSyncResp) string {
34         var changes []string
35         if len(resp.FileChanges) > 0 {
36                 changes = append(changes,
37                         s.formatFileChanges(resp.FileChanges))
38         }
39         if len(resp.PackageChanges) > 0 {
40                 changes = append(changes,
41                         s.formatPackageChanges(resp.PackageChanges))
42         }
43         if len(resp.ServiceChanges) > 0 {
44                 changes = append(changes,
45                         s.formatServiceChanges(resp.ServiceChanges))
46         }
47         if len(resp.CommandChanges) > 0 {
48                 changes = append(changes,
49                         s.formatCommandChanges(resp.CommandChanges))
50         }
51         if len(changes) == 0 {
52                 // Notify user that the host was synced successfully
53                 return "no changes"
54         }
55
56         x := strings.Join(changes, "\n")
57         // If quiet is used and only commands without output were executed
58         // then don't prepend a newline so that the whole change output of a
59         // host fits in a single line. This makes the output much more
60         // readable with multiple hosts.
61         if strings.Count(x, "\n") == 1 {
62                 return x
63         }
64         return "\n" + x
65 }
66
67 func (s *Sync) formatFileChanges(changes []safcm.FileChange) string {
68         var buf strings.Builder
69         if s.config.DryRun {
70                 fmt.Fprintf(&buf, "will change %d file(s): (dry-run)\n",
71                         len(changes))
72         } else {
73                 fmt.Fprintf(&buf, "changed %d file(s):\n", len(changes))
74         }
75         for _, x := range changes {
76                 fmt.Fprintf(&buf, "%s:", s.formatTarget(x.Path))
77
78                 var info []string
79                 if x.Created {
80                         info = append(info,
81                                 ColorString(s.isTTY, ColorGreen, "created"),
82                                 formatFileType(x.New),
83                                 formatFileUserGroup(x.New),
84                                 formatFilePerm(x.New),
85                         )
86                 } else {
87                         if x.Old.Mode.Type() != x.New.Mode.Type() {
88                                 info = append(info, fmt.Sprintf("%s -> %s",
89                                         formatFileType(x.Old),
90                                         formatFileType(x.New),
91                                 ))
92                         }
93                         if x.Old.User != x.New.User ||
94                                 x.Old.Uid != x.New.Uid ||
95                                 x.Old.Group != x.New.Group ||
96                                 x.Old.Gid != x.New.Gid {
97                                 info = append(info, fmt.Sprintf("%s -> %s",
98                                         formatFileUserGroup(x.Old),
99                                         formatFileUserGroup(x.New),
100                                 ))
101                         }
102                         if config.FileModeToFullPerm(x.Old.Mode) !=
103                                 config.FileModeToFullPerm(x.New.Mode) {
104                                 info = append(info, fmt.Sprintf("%s -> %s",
105                                         formatFilePerm(x.Old),
106                                         formatFilePerm(x.New),
107                                 ))
108                         }
109                 }
110                 if len(info) > 0 {
111                         fmt.Fprint(&buf, " ")
112                         fmt.Fprint(&buf, strings.Join(info, ", "))
113                 }
114
115                 if x.DataDiff != "" {
116                         fmt.Fprintf(&buf, "\n%s", s.formatDiff(x.DataDiff))
117                 }
118                 fmt.Fprintf(&buf, "\n")
119         }
120         return buf.String()
121 }
122 func formatFileType(info safcm.FileChangeInfo) string {
123         switch info.Mode.Type() {
124         case 0: // regular file
125                 return "file"
126         case fs.ModeSymlink:
127                 return "symlink"
128         case fs.ModeDir:
129                 return "dir"
130         default:
131                 return fmt.Sprintf("invalid type %v", info.Mode.Type())
132         }
133 }
134 func formatFileUserGroup(info safcm.FileChangeInfo) string {
135         return fmt.Sprintf("%s(%d) %s(%d)",
136                 EscapeControlCharacters(false, info.User), info.Uid,
137                 EscapeControlCharacters(false, info.Group), info.Gid)
138 }
139 func formatFilePerm(info safcm.FileChangeInfo) string {
140         return fmt.Sprintf("%#o", config.FileModeToFullPerm(info.Mode))
141 }
142
143 func (s *Sync) formatPackageChanges(changes []safcm.PackageChange) string {
144         var buf strings.Builder
145         if s.config.DryRun {
146                 fmt.Fprintf(&buf, "will install %d package(s): (dry-run)\n",
147                         len(changes))
148         } else {
149                 fmt.Fprintf(&buf, "installed %d package(s):\n", len(changes))
150         }
151         for _, x := range changes {
152                 // TODO: indicate if installation failed
153                 fmt.Fprintf(&buf, "%s\n", s.formatTarget(x.Name))
154         }
155         return buf.String()
156 }
157
158 func (s *Sync) formatServiceChanges(changes []safcm.ServiceChange) string {
159         var buf strings.Builder
160         if s.config.DryRun {
161                 fmt.Fprintf(&buf, "will modify %d service(s): (dry-run)\n",
162                         len(changes))
163         } else {
164                 fmt.Fprintf(&buf, "modified %d service(s):\n", len(changes))
165         }
166         for _, x := range changes {
167                 var info []string
168                 if x.Started {
169                         info = append(info, "started")
170                 }
171                 if x.Enabled {
172                         info = append(info, "enabled")
173                 }
174                 fmt.Fprintf(&buf, "%s: %s\n",
175                         s.formatTarget(x.Name),
176                         strings.Join(info, ", "))
177         }
178         return buf.String()
179 }
180
181 func (s *Sync) formatCommandChanges(changes []safcm.CommandChange) string {
182         const indent = "   > "
183
184         // Quiet hides all successful, non-trigger commands which produce no
185         // output. This is useful as many commands will be used to enforce a
186         // certain state (e.g. file not-present, `ainsl`, etc.) and are run on
187         // each sync. Displaying them provides not much useful information.
188         // Instead, quiet shows them only when they produce output (e.g.
189         // `ainsl`, `rm -v`) and thus modify the host's state.
190         var noOutput int
191         if s.config.Quiet {
192                 for _, x := range changes {
193                         if x.Trigger == "" &&
194                                 x.Error == "" &&
195                                 x.Output == "" {
196                                 noOutput++
197                         }
198                 }
199         }
200
201         var buf strings.Builder
202         if s.config.DryRun {
203                 fmt.Fprintf(&buf, "will execute %d command(s)", len(changes))
204         } else {
205                 fmt.Fprintf(&buf, "executed %d command(s)", len(changes))
206         }
207         if noOutput > 0 && !s.config.DryRun {
208                 fmt.Fprintf(&buf, ", %d with no output (hidden)", noOutput)
209         }
210         if noOutput != len(changes) {
211                 fmt.Fprintf(&buf, ":")
212         }
213         if s.config.DryRun {
214                 fmt.Fprintf(&buf, " (dry-run)")
215         }
216         fmt.Fprintf(&buf, "\n")
217         for _, x := range changes {
218                 if noOutput > 0 &&
219                         x.Trigger == "" && x.Error == "" && x.Output == "" {
220                         continue
221                 }
222
223                 fmt.Fprintf(&buf, "%s", s.formatTarget(x.Command))
224                 if x.Trigger != "" {
225                         fmt.Fprintf(&buf, ", trigger for %q", x.Trigger)
226                 }
227                 if x.Error != "" {
228                         fmt.Fprintf(&buf, ", failed: %q", x.Error)
229                 }
230                 if x.Output != "" {
231                         // TODO: truncate very large outputs?
232                         x := indentBlock(x.Output, indent)
233                         fmt.Fprintf(&buf, ":\n%s",
234                                 EscapeControlCharacters(s.isTTY, x))
235                 }
236                 fmt.Fprintf(&buf, "\n")
237         }
238         return buf.String()
239 }
240
241 func (s *Sync) formatTarget(x string) string {
242         x = fmt.Sprintf("%q", x) // escape!
243         return ColorString(s.isTTY, ColorCyan, x)
244 }
245
246 func (s *Sync) formatDiff(diff string) string {
247         const indent = "   "
248
249         diff = indentBlock(diff, indent)
250         // Never color diff content as we want to color the whole diff
251         diff = EscapeControlCharacters(false, diff)
252         if !s.isTTY {
253                 return diff
254         }
255
256         var res []string
257         for _, x := range strings.Split(diff, "\n") {
258                 if strings.HasPrefix(x, indent+"+") {
259                         x = ColorString(s.isTTY, ColorGreen, x)
260                 } else if strings.HasPrefix(x, indent+"-") {
261                         x = ColorString(s.isTTY, ColorRed, x)
262                 }
263                 res = append(res, x)
264         }
265         return strings.Join(res, "\n")
266 }
267
268 func indentBlock(x string, sep string) string {
269         lines := strings.Split(x, "\n")
270         if lines[len(lines)-1] == "" {
271                 lines = lines[:len(lines)-1]
272         } else {
273                 lines = append(lines, "\\ No newline at end of file")
274         }
275
276         return sep + strings.Join(lines, "\n"+sep)
277 }