a73x

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)
	}
}