m/pkg/logtree: implement concise stringification

This adds a new method on LogEntry: ConciseString(). It's designed to
be used in cases where we want to display the log line on some
limited-width (and likely non-interactive) log console, like TTY
consoles in Metropolis.

This is a bit too Metropolis-specific to my liking (we hardcode some
logic related to the layout of root.role.*), but it'll do for now.

Change-Id: I1079b8b19a3c304fcc5077ce6b4c69887a34d7ae
Reviewed-on: https://review.monogon.dev/c/monogon/+/1359
Reviewed-by: Leopold Schabel <leo@monogon.tech>
Tested-by: Jenkins CI
diff --git a/go.mod b/go.mod
index 9ec5608..a69c12c 100644
--- a/go.mod
+++ b/go.mod
@@ -100,6 +100,7 @@
 	github.com/mdlayher/genetlink v1.2.0
 	github.com/mdlayher/netlink v1.6.0
 	github.com/mdlayher/raw v0.1.0
+	github.com/mitchellh/go-wordwrap v1.0.0
 	github.com/opencontainers/runc v1.1.3
 	github.com/packethost/packngo v0.29.0
 	github.com/pierrec/lz4/v4 v4.1.14
@@ -290,7 +291,6 @@
 	github.com/mindprince/gonvml v0.0.0-20190828220739-9ebdce4bb989 // indirect
 	github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible // indirect
 	github.com/mitchellh/go-homedir v1.1.0 // indirect
-	github.com/mitchellh/go-wordwrap v1.0.0 // indirect
 	github.com/mitchellh/mapstructure v1.4.2 // indirect
 	github.com/moby/locker v1.0.1 // indirect
 	github.com/moby/spdystream v0.2.0 // indirect
diff --git a/metropolis/pkg/logtree/BUILD.bazel b/metropolis/pkg/logtree/BUILD.bazel
index aabc40d..5c4237c 100644
--- a/metropolis/pkg/logtree/BUILD.bazel
+++ b/metropolis/pkg/logtree/BUILD.bazel
@@ -21,6 +21,7 @@
     deps = [
         "//metropolis/pkg/logbuffer",
         "//metropolis/proto/api",
+        "@com_github_mitchellh_go_wordwrap//:go-wordwrap",
         "@org_golang_google_protobuf//types/known/timestamppb",
     ],
 )
diff --git a/metropolis/pkg/logtree/journal.go b/metropolis/pkg/logtree/journal.go
index d29fdaa..5df6e1a 100644
--- a/metropolis/pkg/logtree/journal.go
+++ b/metropolis/pkg/logtree/journal.go
@@ -59,21 +59,25 @@
 // represented by heads[DN]/tails[DN] pointers in journal and nextLocal/prevLocal
 // pointers in entries:
 //
-//       .------------.        .------------.        .------------.
-//       | dn: A.B    |        | dn: Z      |        | dn: A.B    |
-//       | time: 1    |        | time: 2    |        | time: 3    |
-//       |------------|        |------------|        |------------|
-//       | nextGlobal :------->| nextGlobal :------->| nextGlobal :--> nil
+//	.------------.        .------------.        .------------.
+//	| dn: A.B    |        | dn: Z      |        | dn: A.B    |
+//	| time: 1    |        | time: 2    |        | time: 3    |
+//	|------------|        |------------|        |------------|
+//	| nextGlobal :------->| nextGlobal :------->| nextGlobal :--> nil
+//
 // nil <-: prevGlobal |<-------: prevGlobal |<-------| prevGlobal |
-//       |------------|        |------------|  n     |------------|
-//       | nextLocal  :---. n  | nextLocal  :->i .-->| nextLocal  :--> nil
+//
+//	|------------|        |------------|  n     |------------|
+//	| nextLocal  :---. n  | nextLocal  :->i .-->| nextLocal  :--> nil
+//
 // nil <-: prevLocal  |<--: i<-: prevLocal  |  l :---| prevLocal  |
-//       '------------'   | l  '------------'    |   '------------'
-//            ^           '----------------------'         ^
-//            |                      ^                     |
-//            |                      |                     |
-//         ( head )             ( tails[Z] )            ( tail )
-//      ( heads[A.B] )          ( heads[Z] )         ( tails[A.B] )
+//
+//	 '------------'   | l  '------------'    |   '------------'
+//	      ^           '----------------------'         ^
+//	      |                      ^                     |
+//	      |                      |                     |
+//	   ( head )             ( tails[Z] )            ( tail )
+//	( heads[A.B] )          ( heads[Z] )         ( tails[A.B] )
 type journal struct {
 	// mu locks the rest of the structure. It must be taken during any operation on the
 	// journal.
@@ -232,3 +236,70 @@
 	}
 
 }
+
+// Shorten returns a shortened version of this DN for constrained logging
+// environments like tty0 logging.
+//
+// If ShortenDictionary is given, it will be used to replace DN parts with
+// shorter equivalents. For example, with the dictionary:
+//
+// { "foobar": "foo", "manager": "mgr" }
+//
+// The DN some.foobar.logger will be turned into some.foo.logger before further
+// being processed by the shortening mechanism.
+//
+// The shortening rules applied are Metropolis-specific.
+func (d DN) Shorten(dict ShortenDictionary, maxLen int) string {
+	path, _ := d.Path()
+	// Apply DN part shortening rules.
+	if dict != nil {
+		for i, p := range path {
+			if sh, ok := dict[p]; ok {
+				path[i] = sh
+			}
+		}
+	}
+
+	// This generally shouldn't happen.
+	if len(path) == 0 {
+		return "?"
+	}
+
+	// Strip 'root.' prefix.
+	if len(path) > 1 && path[0] == "root" {
+		path = path[1:]
+	}
+
+	// Replace role.xxx.yyy.zzz with xxx.zzz - stripping everything between the role
+	// name and the last element of the path.
+	if path[0] == "role" && len(path) > 1 {
+		if len(path) == 2 {
+			path = path[1:]
+		} else {
+			path = []string{
+				path[1],
+				path[len(path)-1],
+			}
+		}
+	}
+
+	// Join back to be ' '-delimited, and ellipsize if too long.
+	s := strings.Join(path, " ")
+	if overflow := len(s) - maxLen; overflow > 0 {
+		s = "..." + s[overflow+3:]
+	}
+	return s
+}
+
+type ShortenDictionary map[string]string
+
+var MetropolisShortenDict = ShortenDictionary{
+	"controlplane":           "cplane",
+	"map-cluster-membership": "map-membership",
+	"cluster-membership":     "cluster",
+	"controller-manager":     "controllers",
+	"networking":             "net",
+	"network":                "net",
+	"interfaces":             "ifaces",
+	"kubernetes":             "k8s",
+}
diff --git a/metropolis/pkg/logtree/journal_test.go b/metropolis/pkg/logtree/journal_test.go
index 474748a..1df3f12 100644
--- a/metropolis/pkg/logtree/journal_test.go
+++ b/metropolis/pkg/logtree/journal_test.go
@@ -146,3 +146,28 @@
 		t.Fatalf("Subtree(a.b): %s", res)
 	}
 }
+
+func TestDN_Shorten(t *testing.T) {
+	for i, te := range []struct {
+		input  string
+		maxLen int
+		want   string
+	}{
+		{"root.role.controlplane.launcher.consensus.autopromoter", 20, "cplane autopromoter"},
+		{"networking.interfaces", 20, "net ifaces"},
+		{"hostsfile", 20, "hostsfile"},
+		{"root.dhcp-server", 20, "dhcp-server"},
+		{"root.role.kubernetes.run.kubernetes.apiserver", 20, "k8s apiserver"},
+		{"some.very.long.dn.that.cant.be.shortened", 20, "...cant be shortened"},
+		{"network.interfaces.dhcp", 20, "net ifaces dhcp"},
+	} {
+		got := DN(te.input).Shorten(MetropolisShortenDict, te.maxLen)
+		if len(got) > te.maxLen {
+			t.Errorf("case %d: output %q too long, got %d bytes, wanted %d", i, got, len(got), te.maxLen)
+		} else {
+			if te.want != got {
+				t.Errorf("case %d: wanted %q, got %q", i, te.want, got)
+			}
+		}
+	}
+}
diff --git a/metropolis/pkg/logtree/logtree_entry.go b/metropolis/pkg/logtree/logtree_entry.go
index 442d456..a020910 100644
--- a/metropolis/pkg/logtree/logtree_entry.go
+++ b/metropolis/pkg/logtree/logtree_entry.go
@@ -20,6 +20,8 @@
 	"fmt"
 	"strings"
 
+	"github.com/mitchellh/go-wordwrap"
+
 	"source.monogon.dev/metropolis/pkg/logbuffer"
 	apb "source.monogon.dev/metropolis/proto/api"
 )
@@ -57,6 +59,116 @@
 	return "INVALID"
 }
 
+// ConciseString returns a concise representation of this log entry for
+// constrained environments, like TTY consoles.
+//
+// The output format is as follows:
+//
+//	  shortened dn I Hello there
+//	some component W Something went wrong
+//	  shortened dn I Goodbye there
+//	external stuff R I am en external process using raw logging.
+//
+// The above output is the result of calling ConciseString on three different
+// LogEntries.
+//
+// If maxWidth is greater than zero, word wrapping will be applied. For example,
+// with maxWidth set to 40:
+//
+//	     shortened I Hello there
+//	some component W Something went wrong and here are the very long details that
+//	               | describe this particular issue: according to all known laws of
+//	               | aviation, there is no way a bee should be able to fly.
+//	  shortened dn I Goodbye there
+//	external stuff R I am en external process using raw logging.
+//
+// The above output is also the result of calling ConciseString on three
+// different LogEntries.
+//
+// Multi-line log entries will emit 'continuation' lines (with '|') in the same
+// way as word wrapping does. That means that even with word wrapping disabled,
+// the result of this function might be multiline.
+//
+// The width of the first column (the 'shortened DN' column) is automatically
+// selected based on maxWidth. If maxWidth is less than 60, the column will be
+// omitted. For example, with maxWidth set to 20:
+//
+//	I Hello there
+//	W Something went wrong and here are the very long details that
+//	| describe this particular issue: according to all known laws of
+//	| aviation, there is no way a bee should be able to fly.
+//	I Goodbye there
+//	R I am en external process using raw logging.
+//
+// The given `dict` implements simple replacement rules for shortening the DN
+// parts of a log entry's DN. Some rules are hardcoded for Metropolis' DN tree.
+// If no extra shortening rules should be applied, dict can be set to ni// The
+// given `dict` implements simple replacement rules for shortening the DN parts
+// of a log entry's DN. Some rules are hardcoded for Metropolis' DN tree. If no
+// extra shortening rules should be applied, dict can be set to nil.
+func (l *LogEntry) ConciseString(dict ShortenDictionary, maxWidth int) string {
+	// Decide on a dnWidth.
+	dnWidth := 0
+	switch {
+	case maxWidth >= 80:
+		dnWidth = 20
+	case maxWidth >= 60:
+		dnWidth = 16
+	case maxWidth <= 0:
+		// No word wrapping.
+		dnWidth = 20
+	}
+
+	// Compute shortened DN, if needed.
+	sh := ""
+	if dnWidth > 0 {
+		sh = l.DN.Shorten(dict, dnWidth)
+		sh = fmt.Sprintf("%*s ", dnWidth, sh)
+	}
+
+	// Prefix of the first line emitted.
+	var prefix string
+	switch {
+	case l.Leveled != nil:
+		prefix = sh + string(l.Leveled.Severity()) + " "
+	case l.Raw != nil:
+		prefix = sh + "R "
+	}
+	// Prefix of rest of lines emitted.
+	continuationPrefix := strings.Repeat(" ", len(sh)) + "| "
+
+	// Collect lines based on the type of LogEntry.
+	var lines []string
+	collect := func(message string) {
+		if maxWidth > 0 {
+			message = wordwrap.WrapString(message, uint(maxWidth-len(prefix)))
+		}
+		for _, m2 := range strings.Split(message, "\n") {
+			if len(m2) == 0 {
+				continue
+			}
+			if len(lines) == 0 {
+				lines = append(lines, prefix+m2)
+			} else {
+				lines = append(lines, continuationPrefix+m2)
+			}
+		}
+	}
+	switch {
+	case l.Leveled != nil:
+		_, messages := l.Leveled.Strings()
+		for _, m := range messages {
+			collect(m)
+		}
+	case l.Raw != nil:
+		collect(l.Raw.String())
+	default:
+		return ""
+	}
+
+	return strings.Join(lines, "\n")
+}
+
 // Strings returns the canonical representation of this payload split into a
 // prefix and all lines that were contained in the original message. This is
 // meant to be displayed to the user by showing the prefix before each line,
diff --git a/metropolis/pkg/logtree/logtree_test.go b/metropolis/pkg/logtree/logtree_test.go
index 315dbc3..a7614a4 100644
--- a/metropolis/pkg/logtree/logtree_test.go
+++ b/metropolis/pkg/logtree/logtree_test.go
@@ -237,3 +237,90 @@
 		t.Errorf("first entry at %d, second at %d, wanted one after the other", first, second)
 	}
 }
+
+func TestLogEntry_ConciseString(t *testing.T) {
+	trim := func(s string) string {
+		return strings.Trim(s, "\n")
+	}
+	for i, te := range []struct {
+		entry    *LogEntry
+		maxWidth int
+		want     string
+	}{
+		{
+			&LogEntry{
+				Leveled: &LeveledPayload{
+					messages: []string{"Hello there!"},
+					severity: WARNING,
+				},
+				DN: "root.role.kubernetes.run.kubernetes.apiserver",
+			},
+			120,
+			"       k8s apiserver W Hello there!",
+		},
+		{
+			&LogEntry{
+				Leveled: &LeveledPayload{
+					messages: []string{"Hello there!", "I am multiline."},
+					severity: WARNING,
+				},
+				DN: "root.role.kubernetes.run.kubernetes.apiserver",
+			},
+			120,
+			trim(`
+       k8s apiserver W Hello there!
+                     | I am multiline.
+`),
+		},
+		{
+			&LogEntry{
+				Leveled: &LeveledPayload{
+					messages: []string{"Hello there! I am a very long string, and I will get wrapped to 120 columns because that's just how life is for long strings."},
+					severity: WARNING,
+				},
+				DN: "root.role.kubernetes.run.kubernetes.apiserver",
+			},
+			120,
+			trim(`
+       k8s apiserver W Hello there! I am a very long string, and I will get wrapped to 120 columns because that's just
+                     | how life is for long strings.
+`),
+		},
+		{
+			&LogEntry{
+				Leveled: &LeveledPayload{
+					messages: []string{"Hello there!"},
+					severity: WARNING,
+				},
+				DN: "root.role.kubernetes.run.kubernetes.apiserver",
+			},
+			60,
+			trim(`
+   k8s apiserver W Hello there!
+`),
+		},
+		{
+			&LogEntry{
+				Leveled: &LeveledPayload{
+					messages: []string{"Hello there!"},
+					severity: WARNING,
+				},
+				DN: "root.role.kubernetes.run.kubernetes.apiserver",
+			},
+			40,
+			"W Hello there!",
+		},
+	} {
+		got := te.entry.ConciseString(MetropolisShortenDict, te.maxWidth)
+		for _, line := range strings.Split(got, "\n") {
+			if want, got := te.maxWidth, len(line); got > want {
+				t.Errorf("Case %d, line %q too long (%d bytes, wanted at most %d)", i, line, got, want)
+			}
+		}
+		if te.want != got {
+			t.Errorf("Case %d, message diff", i)
+			t.Logf("Wanted:\n%s", te.want)
+			t.Logf("Got:\n%s", got)
+		}
+	}
+}