X-Git-Url: https://ruderich.org/simon/gitweb/?a=blobdiff_plain;f=frontend%2Fchanges.go;fp=frontend%2Fchanges.go;h=351cdfac181a7a054350584453d5133e3606b371;hb=ecbcb0132728cc18016819a214378b642d92278e;hp=0000000000000000000000000000000000000000;hpb=b0f49e5d47786984e24731b200d3d3d7d6add263;p=safcm%2Fsafcm.git diff --git a/frontend/changes.go b/frontend/changes.go new file mode 100644 index 0000000..351cdfa --- /dev/null +++ b/frontend/changes.go @@ -0,0 +1,284 @@ +// Frontend: Format changes + +// Copyright (C) 2021 Simon Ruderich +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package frontend + +import ( + "fmt" + "io/fs" + "strings" + + "ruderich.org/simon/safcm" + "ruderich.org/simon/safcm/cmd/safcm/config" +) + +// NOTE: Be careful when implementing new format* functions. All input from +// the remote helper is untrusted and must be either escaped with %q or by +// calling EscapeControlCharacters(). + +type Changes struct { + DryRun bool + Quiet bool + IsTTY bool +} + +func (c *Changes) FormatChanges(resp safcm.MsgSyncResp) string { + var changes []string + if len(resp.FileChanges) > 0 { + changes = append(changes, + c.FormatFileChanges(resp.FileChanges)) + } + if len(resp.PackageChanges) > 0 { + changes = append(changes, + c.FormatPackageChanges(resp.PackageChanges)) + } + if len(resp.ServiceChanges) > 0 { + changes = append(changes, + c.FormatServiceChanges(resp.ServiceChanges)) + } + if len(resp.CommandChanges) > 0 { + changes = append(changes, + c.FormatCommandChanges(resp.CommandChanges)) + } + if len(changes) == 0 { + // Notify user that the host was synced successfully + return "no changes" + } + + x := strings.Join(changes, "\n") + // If quiet is used and only commands without output were executed + // then don't prepend a newline so that the whole change output of a + // host fits in a single line. This makes the output much more + // readable with multiple hosts. + if strings.Count(x, "\n") == 1 { + return x + } + return "\n" + x +} + +func (c *Changes) FormatFileChanges(changes []safcm.FileChange) string { + var buf strings.Builder + if c.DryRun { + fmt.Fprintf(&buf, "will change %d file(s): (dry-run)\n", + len(changes)) + } else { + fmt.Fprintf(&buf, "changed %d file(s):\n", len(changes)) + } + for _, x := range changes { + fmt.Fprintf(&buf, "%s:", c.FormatTarget(x.Path)) + + var info []string + if x.Created { + info = append(info, + ColorString(c.IsTTY, ColorGreen, "created"), + FormatFileType(x.New), + FormatFileUserGroup(x.New), + FormatFilePerm(x.New), + ) + } else { + if x.Old.Mode.Type() != x.New.Mode.Type() { + info = append(info, fmt.Sprintf("%s -> %s", + FormatFileType(x.Old), + FormatFileType(x.New), + )) + } + if x.Old.User != x.New.User || + x.Old.Uid != x.New.Uid || + x.Old.Group != x.New.Group || + x.Old.Gid != x.New.Gid { + info = append(info, fmt.Sprintf("%s -> %s", + FormatFileUserGroup(x.Old), + FormatFileUserGroup(x.New), + )) + } + if config.FileModeToFullPerm(x.Old.Mode) != + config.FileModeToFullPerm(x.New.Mode) { + info = append(info, fmt.Sprintf("%s -> %s", + FormatFilePerm(x.Old), + FormatFilePerm(x.New), + )) + } + } + if len(info) > 0 { + fmt.Fprint(&buf, " ") + fmt.Fprint(&buf, strings.Join(info, ", ")) + } + + if x.DataDiff != "" { + fmt.Fprintf(&buf, "\n%s", c.FormatDiff(x.DataDiff)) + } + fmt.Fprintf(&buf, "\n") + } + return buf.String() +} + +func FormatFileType(info safcm.FileChangeInfo) string { + switch info.Mode.Type() { + case 0: // regular file + return "file" + case fs.ModeSymlink: + return "symlink" + case fs.ModeDir: + return "dir" + default: + return fmt.Sprintf("invalid type %v", info.Mode.Type()) + } +} +func FormatFileUserGroup(info safcm.FileChangeInfo) string { + return fmt.Sprintf("%s(%d) %s(%d)", + EscapeControlCharacters(false, info.User), info.Uid, + EscapeControlCharacters(false, info.Group), info.Gid) +} +func FormatFilePerm(info safcm.FileChangeInfo) string { + return fmt.Sprintf("%#o", config.FileModeToFullPerm(info.Mode)) +} + +func (c *Changes) FormatPackageChanges(changes []safcm.PackageChange) string { + var buf strings.Builder + if c.DryRun { + fmt.Fprintf(&buf, "will install %d package(s): (dry-run)\n", + len(changes)) + } else { + fmt.Fprintf(&buf, "installed %d package(s):\n", len(changes)) + } + for _, x := range changes { + // TODO: indicate if installation failed + fmt.Fprintf(&buf, "%s\n", c.FormatTarget(x.Name)) + } + return buf.String() +} + +func (c *Changes) FormatServiceChanges(changes []safcm.ServiceChange) string { + var buf strings.Builder + if c.DryRun { + fmt.Fprintf(&buf, "will modify %d service(s): (dry-run)\n", + len(changes)) + } else { + fmt.Fprintf(&buf, "modified %d service(s):\n", len(changes)) + } + for _, x := range changes { + var info []string + if x.Started { + info = append(info, "started") + } + if x.Enabled { + info = append(info, "enabled") + } + fmt.Fprintf(&buf, "%s: %s\n", + c.FormatTarget(x.Name), + strings.Join(info, ", ")) + } + return buf.String() +} + +func (c *Changes) FormatCommandChanges(changes []safcm.CommandChange) string { + const indent = " > " + + // Quiet hides all successful, non-trigger commands which produce no + // output. This is useful as many commands will be used to enforce a + // certain state (e.g. file not-present, `ainsl`, etc.) and are run on + // each sync. Displaying them provides not much useful information. + // Instead, quiet shows them only when they produce output (e.g. + // `ainsl`, `rm -v`) and thus modify the host's state. + var noOutput int + if c.Quiet { + for _, x := range changes { + if x.Trigger == "" && + x.Error == "" && + x.Output == "" { + noOutput++ + } + } + } + + var buf strings.Builder + if c.DryRun { + fmt.Fprintf(&buf, "will execute %d command(s)", len(changes)) + } else { + fmt.Fprintf(&buf, "executed %d command(s)", len(changes)) + } + if noOutput > 0 && !c.DryRun { + fmt.Fprintf(&buf, ", %d with no output (hidden)", noOutput) + } + if noOutput != len(changes) { + fmt.Fprintf(&buf, ":") + } + if c.DryRun { + fmt.Fprintf(&buf, " (dry-run)") + } + fmt.Fprintf(&buf, "\n") + for _, x := range changes { + if noOutput > 0 && + x.Trigger == "" && x.Error == "" && x.Output == "" { + continue + } + + fmt.Fprintf(&buf, "%s", c.FormatTarget(x.Command)) + if x.Trigger != "" { + fmt.Fprintf(&buf, ", trigger for %q", x.Trigger) + } + if x.Error != "" { + fmt.Fprintf(&buf, ", failed: %q", x.Error) + } + if x.Output != "" { + // TODO: truncate very large outputs? + x := indentBlock(x.Output, indent) + fmt.Fprintf(&buf, ":\n%s", + EscapeControlCharacters(c.IsTTY, x)) + } + fmt.Fprintf(&buf, "\n") + } + return buf.String() +} + +func (c *Changes) FormatTarget(x string) string { + x = fmt.Sprintf("%q", x) // escape! + return ColorString(c.IsTTY, ColorCyan, x) +} + +func (c *Changes) FormatDiff(diff string) string { + const indent = " " + + diff = indentBlock(diff, indent) + // Never color diff content as we want to color the whole diff + diff = EscapeControlCharacters(false, diff) + if !c.IsTTY { + return diff + } + + var res []string + for _, x := range strings.Split(diff, "\n") { + if strings.HasPrefix(x, indent+"+") { + x = ColorString(c.IsTTY, ColorGreen, x) + } else if strings.HasPrefix(x, indent+"-") { + x = ColorString(c.IsTTY, ColorRed, x) + } + res = append(res, x) + } + return strings.Join(res, "\n") +} + +func indentBlock(x string, sep string) string { + lines := strings.Split(x, "\n") + if lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } else { + lines = append(lines, "\\ No newline at end of file") + } + + return sep + strings.Join(lines, "\n"+sep) +}