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