a73x

internal/agent/vfkit/console_pty_linux_test.go

Ref:   Size: 6.6 KiB   History

//go:build linux

package vfkit

import (
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"syscall"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"golang.org/x/sys/unix"
)

// The rest of this package's console tests stand a regular file in for the PTY,
// which is fair for what Open owes its caller — a stream at the path vfkit
// named — and is exactly why the line discipline went unnoticed for so long: a
// regular file has none. These tests use a real pty pair, so ECHO and ICANON
// are in play the way they are on a Mac. Linux-only for the pty plumbing
// (/dev/ptmx, TIOCGPTN); the behaviour under test is POSIX and identical on the
// platform this backend ships to.

// openPTY returns the master half of a fresh pty pair and the path of its
// slave — the shape vfkit hands the agent: vfkit holds the master and drives
// the guest's serial line through it, and reports the slave for us to open.
// The master is left non-blocking so a test can assert that nothing arrived.
func openPTY(t *testing.T) (masterFD int, slave string) {
	t.Helper()
	master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY, 0)
	if err != nil {
		t.Skipf("no pty available on this host: %v", err)
	}
	t.Cleanup(func() { _ = master.Close() })
	fd := int(master.Fd())
	n, err := unix.IoctlGetInt(fd, unix.TIOCGPTN)
	if err != nil {
		t.Skipf("cannot number the pty: %v", err)
	}
	// TIOCSPTLCK takes a POINTER to the lock value, so the pointer form of the
	// ioctl is the one that unlocks rather than returning EFAULT.
	if err := unix.IoctlSetPointerInt(fd, unix.TIOCSPTLCK, 0); err != nil {
		t.Skipf("cannot unlock the pty: %v", err)
	}
	require.NoError(t, unix.SetNonblock(fd, true))
	return fd, fmt.Sprintf("/dev/pts/%d", n)
}

// openConsole opens a console over a real pty, the way the pump does: vfkit
// reports the slave's path over its REST socket and Open takes it from there.
func openConsole(t *testing.T) (console io.ReadWriteCloser, masterFD int) {
	t.Helper()
	fd, slave := openPTY(t)
	sock := inspectServer(t, http.StatusOK,
		`{"devices":[{"kind":"virtioserial","ptyName":"`+slave+`"}]}`)

	rwc, err := NewConsoleSource(func(string) string { return sock }).Open("vm-1")
	require.NoError(t, err)
	t.Cleanup(func() { _ = rwc.Close() })
	return rwc, fd
}

// writeGuestOutput puts bytes on the master, which is what the guest writing to
// its serial line looks like from this end.
func writeGuestOutput(t *testing.T, fd int, s string) {
	t.Helper()
	_, err := unix.Write(fd, []byte(s))
	require.NoError(t, err)
}

// readWithin drains fd until it holds want bytes or the wait runs out. It reads
// the descriptor directly, non-blocking, so a discipline that withholds the
// bytes fails the test instead of parking it.
func readWithin(t *testing.T, fd int, want int, wait time.Duration) string {
	t.Helper()
	require.NoError(t, unix.SetNonblock(fd, true))
	buf := make([]byte, 512)
	var out []byte
	for deadline := time.Now().Add(wait); len(out) < want && time.Now().Before(deadline); {
		n, err := unix.Read(fd, buf)
		if n > 0 {
			out = append(out, buf[:n]...)
			continue
		}
		if err != nil && err != unix.EAGAIN && err != unix.EINTR {
			t.Fatalf("read: %v", err)
		}
		time.Sleep(5 * time.Millisecond)
	}
	return string(out)
}

// consoleFD is the descriptor behind an opened console.
func consoleFD(t *testing.T, console io.ReadWriteCloser) int {
	t.Helper()
	f, ok := console.(*os.File)
	require.True(t, ok, "the console must be a file the test can read directly")
	return int(f.Fd())
}

func TestConsoleReadsGuestOutputThatHasNoNewline(t *testing.T) {
	console, master := openConsole(t)
	const prompt = "ubuntu login: "

	writeGuestOutput(t, master, prompt)

	// Under the default line discipline ICANON withholds everything up to the
	// next newline, and a login prompt, a shell prompt and `Password:` are all
	// exactly that — so the moments an operator most needs to see never reach
	// serial.log or a live viewer at all.
	assert.Equal(t, prompt, readWithin(t, consoleFD(t, console), len(prompt), 2*time.Second),
		"the console withheld output that carries no newline")
}

func TestConsoleDoesNotEchoTheGuestsOutputBackAtIt(t *testing.T) {
	_, master := openConsole(t)

	writeGuestOutput(t, master, "Ubuntu 26.04 LTS ubuntu ttyS0\n")

	// Everything the guest writes lands in the slave's INPUT queue, so with
	// ECHO on the kernel types the guest's own boot log back at it: a getty at
	// the login prompt answers itself, and the console fills with the guest
	// replying to its own output.
	assert.Empty(t, readWithin(t, master, 1, 200*time.Millisecond),
		"the guest's own output was echoed back to it as console input")
}

func TestClosingTheConsoleUnblocksAReadInFlight(t *testing.T) {
	console, _ := openConsole(t)
	read := make(chan error, 1)
	go func() {
		buf := make([]byte, 1)
		_, err := console.Read(buf)
		read <- err
	}()
	time.Sleep(50 * time.Millisecond) // let the read park

	require.NoError(t, console.Close())

	// This is how the pump stops a console: it closes the stream to release
	// the goroutine sitting in Read. That only works while the runtime poller
	// owns the descriptor, so anything Open does to the fd has to borrow it
	// (SyscallConn) rather than take it (Fd) — see rawMode.
	select {
	case <-read:
	case <-time.After(2 * time.Second):
		t.Fatal("closing the console left the drain goroutine parked in Read — the pump can never stop it")
	}
}

// ptyChildEnv marks the re-executed half of the controlling-terminal test.
const ptyChildEnv = "EITRI_VFKIT_PTY_CHILD"

func TestConsoleDoesNotTakeTheAgentsControllingTerminal(t *testing.T) {
	if os.Getenv(ptyChildEnv) == "1" {
		openConsole(t)
		// A process with no controlling terminal cannot open /dev/tty. If this
		// succeeds, opening the console took one — and every subsequent guest
		// that hangs up delivers SIGHUP to the agent, killing the fleet's
		// connection to this host along with every VM's reconcile.
		f, err := os.OpenFile("/dev/tty", os.O_RDWR|syscall.O_NOCTTY, 0)
		if err == nil {
			_ = f.Close()
			t.Fatal("the guest console became this process's controlling terminal — Open must pass O_NOCTTY")
		}
		return
	}

	// Only a session leader with no controlling terminal can acquire one, and
	// the test binary is neither, so the check has to run somewhere that is:
	// Setsid makes the child exactly that. Re-executing this same test is what
	// keeps the pty and the fake vfkit in one place.
	cmd := exec.Command(os.Args[0], "-test.run", "^"+t.Name()+"$", "-test.v")
	cmd.Env = append(os.Environ(), ptyChildEnv+"=1")
	cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}

	out, err := cmd.CombinedOutput()

	require.NoError(t, err, string(out))
}