a73x

internal/smoke/console.go

Ref:   Size: 4.8 KiB   History

package smoke

import (
	"context"
	"io"
	"sync"
	"time"
)

const (
	// serialTailMax bounds what one tail holds in memory. It matches the
	// agent's own console ring, so the smoke keeps exactly as much history as
	// the host is willing to replay — enough for a boot log.
	serialTailMax = 256 << 10

	// consoleRetryDelay paces re-attaching after the stream ends. The console
	// goes away for real mid-run: a host tears a VM's console down when the VM
	// powers off, so the whole power-cycle window is one long re-attach.
	consoleRetryDelay = time.Second
)

// consoleDialer attaches to a VM's serial console and returns the byte pipe.
// The live implementation is the API client's ticket-then-dial WebSocket.
type consoleDialer func(ctx context.Context, vmID string) (io.ReadWriteCloser, error)

// consoleTail watches one VM's serial console. It keeps the console attached —
// re-dialing whenever the stream ends, because a guest that is mid-power-cycle
// has no console to attach to — and accumulates the printable text the guest
// has produced, which the boot proofs read with text().
//
// mark() draws a line under everything collected so far. That is what makes a
// second boot provable: the console replays a host's recent backlog on attach,
// so without a line the first boot's login prompt would answer for the second.
type consoleTail struct {
	mu   sync.Mutex
	buf  []byte
	last error // most recent dial/read failure, for a proof that times out

	cancel context.CancelFunc
	done   chan struct{}
}

// attachConsole starts watching vmID's console. The tail runs until close().
func attachConsole(ctx context.Context, dial consoleDialer, vmID string) *consoleTail {
	return newConsoleTail(ctx, dial, vmID, consoleRetryDelay)
}

// newConsoleTail is attachConsole with the re-attach pace injected, so tests
// drive the drop-and-redial path without waiting out a real backoff.
func newConsoleTail(ctx context.Context, dial consoleDialer, vmID string, retry time.Duration) *consoleTail {
	ctx, cancel := context.WithCancel(ctx)
	t := &consoleTail{cancel: cancel, done: make(chan struct{})}
	go t.run(ctx, dial, vmID, retry)
	return t
}

// run keeps a stream attached for as long as the tail lives. A dial that fails
// and a stream that ends are the same thing here — the console is not there
// right now — and neither is fatal: the proofs' own deadlines decide when a
// console that never arrives becomes a failure.
func (t *consoleTail) run(ctx context.Context, dial consoleDialer, vmID string, retry time.Duration) {
	defer close(t.done)
	for ctx.Err() == nil {
		stream, err := dial(ctx, vmID)
		if err == nil {
			// Attached: whatever went wrong last time is history, and reporting
			// it against a console that is answering would be a lie.
			t.setErr(nil)
			t.consume(stream)
			stream.Close()
		} else {
			t.setErr(err)
		}
		select {
		case <-ctx.Done():
			return
		case <-time.After(retry):
		}
	}
}

// consume reads the stream to its end, collecting as it goes.
func (t *consoleTail) consume(r io.Reader) {
	buf := make([]byte, 4096)
	for {
		n, err := r.Read(buf)
		if n > 0 {
			t.collect(buf[:n])
		}
		if err != nil {
			t.setErr(err)
			return
		}
	}
}

// collect appends b's printable text to the tail, trimming the front past the
// cap.
func (t *consoleTail) collect(b []byte) {
	clean := sanitizeSerial(b)
	if len(clean) == 0 {
		return
	}
	t.mu.Lock()
	defer t.mu.Unlock()
	t.buf = append(t.buf, clean...)
	if over := len(t.buf) - serialTailMax; over > 0 {
		t.buf = t.buf[over:]
	}
}

func (t *consoleTail) setErr(err error) {
	t.mu.Lock()
	defer t.mu.Unlock()
	t.last = err
}

// text is what the guest has printed since the last mark.
func (t *consoleTail) text() string {
	t.mu.Lock()
	defer t.mu.Unlock()
	return string(t.buf)
}

// mark discards everything collected so far, so only what the guest prints
// after it can satisfy a later proof.
func (t *consoleTail) mark() {
	t.mu.Lock()
	defer t.mu.Unlock()
	t.buf = nil
	t.last = nil
}

// why renders the last transport failure as a trailing clause, so a proof that
// times out on an empty console says whether it was even connected.
func (t *consoleTail) why() string {
	t.mu.Lock()
	defer t.mu.Unlock()
	if t.last == nil {
		return ""
	}
	return "; last console error: " + t.last.Error()
}

// close detaches and waits for the watcher to finish.
func (t *consoleTail) close() {
	t.cancel()
	<-t.done
}

// sanitizeSerial keeps only what a serial console's text evidence lives in —
// tab, newline, carriage return and printable ASCII — dropping the escape
// sequences and control bytes a guest terminal emits, which would otherwise
// split a login prompt across bytes no pattern matches.
func sanitizeSerial(b []byte) []byte {
	out := make([]byte, 0, len(b))
	for _, c := range b {
		if c == '\t' || c == '\n' || c == '\r' || (c >= 0x20 && c <= 0x7e) {
			out = append(out, c)
		}
	}
	return out
}