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