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
}