Each spawned clone now runs under a _console-proxy daemon that connects firecracker's ttyS0 (stdin/stdout) to a PTY and serves it on a Unix socket at clones/<id>/console.sock for the VM's lifetime. sudo ./fc-orch spawn 1 sudo ./fc-orch console 1 # Ctrl+] to detach spawnOne delegates VM startup to the proxy process (Setsid, detached) and waits for console.sock to appear before returning. Kill continues to work via PID files — proxy and firecracker PIDs are both recorded. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
105 lines
2.6 KiB
Go
105 lines
2.6 KiB
Go
package orchestrator
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"sync"
|
|
"syscall"
|
|
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
// ConnectConsole attaches the calling terminal to the serial console of the
|
|
// clone with the given ID. The connection is made over the Unix socket at
|
|
// {cloneDir}/console.sock served by the console-proxy process.
|
|
//
|
|
// Escape sequence: Ctrl+] (byte 0x1D) detaches without stopping the VM.
|
|
func ConnectConsole(cfg Config, id int) error {
|
|
sockPath := filepath.Join(cfg.BaseDir, "clones", strconv.Itoa(id), "console.sock")
|
|
if _, err := os.Stat(sockPath); err != nil {
|
|
return fmt.Errorf("clone %d has no console socket — is it running?", id)
|
|
}
|
|
|
|
conn, err := net.Dial("unix", sockPath)
|
|
if err != nil {
|
|
return fmt.Errorf("connect to console: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
|
return fmt.Errorf("console requires an interactive terminal")
|
|
}
|
|
|
|
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
|
|
if err != nil {
|
|
return fmt.Errorf("set raw terminal: %w", err)
|
|
}
|
|
defer term.Restore(int(os.Stdin.Fd()), oldState) //nolint:errcheck
|
|
|
|
// Ensure the terminal is restored even on SIGTERM / SIGHUP.
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP)
|
|
go func() {
|
|
<-sigCh
|
|
term.Restore(int(os.Stdin.Fd()), oldState) //nolint:errcheck
|
|
os.Exit(0)
|
|
}()
|
|
|
|
fmt.Fprintf(os.Stderr, "Connected to clone %d console. Escape: Ctrl+]\r\n", id)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
|
|
// stdin → VM (with Ctrl+] escape detection)
|
|
go func() {
|
|
defer wg.Done()
|
|
io.Copy(conn, &escapeReader{r: os.Stdin, cancel: cancel}) //nolint:errcheck
|
|
// Half-close so the proxy knows we're done sending.
|
|
if uc, ok := conn.(*net.UnixConn); ok {
|
|
uc.CloseWrite() //nolint:errcheck
|
|
}
|
|
}()
|
|
|
|
// VM → stdout
|
|
go func() {
|
|
defer wg.Done()
|
|
io.Copy(os.Stdout, conn) //nolint:errcheck
|
|
cancel() // VM exited or proxy closed the connection
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
conn.Close() // unblock both goroutines
|
|
wg.Wait()
|
|
|
|
fmt.Fprintf(os.Stderr, "\r\nDetached from clone %d.\r\n", id)
|
|
return nil
|
|
}
|
|
|
|
// escapeReader wraps an io.Reader and intercepts Ctrl+] (0x1D), calling cancel
|
|
// and returning io.EOF when the escape byte is seen.
|
|
type escapeReader struct {
|
|
r io.Reader
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func (e *escapeReader) Read(p []byte) (int, error) {
|
|
n, err := e.r.Read(p)
|
|
for i := 0; i < n; i++ {
|
|
if p[i] == 0x1D { // Ctrl+]
|
|
e.cancel()
|
|
// Return only the bytes before the escape so nothing leaks to the VM.
|
|
return i, io.EOF
|
|
}
|
|
}
|
|
return n, err
|
|
}
|