internal/agent/hyperlog/hyperlog.go
Ref: Size: 3.7 KiB History
// Package hyperlog reads back the last thing a hypervisor said before it
// stopped running.
//
// Every backend already redirects its hypervisor's stdout and stderr to a file
// beside the VM's disk — cloud-hypervisor to ch.log, vfkit to vfkit.log — but
// nothing ever read them, so a guest whose hypervisor exited on startup was
// reported as lost and nothing more. "ephemeral VM lost" is a true statement
// about the process table and says nothing about the cause; the cause was
// sitting on disk the whole time.
//
// This is deliberately the hypervisor's own words rather than an interpretation
// of them. eitri cannot enumerate what cloud-hypervisor or vfkit might fail at,
// and a guess dressed up as a diagnosis is worse than a quote.
package hyperlog
import (
"bytes"
"io"
"os"
"slices"
"strings"
"unicode"
)
const (
// tailBytes bounds the read. A hypervisor's fatal message is its last line,
// so only the end of the file is interesting, and a log that has been
// running for weeks must not be pulled into memory to find it.
tailBytes = 8 << 10
// maxLen bounds the returned string. It lands in a VM's last_error, which
// is a database column rendered in the console and the CLI, so a runaway
// line has to be cut somewhere it stays readable.
maxLen = 240
)
// Reason returns the last meaningful line the hypervisor wrote to path, or ""
// when there is nothing to report — no file, an empty one, or output that is
// all whitespace. Empty means "nothing to add", never "nothing went wrong":
// callers append it to their own message only when it is non-empty.
func Reason(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
// Seek to the last tailBytes. A file shorter than that is read whole; the
// first line of the window may be a fragment, which is why only complete
// trailing lines are considered below.
size, err := f.Seek(0, io.SeekEnd)
if err != nil {
return ""
}
start := max(size-tailBytes, 0)
if _, err := f.Seek(start, io.SeekStart); err != nil {
return ""
}
buf, err := io.ReadAll(f)
if err != nil {
return ""
}
// Drop a leading partial line when the window did not start at the file's
// beginning, so a truncated fragment is never reported as the reason.
if start > 0 {
if i := bytes.IndexByte(buf, '\n'); i >= 0 {
buf = buf[i+1:]
} else {
return "" // one enormous line, no complete one to quote
}
}
lines := strings.Split(string(buf), "\n")
for _, line := range slices.Backward(lines) {
if s := clean(line); s != "" {
return s
}
}
return ""
}
// clean reduces one log line to something safe to store and show: printable
// characters only, collapsed whitespace, bounded length. Hypervisors colour
// their output and draw progress with control characters, none of which belong
// in a database column.
func clean(line string) string {
var b strings.Builder
b.Grow(len(line))
prevSpace := true // leading whitespace is dropped
for _, r := range line {
switch {
case r == 0x1b: // start of an ANSI escape; drop the rest of the line
return strings.TrimRight(b.String(), " ")
case unicode.IsSpace(r):
if !prevSpace {
b.WriteRune(' ')
prevSpace = true
}
case unicode.IsPrint(r):
b.WriteRune(r)
prevSpace = false
}
// Anything else (other control characters) is dropped.
}
out := strings.TrimRight(b.String(), " ")
if len(out) > maxLen {
// Cut on a rune boundary so the result stays valid UTF-8.
cut := maxLen
for cut > 0 && !utf8Start(out[cut]) {
cut--
}
out = strings.TrimRight(out[:cut], " ") + "…"
}
return out
}
// utf8Start reports whether b begins a UTF-8 rune (i.e. is not a continuation
// byte).
func utf8Start(b byte) bool { return b&0xC0 != 0x80 }