tstest/natlab/vmtest: add macOS VM screenshot streaming to web UI
When --vmtest-web is set, Host.app is launched with --screenshot-port 0
to start a localhost HTTP server that captures the VZVirtualMachineView
display. The Go test harness parses the SCREENSHOT_PORT=<port> line from
stdout, then polls every 2 seconds for JPEG thumbnails and pushes them
over WebSocket to the web dashboard.
Clicking a screenshot thumbnail opens a full-resolution image proxied
through the web UI's /screenshot/{node} endpoint.
Screenshot events are excluded from the EventBus history (they're large
and only the latest matters, stored in NodeStatus.Screenshot).
Updates #13038
Change-Id: I9bc67ddd1cc72948b33c555d4be3d8db06a41f6d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
committed by
Brad Fitzpatrick
parent
78627c132f
commit
4cec06b8f2
@@ -4,9 +4,13 @@
|
||||
package vmtest
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -168,13 +172,16 @@ func (e *Env) startTailMacVM(n *Node) error {
|
||||
}
|
||||
e.t.Cleanup(func() { os.RemoveAll(cloneDir) })
|
||||
|
||||
// Launch Host.app in headless mode with disconnected NIC,
|
||||
// then hot-swap to the vnet dgram socket after boot.
|
||||
hostBin := filepath.Join(e.tailmacDir, "Host.app", "Contents", "MacOS", "Host")
|
||||
args := []string{
|
||||
"run", "--id", testID, "--headless",
|
||||
}
|
||||
|
||||
wantScreenshots := *vmtestWeb != ""
|
||||
if wantScreenshots {
|
||||
args = append(args, "--screenshot-port", "0")
|
||||
}
|
||||
|
||||
logPath := filepath.Join(e.tempDir, n.name+"-tailmac.log")
|
||||
logFile, err := os.Create(logPath)
|
||||
if err != nil {
|
||||
@@ -182,11 +189,22 @@ func (e *Env) startTailMacVM(n *Node) error {
|
||||
}
|
||||
|
||||
cmd := exec.Command(hostBin, args...)
|
||||
// NSUnbufferedIO forces Swift/Foundation to unbuffer stdout so we can
|
||||
// see output in the log file as it happens.
|
||||
cmd.Env = append(os.Environ(), "NSUnbufferedIO=YES")
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
// If screenshots are enabled, we need to parse stdout for the
|
||||
// SCREENSHOT_PORT=<port> line, while also logging everything to file.
|
||||
var stdoutPipe io.ReadCloser
|
||||
if wantScreenshots {
|
||||
stdoutPipe, err = cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
logFile.Close()
|
||||
return fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
cmd.Stderr = logFile
|
||||
} else {
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
}
|
||||
devNull, err := os.Open(os.DevNull)
|
||||
if err != nil {
|
||||
logFile.Close()
|
||||
@@ -201,6 +219,36 @@ func (e *Env) startTailMacVM(n *Node) error {
|
||||
}
|
||||
e.t.Logf("[%s] launched tailmac (pid %d), log: %s", n.name, cmd.Process.Pid, logPath)
|
||||
|
||||
// Parse screenshot port from stdout and start polling goroutine.
|
||||
if wantScreenshots {
|
||||
screenshotPortCh := make(chan int, 1)
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stdoutPipe)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fmt.Fprintln(logFile, line) // tee to log file
|
||||
if port := 0; strings.HasPrefix(line, "SCREENSHOT_PORT=") {
|
||||
fmt.Sscanf(line, "SCREENSHOT_PORT=%d", &port)
|
||||
if port > 0 {
|
||||
screenshotPortCh <- port
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
select {
|
||||
case port := <-screenshotPortCh:
|
||||
e.t.Logf("[%s] screenshot server on port %d", n.name, port)
|
||||
e.setNodeScreenshotPort(n.name, port)
|
||||
e.tailScreenshots(n.name, port)
|
||||
case <-ctx.Done():
|
||||
e.t.Logf("[%s] screenshot port not received", n.name)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
clientSock := fmt.Sprintf("/tmp/qemu-dgram-%s.sock", testID)
|
||||
|
||||
e.t.Cleanup(func() {
|
||||
@@ -234,3 +282,32 @@ func (e *Env) startTailMacVM(n *Node) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// tailScreenshots polls the Host.app screenshot HTTP server every 2 seconds
|
||||
// and publishes each screenshot as a base64 data URI to the web UI.
|
||||
func (e *Env) tailScreenshots(name string, port int) {
|
||||
url := fmt.Sprintf("http://127.0.0.1:%d/screenshot", port)
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString(data)
|
||||
dataURI := "data:image/jpeg;base64," + b64
|
||||
e.setNodeScreenshot(name, dataURI)
|
||||
e.eventBus.Publish(VMEvent{
|
||||
NodeName: name,
|
||||
Type: EventScreenshot,
|
||||
Message: b64,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user