a73x

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 }