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