internal/smoke/console_test.go
Ref: Size: 8.0 KiB History
package smoke
import (
"context"
"errors"
"io"
"strings"
"sync"
"testing"
"time"
)
// fakeConsole is a scripted serial console. Every attach opens a new stream and
// replays history, the way a host replays its console backlog to a viewer that
// attaches late; write puts fresh output on the stream currently attached and
// returns once the tail has taken it, so a test can order what it sends against
// what the scenario does next.
type fakeConsole struct {
mu sync.Mutex
history string
dials int
err error // when non-nil, attaching fails instead
w *io.PipeWriter
replayed chan int // dial number, once its history has been consumed
}
func newFakeConsole(history string) *fakeConsole {
return &fakeConsole{history: history, replayed: make(chan int, 16)}
}
// dial is the consoleDialer under test.
func (f *fakeConsole) dial(ctx context.Context, vmID string) (io.ReadWriteCloser, error) {
f.mu.Lock()
f.dials++
n := f.dials
history, err := f.history, f.err
if err != nil {
f.mu.Unlock()
return nil, err
}
pr, pw := io.Pipe()
f.w = pw
f.mu.Unlock()
go func() {
if history != "" {
if _, err := pw.Write([]byte(history)); err != nil {
return
}
// A pipe write returns once the reader has taken the bytes, which is
// a moment before it has collected them. This second write returns
// only after the reader comes back for more — which it does only
// after collecting the first — so the signal below means "collected",
// and a test that waits on it can safely mark the tail.
if _, err := pw.Write([]byte("\n")); err != nil {
return
}
}
f.replayed <- n
<-ctx.Done()
pw.CloseWithError(io.EOF)
}()
return pipeStream{r: pr, w: pw}, nil
}
// write puts text on the attached stream, blocking until it is consumed.
func (f *fakeConsole) write(t *testing.T, text string) {
t.Helper()
f.mu.Lock()
w := f.w
f.mu.Unlock()
if w == nil {
t.Fatal("fakeConsole: nothing is attached")
}
if _, err := w.Write([]byte(text)); err != nil {
t.Fatalf("fakeConsole write: %v", err)
}
}
// waitReplayed blocks until attach number n has had its history consumed.
func (f *fakeConsole) waitReplayed(t *testing.T, n int) {
t.Helper()
for {
select {
case got := <-f.replayed:
if got >= n {
return
}
case <-time.After(5 * time.Second):
t.Fatalf("fakeConsole: attach %d never replayed its history", n)
}
}
}
func (f *fakeConsole) attachCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.dials
}
func (f *fakeConsole) failWith(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.err = err
}
// pipeStream is one attached console: reads deliver the guest's output, writes
// (keystrokes) go nowhere.
type pipeStream struct {
r *io.PipeReader
w *io.PipeWriter
}
func (p pipeStream) Read(b []byte) (int, error) { return p.r.Read(b) }
func (p pipeStream) Write(b []byte) (int, error) { return len(b), nil }
func (p pipeStream) Close() error {
p.r.Close()
p.w.Close()
return nil
}
// waitForText blocks until the tail holds want, or fails the test.
func waitForText(t *testing.T, tail *consoleTail, want string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if strings.Contains(tail.text(), want) {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("console tail = %q, want it to contain %q", tail.text(), want)
}
func TestConsoleTailCollectsWhatTheGuestPrints(t *testing.T) {
console := newFakeConsole("Ubuntu 24.04 LTS ubuntu-vm login: ")
tail := newConsoleTail(context.Background(), console.dial, "vm-1", time.Millisecond)
defer tail.close()
waitForText(t, tail, "login:")
if booted, _ := classifySerial(tail.text()); !booted {
t.Errorf("console text %q does not read as a booted guest", tail.text())
}
}
// TestConsoleTailKeepsOnlyPrintableText pins the sanitizing: a raw console
// carries escape sequences and control bytes, and a login prompt split by them
// must still read as one.
func TestConsoleTailKeepsOnlyPrintableText(t *testing.T) {
console := newFakeConsole("\x1b[0;32m\x00ubuntu-vm\x07 login: \x1b[0m")
tail := newConsoleTail(context.Background(), console.dial, "vm-1", time.Millisecond)
defer tail.close()
waitForText(t, tail, "ubuntu-vm login: ")
if strings.ContainsAny(tail.text(), "\x00\x07\x1b") {
t.Errorf("console text %q still carries control bytes", tail.text())
}
}
// TestConsoleTailMarkDropsWhatCameBefore is the freshness rule the reboot proof
// rests on: a console replays history, so everything collected before the mark
// must be unable to answer for what happens after it.
func TestConsoleTailMarkDropsWhatCameBefore(t *testing.T) {
console := newFakeConsole("first boot: ubuntu-vm login: ")
tail := newConsoleTail(context.Background(), console.dial, "vm-1", time.Millisecond)
defer tail.close()
waitForText(t, tail, "first boot")
tail.mark()
if booted, _ := classifySerial(tail.text()); booted {
t.Errorf("after the mark the tail still reads as booted: %q", tail.text())
}
console.write(t, "[ 0.9] Booting Linux\nubuntu-vm login: ")
waitForText(t, tail, "Booting Linux")
if booted, _ := classifySerial(tail.text()); !booted {
t.Errorf("post-mark output %q should read as a booted guest", tail.text())
}
}
// TestConsoleTailReattachesAfterTheStreamDrops: the console goes away for real
// mid-run — a host tears it down while the VM is off — so the tail must come
// back on its own rather than leaving the proof watching nothing.
func TestConsoleTailReattachesAfterTheStreamDrops(t *testing.T) {
console := newFakeConsole("ubuntu-vm login: ")
ctx, cancel := context.WithCancel(context.Background())
tail := newConsoleTail(ctx, console.dial, "vm-1", time.Millisecond)
defer tail.close()
console.waitReplayed(t, 1)
// Drop the stream under the tail: the writer's close ends the read.
console.mu.Lock()
w := console.w
console.mu.Unlock()
w.CloseWithError(io.ErrUnexpectedEOF)
console.waitReplayed(t, 2)
if console.attachCount() < 2 {
t.Errorf("attaches = %d, want the tail to re-attach after the drop", console.attachCount())
}
waitForText(t, tail, "login:")
cancel()
}
// TestConsoleTailKeepsTryingWhenTheConsoleRefuses: a console that is not there
// yet is not a failure — the proof's deadline decides that — but the reason is
// kept, so a proof that times out can say it was never connected.
func TestConsoleTailKeepsTryingWhenTheConsoleRefuses(t *testing.T) {
console := newFakeConsole("ubuntu-vm login: ")
console.failWith(errors.New("console unavailable: host offline"))
tail := newConsoleTail(context.Background(), console.dial, "vm-1", time.Millisecond)
defer tail.close()
deadline := time.Now().Add(5 * time.Second)
for console.attachCount() < 3 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if console.attachCount() < 3 {
t.Fatalf("attaches = %d, want the tail to keep retrying a refused console", console.attachCount())
}
if why := tail.why(); !strings.Contains(why, "host offline") {
t.Errorf("why = %q, want the refusal to be reportable", why)
}
console.failWith(nil)
waitForText(t, tail, "login:")
if tail.why() != "" {
t.Errorf("why = %q, want no error once the console answers", tail.why())
}
}
func TestConsoleTailCloseStopsWatching(t *testing.T) {
console := newFakeConsole("ubuntu-vm login: ")
tail := newConsoleTail(context.Background(), console.dial, "vm-1", time.Millisecond)
console.waitReplayed(t, 1)
tail.close()
attaches := console.attachCount()
time.Sleep(20 * time.Millisecond)
if console.attachCount() != attaches {
t.Errorf("attaches went %d -> %d after close; the watcher is still running", attaches, console.attachCount())
}
}
// TestSanitizeSerialKeepsTheTextAndDropsTheRest pins the same character set the
// boot gate has always classified on: tab, newline, carriage return and
// printable ASCII survive; NULs, escapes and high bytes do not.
func TestSanitizeSerialKeepsTheTextAndDropsTheRest(t *testing.T) {
const want = "a\tb\r\nc[1md"
if got := string(sanitizeSerial([]byte("a\tb\r\nc\x00\x1b[1md\x80"))); got != want {
t.Errorf("sanitizeSerial = %q, want %q", got, want)
}
}