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