a73x

internal/agent/vfkit/console_test.go

Ref:   Size: 6.5 KiB   History

package vfkit

import (
	"io"
	"net"
	"net/http"
	"os"
	"path/filepath"
	"runtime"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// inspectServer answers /vm/inspect on a unix socket with the given body and
// status, and returns the socket path.
func inspectServer(t *testing.T, status int, body string) string {
	t.Helper()
	sock := filepath.Join(t.TempDir(), "vfkit.sock")
	ln, err := net.Listen("unix", sock)
	require.NoError(t, err)
	srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		assert.Equal(t, "/vm/inspect", r.URL.Path)
		w.WriteHeader(status)
		_, _ = io.WriteString(w, body)
	})}
	go func() { _ = srv.Serve(ln) }()
	t.Cleanup(func() { _ = srv.Close() })
	return sock
}

func TestConsoleOpensThePTYVfkitReports(t *testing.T) {
	// A regular file stands in for the PTY slave: what Open owes its caller is
	// a bidirectional stream at the path vfkit named, and the pty-ness of that
	// path is the kernel's business, not this package's.
	pty := filepath.Join(t.TempDir(), "ttys004")
	require.NoError(t, os.WriteFile(pty, nil, 0o600))
	sock := inspectServer(t, http.StatusOK, `{"vcpus":2,"devices":[
		{"kind":"virtioblk","imagePath":"/disk.raw"},
		{"kind":"virtioserial","usesPty":true,"ptyName":"`+pty+`"},
		{"kind":"virtiorng"}]}`)

	rwc, err := NewConsoleSource(func(string) string { return sock }).Open("vm-1")

	require.NoError(t, err)
	defer rwc.Close()
	// Writable as well as readable: the console carries keystrokes back to the
	// guest, which is why this backend asks vfkit for a pty and not a log file.
	_, err = rwc.Write([]byte("uname -a\n"))
	assert.NoError(t, err)
	require.NoError(t, rwc.Close())
	got, err := os.ReadFile(pty)
	require.NoError(t, err)
	assert.Equal(t, "uname -a\n", string(got))
}

func TestConsoleFailsWhileTheVMIsStillComingUp(t *testing.T) {
	// vfkit fills ptyName in as it builds the VM, so a serial device with no
	// pty yet is the normal answer in the first moments after Boot. The pump's
	// reopen loop is the retry — Open must fail rather than hand back nothing.
	sock := inspectServer(t, http.StatusOK, `{"devices":[{"kind":"virtioserial","usesPty":true}]}`)

	_, err := NewConsoleSource(func(string) string { return sock }).Open("vm-1")

	require.Error(t, err)
	assert.Contains(t, err.Error(), "no serial PTY")
}

func TestConsoleFailsWhenTheVMHasNoSerialDevice(t *testing.T) {
	sock := inspectServer(t, http.StatusOK, `{"devices":[{"kind":"virtioblk"}]}`)

	_, err := NewConsoleSource(func(string) string { return sock }).Open("vm-1")

	assert.Error(t, err)
}

func TestConsoleFailsWhenVfkitIsNotListening(t *testing.T) {
	missing := filepath.Join(t.TempDir(), "vfkit.sock")

	// The VM is down, or was never booted. This is the pump's steady state
	// between a Shutdown and the next Boot, so it must be an ordinary error.
	_, err := NewConsoleSource(func(string) string { return missing }).Open("vm-1")

	require.Error(t, err)
	assert.Contains(t, err.Error(), "vfkit inspect")
}

func TestConsoleFailsOnAnUnhappyVfkit(t *testing.T) {
	sock := inspectServer(t, http.StatusInternalServerError, `{"error":"boom"}`)

	_, err := NewConsoleSource(func(string) string { return sock }).Open("vm-1")

	require.Error(t, err)
	assert.Contains(t, err.Error(), "500")
}

func TestConsoleFailsOnAnUnreadableAnswer(t *testing.T) {
	sock := inspectServer(t, http.StatusOK, `not json`)

	_, err := NewConsoleSource(func(string) string { return sock }).Open("vm-1")

	assert.Error(t, err)
}

func TestConsoleFailsWhenThePTYPathIsGone(t *testing.T) {
	gone := filepath.Join(t.TempDir(), "ttys004")
	sock := inspectServer(t, http.StatusOK,
		`{"devices":[{"kind":"virtioserial","ptyName":"`+gone+`"}]}`)

	_, err := NewConsoleSource(func(string) string { return sock }).Open("vm-1")

	assert.Error(t, err)
}

// restCallCount is how many REST calls the leak tests make. Each leaked
// connection costs a read goroutine and a write goroutine, so this is an order
// of magnitude clear of the tolerance below.
const restCallCount = 100

// assertNoGoroutineGrowth fails if call leaves goroutines behind. It is how the
// REST client's lifetime is pinned: one built per call never expires its idle
// connections, and each one parks a readLoop and a writeLoop forever. The
// tolerance is for the server side's own bookkeeping, which drains on its own
// schedule — hence the wait rather than a single reading.
func assertNoGoroutineGrowth(t *testing.T, call func()) {
	t.Helper()
	before := runtime.NumGoroutine()
	call()
	// Poll by hand rather than require.Eventually: the counts belong in the
	// failure message, and testify evaluates those before the condition runs.
	after := runtime.NumGoroutine()
	for deadline := time.Now().Add(5 * time.Second); after >= before+10 && time.Now().Before(deadline); {
		time.Sleep(50 * time.Millisecond)
		after = runtime.NumGoroutine()
	}
	assert.Less(t, after, before+10,
		"%d REST calls left goroutines behind: %d before, %d after", restCallCount, before, after)
}

func TestConsoleDoesNotLeakAConnectionPerInspect(t *testing.T) {
	pty := filepath.Join(t.TempDir(), "ttys004")
	require.NoError(t, os.WriteFile(pty, nil, 0o600))
	sock := inspectServer(t, http.StatusOK,
		`{"devices":[{"kind":"virtioserial","ptyName":"`+pty+`"}]}`)
	s := NewConsoleSource(func(string) string { return sock })
	// One call first, so the server's own machinery is up and the measurement
	// covers only what the loop adds.
	rwc, err := s.Open("vm-1")
	require.NoError(t, err)
	require.NoError(t, rwc.Close())

	// Open runs inside the pump's reconnect loop, so this is not a synthetic
	// volume: a VM whose PTY never opens is asked this many times in minutes.
	assertNoGoroutineGrowth(t, func() {
		for range restCallCount {
			rwc, err := s.Open("vm-1")
			require.NoError(t, err)
			require.NoError(t, rwc.Close())
		}
	})
}

func TestConsoleAsksEachVMsOwnSocket(t *testing.T) {
	pty := filepath.Join(t.TempDir(), "ttys004")
	require.NoError(t, os.WriteFile(pty, nil, 0o600))
	socks := map[string]string{}
	for _, vmID := range []string{"vm-1", "vm-2"} {
		socks[vmID] = inspectServer(t, http.StatusOK,
			`{"devices":[{"kind":"virtioserial","ptyName":"`+pty+`"}]}`)
	}
	s := NewConsoleSource(func(vmID string) string { return socks[vmID] })

	// One client serves every VM, and a connection is pooled under the URL's
	// host — the same placeholder for all of them — so the second VM's inspect
	// must not be able to ride the first VM's connection to the wrong socket.
	for vmID := range socks {
		rwc, err := s.Open(vmID)
		require.NoError(t, err, vmID)
		require.NoError(t, rwc.Close())
	}
}