internal/agent/vfkit/console.go
Ref: Size: 6.8 KiB History
package vfkit
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"syscall"
"time"
"golang.org/x/term"
)
// ConsoleSource opens a VM's guest console. It satisfies
// serialpump.ConsoleSource; the func it wraps resolves a VM's vfkit REST
// socket path (production: Provisioner.SocketPath).
//
// vfkit does not serve the serial line on a socket the way cloud-hypervisor
// does — it allocates a PTY and reports the slave's path. So opening the
// console is two steps rather than one: ask the running VM where its PTY is,
// then open it. Both fail while the VM is down, and the pump's reopen loop is
// the retry for that.
//
// It is a struct rather than the bare func it wraps because it has to own one
// long-lived REST client: see newRESTClient for what a per-call one costs.
type ConsoleSource struct {
sock func(vmID string) string
rest *http.Client
}
// NewConsoleSource builds a console source over sock, which resolves a VM id to
// its vfkit REST socket path.
func NewConsoleSource(sock func(vmID string) string) *ConsoleSource {
return &ConsoleSource{sock: sock, rest: newRESTClient()}
}
// inspectTimeout bounds the /vm/inspect call. It is short because the pump
// calls Open inside its reconnect loop: a socket that is present but not
// answering must fail fast enough to back off, not park the pump.
const inspectTimeout = 3 * time.Second
// Open returns vmID's guest console as a bidirectional stream. The PTY is
// opened O_NOCTTY: without it, the first console the agent opens would become
// the agent process's controlling terminal, and a guest that hung up would
// deliver SIGHUP to the agent.
func (s *ConsoleSource) Open(vmID string) (io.ReadWriteCloser, error) {
pty, err := inspectPTY(s.rest, s.sock(vmID))
if err != nil {
return nil, err
}
f, err := os.OpenFile(pty, os.O_RDWR|syscall.O_NOCTTY, 0)
if err != nil {
return nil, err
}
if err := rawMode(f); err != nil {
_ = f.Close()
return nil, fmt.Errorf("raw mode %s: %w", pty, err)
}
return f, nil
}
// rawMode strips the line discipline off a freshly opened PTY slave, because
// its defaults are written for a human at a keyboard and this end of the line
// is a log drain and a relay.
//
// ECHO is the destructive one: everything the guest writes arrives in the
// slave's input queue, so with echo on the kernel feeds the guest's own boot
// log back to it as if someone had typed it — a getty sitting at the login
// prompt answers its own output, and the console fills with the guest's replies
// to itself. That one is not ours to have caused and is not conditional on this
// Open: the pair carries those defaults from the moment vfkit allocates it, so
// the echo runs whether or not anything ever opens the slave. We clear it
// because we hold the only descriptor that can. ICANON is ours — it withholds
// what THIS end reads, and it withholds anything not terminated by
// a newline, and the login prompt, the shell prompt and `Password:` are exactly
// that, so they never reach serial.log or a live viewer. OPOST would rewrite
// the bytes a viewer types on their way to the guest. term.MakeRaw clears all
// three (and ISIG with them).
//
// A path that is not a terminal has no line discipline to configure and is left
// alone: nothing in production hands us one — vfkit reports the pty it
// allocated — but the check keeps the failure legible instead of turning every
// Open against a plain file into an ioctl error.
//
// The descriptor is borrowed through SyscallConn rather than taken with Fd():
// Fd() moves the file out of the runtime poller and leaves it blocking, and the
// pump stops a console by closing it to unblock the goroutine parked in Read —
// which only works while the poller still owns the descriptor.
func rawMode(f *os.File) error {
rc, err := f.SyscallConn()
if err != nil {
return err
}
var ioctlErr error
if err := rc.Control(func(fd uintptr) {
if !term.IsTerminal(int(fd)) {
return
}
_, ioctlErr = term.MakeRaw(int(fd))
}); err != nil {
return err
}
return ioctlErr
}
// vmInspect is the part of vfkit's /vm/inspect response this package reads: a
// device list in which each entry names its own kind. Everything else vfkit
// reports about the VM — vcpus, memory, bootloader — is state we passed it, so
// there is nothing to learn from reading it back.
type vmInspect struct {
Devices []struct {
Kind string `json:"kind"`
PtyName string `json:"ptyName"`
} `json:"devices"`
}
const serialKind = "virtioserial"
// inspectPTY asks the VM on sock where its serial PTY is.
func inspectPTY(client *http.Client, sock string) (string, error) {
ctx, cancel := context.WithTimeout(withSocket(context.Background(), sock), inspectTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://vfkit/vm/inspect", nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("vfkit inspect: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return "", fmt.Errorf("vfkit inspect: HTTP %d", resp.StatusCode)
}
var vm vmInspect
if err := json.NewDecoder(resp.Body).Decode(&vm); err != nil {
return "", fmt.Errorf("vfkit inspect: %w", err)
}
for _, d := range vm.Devices {
if d.Kind == serialKind && d.PtyName != "" {
return d.PtyName, nil
}
}
return "", fmt.Errorf("vfkit inspect: no serial PTY yet")
}
type sockKey struct{}
func withSocket(ctx context.Context, sock string) context.Context {
return context.WithValue(ctx, sockKey{}, sock)
}
// newRESTClient builds the one client its owner uses for every vfkit REST call
// it will ever make. Building one per call is what this exists to stop: an
// http.Transport with a zero IdleConnTimeout never expires an idle connection,
// and each one holds a read goroutine, a write goroutine and a file descriptor
// alive for as long as the process runs, with the transport itself unreachable
// and so uncollectable. Both callers sit on retry loops — the serial pump
// reopens the console until it succeeds, reconcile calls Shutdown every tick
// past a stop request — so a VM that never answers leaked until the agent could
// no longer open a file.
//
// Keep-alives are off, and that is what makes one client safe for every VM at
// once: a pooled connection is keyed on the URL's host, which is the same
// placeholder for all of them, so a connection to one VM's socket could serve
// the next VM's inspect. Nothing is given up — a vfkit REST call is a one-shot
// against a socket that dies with its VM, and no second request ever follows
// close enough to reuse it.
func newRESTClient() *http.Client {
return &http.Client{
Timeout: inspectTimeout,
Transport: &http.Transport{
DisableKeepAlives: true,
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
sock, _ := ctx.Value(sockKey{}).(string)
return (&net.Dialer{}).DialContext(ctx, "unix", sock)
},
},
}
}