core: plug logtree into NodeDebugService

This introduces a new Proto API for accessing debug logs. Currently this
is implemented to be used by the debug service. However, these proto
definitions will likely be reused for production cluster APIs.

The implementation mostly consists of adding the proto, implementing
to/from conversion methods, and altering the debug service to use the
new API.

We also move all of the debug service implementation into a separate file,
to slightly clean up main.go. This produces an unfortunately colorful
diff, but it's just moving code around.

Test Plan: Manually tested using the dbg tool. We currently don't properly test the debug service. I suppose we should do that for the production cluster APIs, and just keep on going for now.

X-Origin-Diff: phab/D649
GitOrigin-RevId: ac454681e4b72b2876e313b3aeababa179eb1fa3
diff --git a/core/pkg/logbuffer/BUILD.bazel b/core/pkg/logbuffer/BUILD.bazel
index fb7512a..958389e 100644
--- a/core/pkg/logbuffer/BUILD.bazel
+++ b/core/pkg/logbuffer/BUILD.bazel
@@ -8,6 +8,7 @@
     ],
     importpath = "git.monogon.dev/source/nexantic.git/core/pkg/logbuffer",
     visibility = ["//visibility:public"],
+    deps = ["//core/proto/api:go_default_library"],
 )
 
 go_test(
diff --git a/core/pkg/logbuffer/linebuffer.go b/core/pkg/logbuffer/linebuffer.go
index 6ee7d6b..fa4dc33 100644
--- a/core/pkg/logbuffer/linebuffer.go
+++ b/core/pkg/logbuffer/linebuffer.go
@@ -21,6 +21,8 @@
 	"fmt"
 	"strings"
 	"sync"
+
+	apb "git.monogon.dev/source/nexantic.git/core/proto/api"
 )
 
 // Line is a line stored in the log buffer - a string, that has been perhaps truncated (due to exceeded limits).
@@ -43,6 +45,29 @@
 	return l.Data
 }
 
+// ProtoLog returns a Logging-specific protobuf structure.
+func (l *Line) ProtoLog() *apb.LogEntry_Raw {
+	return &apb.LogEntry_Raw{
+		Data:           l.Data,
+		OriginalLength: int64(l.OriginalLength),
+	}
+}
+
+// LineFromLogProto converts a Logging-specific protobuf message back into a Line.
+func LineFromLogProto(raw *apb.LogEntry_Raw) (*Line, error) {
+	if raw.OriginalLength < int64(len(raw.Data)) {
+		return nil, fmt.Errorf("original_length smaller than length of data")
+	}
+	originalLength := int(raw.OriginalLength)
+	if int64(originalLength) < raw.OriginalLength {
+		return nil, fmt.Errorf("original_length larger than native int size")
+	}
+	return &Line{
+		Data:           raw.Data,
+		OriginalLength: originalLength,
+	}, nil
+}
+
 // LineBuffer is a io.WriteCloser that will call a given callback every time a line is completed.
 type LineBuffer struct {
 	maxLineLength int
diff --git a/core/pkg/logtree/BUILD.bazel b/core/pkg/logtree/BUILD.bazel
index 68abcfb..7b899a4 100644
--- a/core/pkg/logtree/BUILD.bazel
+++ b/core/pkg/logtree/BUILD.bazel
@@ -15,7 +15,10 @@
     ],
     importpath = "git.monogon.dev/source/nexantic.git/core/pkg/logtree",
     visibility = ["//visibility:public"],
-    deps = ["//core/pkg/logbuffer:go_default_library"],
+    deps = [
+        "//core/pkg/logbuffer:go_default_library",
+        "//core/proto/api:go_default_library",
+    ],
 )
 
 go_test(
diff --git a/core/pkg/logtree/journal.go b/core/pkg/logtree/journal.go
index 893eff0..78c55a1 100644
--- a/core/pkg/logtree/journal.go
+++ b/core/pkg/logtree/journal.go
@@ -17,7 +17,7 @@
 package logtree
 
 import (
-	"fmt"
+	"errors"
 	"strings"
 	"sync"
 )
@@ -27,6 +27,10 @@
 // the root node of the tree.
 type DN string
 
+var (
+	ErrInvalidDN = errors.New("invalid DN")
+)
+
 // Path return the parts of a DN, ie. all the elements of the dot-delimited DN path. For the root node, an empty list
 // will be returned. An error will be returned if the DN is invalid (contains empty parts, eg. `foo..bar`, `.foo` or
 // `foo.`.
@@ -37,7 +41,7 @@
 	parts := strings.Split(string(d), ".")
 	for _, p := range parts {
 		if p == "" {
-			return nil, fmt.Errorf("invalid DN")
+			return nil, ErrInvalidDN
 		}
 	}
 	return parts, nil
diff --git a/core/pkg/logtree/leveled.go b/core/pkg/logtree/leveled.go
index 2c8fcc4..125e1df 100644
--- a/core/pkg/logtree/leveled.go
+++ b/core/pkg/logtree/leveled.go
@@ -16,6 +16,12 @@
 
 package logtree
 
+import (
+	"fmt"
+
+	apb "git.monogon.dev/source/nexantic.git/core/proto/api"
+)
+
 // LeveledLogger is a generic interface for glog-style logging. There are four hardcoded log severities, in increasing
 // order: INFO, WARNING, ERROR, FATAL. Logging at a certain severity level logs not only to consumers expecting data
 // at that severity level, but also all lower severity levels. For example, an ERROR log will also be passed to
@@ -106,3 +112,33 @@
 	}
 	return false
 }
+
+func SeverityFromProto(s apb.LeveledLogSeverity) (Severity, error) {
+	switch s {
+	case apb.LeveledLogSeverity_INFO:
+		return INFO, nil
+	case apb.LeveledLogSeverity_WARNING:
+		return WARNING, nil
+	case apb.LeveledLogSeverity_ERROR:
+		return ERROR, nil
+	case apb.LeveledLogSeverity_FATAL:
+		return FATAL, nil
+	default:
+		return "", fmt.Errorf("unknown severity value %d", s)
+	}
+}
+
+func (s Severity) ToProto() apb.LeveledLogSeverity {
+	switch s {
+	case INFO:
+		return apb.LeveledLogSeverity_INFO
+	case WARNING:
+		return apb.LeveledLogSeverity_WARNING
+	case ERROR:
+		return apb.LeveledLogSeverity_ERROR
+	case FATAL:
+		return apb.LeveledLogSeverity_FATAL
+	default:
+		return apb.LeveledLogSeverity_INVALID
+	}
+}
diff --git a/core/pkg/logtree/logtree.go b/core/pkg/logtree/logtree.go
index 064b6e7..2d405f8 100644
--- a/core/pkg/logtree/logtree.go
+++ b/core/pkg/logtree/logtree.go
@@ -66,6 +66,8 @@
 		tree:     tree,
 		children: make(map[string]*node),
 	}
+	// TODO(q3k): make this limit configurable. If this happens, or the default (1024) gets changes, max chunk size
+	// calculations when serving the logs (eg. in NodeDebugService) must reflect this.
 	n.rawLineBuffer = logbuffer.NewLineBuffer(1024, n.logRaw)
 	return n
 }
diff --git a/core/pkg/logtree/logtree_access.go b/core/pkg/logtree/logtree_access.go
index ee93df2..bb8a524 100644
--- a/core/pkg/logtree/logtree_access.go
+++ b/core/pkg/logtree/logtree_access.go
@@ -17,11 +17,12 @@
 package logtree
 
 import (
+	"errors"
 	"fmt"
-	"log"
 	"sync/atomic"
 
 	"git.monogon.dev/source/nexantic.git/core/pkg/logbuffer"
+	apb "git.monogon.dev/source/nexantic.git/core/proto/api"
 )
 
 // LogReadOption describes options for the LogTree.Read call.
@@ -101,6 +102,79 @@
 	DN DN
 }
 
+// Convert this LogEntry to proto. Returned value may be nil if given LogEntry is invalid, eg. contains neither a Raw
+// nor Leveled entry.
+func (l *LogEntry) Proto() *apb.LogEntry {
+	p := &apb.LogEntry{
+		Dn: string(l.DN),
+	}
+	switch {
+	case l.Leveled != nil:
+		leveled := l.Leveled
+		p.Kind = &apb.LogEntry_Leveled_{
+			Leveled: leveled.Proto(),
+		}
+	case l.Raw != nil:
+		raw := l.Raw
+		p.Kind = &apb.LogEntry_Raw_{
+			Raw: raw.ProtoLog(),
+		}
+	default:
+		return nil
+	}
+	return p
+}
+
+// String returns a standardized human-readable representation of either underlying raw or leveled entry. The returned
+// data is pre-formatted to be displayed in a fixed-width font.
+func (l *LogEntry) String() string {
+	if l.Leveled != nil {
+		// Use glog-like layout, but with supervisor DN instead of filename.
+		timestamp := l.Leveled.Timestamp()
+		_, month, day := timestamp.Date()
+		hour, minute, second := timestamp.Clock()
+		nsec := timestamp.Nanosecond() / 1000
+		return fmt.Sprintf("%s%02d%02d %02d:%02d:%02d.%06d %s] %s", l.Leveled.Severity(), month, day, hour, minute, second, nsec, l.DN, l.Leveled.Message())
+	}
+	if l.Raw != nil {
+		return fmt.Sprintf("%-32s R %s", l.DN, l.Raw)
+	}
+	return "INVALID"
+}
+
+// Parse a proto LogEntry back into internal structure. This can be used in log proto API consumers to easily print
+// received log entries.
+func LogEntryFromProto(l *apb.LogEntry) (*LogEntry, error) {
+	dn := DN(l.Dn)
+	if _, err := dn.Path(); err != nil {
+		return nil, fmt.Errorf("could not convert DN: %w", err)
+	}
+	res := &LogEntry{
+		DN: dn,
+	}
+	switch inner := l.Kind.(type) {
+	case *apb.LogEntry_Leveled_:
+		leveled, err := LeveledPayloadFromProto(inner.Leveled)
+		if err != nil {
+			return nil, fmt.Errorf("could not convert leveled entry: %w", err)
+		}
+		res.Leveled = leveled
+	case *apb.LogEntry_Raw_:
+		line, err := logbuffer.LineFromLogProto(inner.Raw)
+		if err != nil {
+			return nil, fmt.Errorf("could not convert raw entry: %w", err)
+		}
+		res.Raw = line
+	default:
+		return nil, fmt.Errorf("proto has neither Leveled nor Raw set")
+	}
+	return res, nil
+}
+
+var (
+	ErrRawAndLeveled = errors.New("cannot return logs that are simultaneously OnlyRaw and OnlyLeveled")
+)
+
 // Read and/or stream entries from a LogTree. The returned LogReader is influenced by the LogReadOptions passed, which
 // influence whether the Read will return existing entries, a stream, or both. In addition the options also dictate
 // whether only entries for that particular DN are returned, or for all sub-DNs as well.
@@ -136,7 +210,7 @@
 	}
 
 	if onlyLeveled && onlyRaw {
-		return nil, fmt.Errorf("cannot return logs that are simultaneously OnlyRaw and OnlyLeveled")
+		return nil, ErrRawAndLeveled
 	}
 
 	var filters []filter
@@ -182,7 +256,6 @@
 	lr := &LogReader{}
 	lr.Backlog = make([]*LogEntry, len(entries))
 	for i, entry := range entries {
-		log.Printf("backlog %d %+v %+v", i, entry.raw, entry.leveled)
 		lr.Backlog[i] = entry.external()
 	}
 	if stream {
diff --git a/core/pkg/logtree/logtree_publisher.go b/core/pkg/logtree/logtree_publisher.go
index 3a81ec6..c898012 100644
--- a/core/pkg/logtree/logtree_publisher.go
+++ b/core/pkg/logtree/logtree_publisher.go
@@ -91,6 +91,7 @@
 			file = file[slash+1:]
 		}
 	}
+
 	p := &LeveledPayload{
 		timestamp: time.Now(),
 		severity:  severity,
diff --git a/core/pkg/logtree/payload.go b/core/pkg/logtree/payload.go
index 2d64a7a..ca7a0a0 100644
--- a/core/pkg/logtree/payload.go
+++ b/core/pkg/logtree/payload.go
@@ -18,7 +18,11 @@
 
 import (
 	"fmt"
+	"strconv"
+	"strings"
 	"time"
+
+	apb "git.monogon.dev/source/nexantic.git/core/proto/api"
 )
 
 // LeveledPayload is a log entry for leveled logs (as per leveled.go). It contains not only the log message itself and
@@ -62,3 +66,37 @@
 
 // Severity returns the Severity with which this entry was logged.
 func (p *LeveledPayload) Severity() Severity { return p.severity }
+
+// Proto converts a LeveledPayload to protobuf format.
+func (p *LeveledPayload) Proto() *apb.LogEntry_Leveled {
+	return &apb.LogEntry_Leveled{
+		Message:   p.Message(),
+		Timestamp: p.Timestamp().UnixNano(),
+		Severity:  p.Severity().ToProto(),
+		Location:  p.Location(),
+	}
+}
+
+// LeveledPayloadFromProto parses a protobuf message into the internal format.
+func LeveledPayloadFromProto(p *apb.LogEntry_Leveled) (*LeveledPayload, error) {
+	severity, err := SeverityFromProto(p.Severity)
+	if err != nil {
+		return nil, fmt.Errorf("could not convert severity: %w", err)
+	}
+	parts := strings.Split(p.Location, ":")
+	if len(parts) != 2 {
+		return nil, fmt.Errorf("invalid location, must be two :-delimited parts, is %d parts", len(parts))
+	}
+	file := parts[0]
+	line, err := strconv.Atoi(parts[1])
+	if err != nil {
+		return nil, fmt.Errorf("invalid location line number: %w", err)
+	}
+	return &LeveledPayload{
+		message:   p.Message,
+		timestamp: time.Unix(0, p.Timestamp),
+		severity:  severity,
+		file:      file,
+		line:      line,
+	}, nil
+}