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